Skip to content

Commit 0d4e54d

Browse files
author
Dynxsty
authored
Merge pull request #77 from Java-Discord/andrew/help-moderation
Add Automated Help Channel Moderation System [Beta]
2 parents 8795d00 + df30688 commit 0d4e54d

File tree

11 files changed

+424
-4
lines changed

11 files changed

+424
-4
lines changed

src/main/java/com/javadiscord/javabot/Bot.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.javadiscord.javabot.data.H2DataSource;
44
import com.javadiscord.javabot.events.*;
5+
import com.javadiscord.javabot.help.HelpChannelListener;
56
import com.javadiscord.javabot.properties.config.BotConfig;
67
import net.dv8tion.jda.api.JDA;
78
import net.dv8tion.jda.api.JDABuilder;
@@ -92,7 +93,8 @@ private static void addEventListeners(JDA jda) {
9293
new AutoMod(),
9394
new SubmissionListener(),
9495
new StarboardListener(),
95-
new InteractionListener()
96+
new InteractionListener(),
97+
new HelpChannelListener()
9698
);
9799
}
98100
}

src/main/java/com/javadiscord/javabot/events/InteractionListener.java

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
import com.google.gson.JsonObject;
44
import com.google.gson.JsonParser;
5+
import com.javadiscord.javabot.Bot;
6+
import com.javadiscord.javabot.help.HelpChannelManager;
57
import com.mongodb.client.MongoCollection;
68
import com.mongodb.client.MongoDatabase;
7-
import net.dv8tion.jda.api.entities.Guild;
8-
import net.dv8tion.jda.api.entities.Member;
9-
import net.dv8tion.jda.api.entities.Role;
9+
import lombok.extern.slf4j.Slf4j;
10+
import net.dv8tion.jda.api.entities.*;
1011
import net.dv8tion.jda.api.events.interaction.ButtonClickEvent;
1112
import net.dv8tion.jda.api.hooks.ListenerAdapter;
1213
import org.bson.Document;
@@ -15,6 +16,7 @@
1516
import static com.javadiscord.javabot.events.Startup.preferredGuild;
1617
import static com.mongodb.client.model.Filters.eq;
1718

19+
@Slf4j
1820
public class InteractionListener extends ListenerAdapter {
1921

2022
// TODO: add Context-Menu Commands (once they're available in JDA)
@@ -31,6 +33,7 @@ public void onButtonClick(ButtonClickEvent event) {
3133
case "dm-submission" -> this.handleDmSubmission(database, guild, event);
3234
case "submission" -> this.handleSubmission(database, guild, event);
3335
case "reaction-role" -> this.handleReactionRoles(event);
36+
case "help-channel" -> this.handleHelpChannel(event, id[1]);
3437
}
3538
}
3639

@@ -81,4 +84,30 @@ private void handleReactionRoles(ButtonClickEvent event) {
8184
event.getHook().sendMessage("Added Role: " + role.getAsMention()).setEphemeral(true).queue();
8285
}
8386
}
87+
88+
private void handleHelpChannel(ButtonClickEvent event, String action) {
89+
var config = Bot.config.get(event.getGuild()).getHelp();
90+
var channelManager = new HelpChannelManager(config);
91+
TextChannel channel = event.getTextChannel();
92+
User owner = channelManager.getReservedChannelOwner(channel);
93+
if (owner == null) {
94+
return; // This channel will be pruned automatically.
95+
}
96+
97+
if (
98+
event.getUser().equals(owner) ||
99+
(event.getMember() != null && event.getMember().getRoles().contains(Bot.config.get(event.getGuild()).getModeration().getStaffRole()))
100+
) {
101+
if (action.equals("done")) {
102+
log.info("Removing reserved channel {} because it was marked as done.", channel.getAsMention());
103+
channel.delete().queue();
104+
} else if (action.equals("not-done")) {
105+
if (event.getMessage() != null) {
106+
log.info("Removing timeout check message in {} because it was marked as not-done.", channel.getAsMention());
107+
event.getMessage().delete().queue();
108+
channel.sendMessage("Okay, we'll keep this channel reserved for you, and check again in **" + config.getInactivityTimeoutMinutes() + "** minutes.").queue();
109+
}
110+
}
111+
}
112+
}
84113
}

src/main/java/com/javadiscord/javabot/events/Startup.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import ch.qos.logback.classic.LoggerContext;
66
import com.javadiscord.javabot.Bot;
77
import com.javadiscord.javabot.commands.other.Version;
8+
import com.javadiscord.javabot.help.HelpChannelUpdater;
89
import com.javadiscord.javabot.other.Database;
910
import com.javadiscord.javabot.other.Misc;
1011
import com.mongodb.MongoClient;
@@ -92,6 +93,10 @@ public void onReady(ReadyEvent event) {
9293
new Database().deleteOpenSubmissions(guild);
9394
new StarboardListener().updateAllSBM(guild);
9495
Bot.slashCommands.registerSlashCommands(guild);
96+
97+
// Schedule the help channel updater to run periodically for each guild.
98+
var helpConfig = Bot.config.get(guild).getHelp();
99+
Bot.asyncPool.scheduleAtFixedRate(new HelpChannelUpdater(event.getJDA(), helpConfig), 5, helpConfig.getUpdateIntervalSeconds(), TimeUnit.SECONDS);
95100
}
96101

97102

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.javadiscord.javabot.help;
2+
3+
import com.javadiscord.javabot.properties.config.guild.HelpConfig;
4+
import net.dv8tion.jda.api.entities.TextChannel;
5+
6+
import java.util.List;
7+
import java.util.concurrent.ThreadLocalRandom;
8+
9+
/**
10+
* Naming strategy that names help channels with a random letter. Note that the
11+
* chance for duplicates is quite high!
12+
*/
13+
public class AlphabetNamingStrategy implements ChannelNamingStrategy {
14+
private static final String ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
15+
16+
@Override
17+
public String getName(List<TextChannel> channels, HelpConfig config) {
18+
return config.getOpenChannelPrefix() + "help-" + ALPHABET.charAt(ThreadLocalRandom.current().nextInt(ALPHABET.length()));
19+
}
20+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.javadiscord.javabot.help;
2+
3+
import com.javadiscord.javabot.properties.config.guild.HelpConfig;
4+
import net.dv8tion.jda.api.entities.TextChannel;
5+
6+
import java.util.List;
7+
import java.util.concurrent.ThreadLocalRandom;
8+
9+
/**
10+
* A naming strategy that names channels with a random animal name.
11+
*/
12+
public class AnimalNamingStrategy implements ChannelNamingStrategy {
13+
private static final String[] ANIMALS = {
14+
"bear", "tiger", "lion", "snake", "cheetah", "panther", "bat", "mosquito", "opossum", "raccoon", "beaver",
15+
"walrus", "seal", "dolphin", "shark", "narwhal", "orca", "whale", "squid", "tuna", "nautilus", "jellyfish",
16+
"seagull", "eagle", "hawk", "flamingo", "spoonbill", "puffin", "condor", "albatross", "parrot", "parakeet",
17+
"rabbit", "sloth", "deer", "boar", "ferret", "dog", "cat", "marmoset", "mole", "lizard", "kangaroo"
18+
};
19+
20+
@Override
21+
public String getName(List<TextChannel> channels, HelpConfig config) {
22+
String name = ANIMALS[ThreadLocalRandom.current().nextInt(ANIMALS.length)];
23+
return config.getOpenChannelPrefix() + "help-" + name;
24+
}
25+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.javadiscord.javabot.help;
2+
3+
import com.javadiscord.javabot.properties.config.guild.HelpConfig;
4+
import net.dv8tion.jda.api.entities.TextChannel;
5+
6+
import java.util.List;
7+
8+
/**
9+
* A strategy to use to generate names for new help channels as they're needed.
10+
*/
11+
public interface ChannelNamingStrategy {
12+
String getName(List<TextChannel> channels, HelpConfig config);
13+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.javadiscord.javabot.help;
2+
3+
import com.javadiscord.javabot.Bot;
4+
import net.dv8tion.jda.api.entities.Category;
5+
import net.dv8tion.jda.api.entities.TextChannel;
6+
import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent;
7+
import net.dv8tion.jda.api.hooks.ListenerAdapter;
8+
import org.jetbrains.annotations.NotNull;
9+
10+
/**
11+
* This listener is responsible for handling messages that are sent in one or
12+
* more designated help channels.
13+
*/
14+
public class HelpChannelListener extends ListenerAdapter {
15+
16+
@Override
17+
public void onGuildMessageReceived(@NotNull GuildMessageReceivedEvent event) {
18+
if (event.getAuthor().isBot() || event.getAuthor().isSystem()) return;
19+
20+
var config = Bot.config.get(event.getGuild()).getHelp();
21+
TextChannel channel = event.getChannel();
22+
Category category = channel.getParent();
23+
if (category == null || !category.equals(config.getHelpChannelCategory())) return;
24+
var channelManager = new HelpChannelManager(config);
25+
26+
// If a message was sent in an open text channel, reserve it.
27+
if (channel.getName().startsWith(config.getOpenChannelPrefix())) {
28+
channelManager.reserve(channel, event.getAuthor());
29+
}
30+
}
31+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package com.javadiscord.javabot.help;
2+
3+
import com.javadiscord.javabot.properties.config.guild.HelpConfig;
4+
import lombok.extern.slf4j.Slf4j;
5+
import net.dv8tion.jda.api.entities.TextChannel;
6+
import net.dv8tion.jda.api.entities.User;
7+
8+
import java.util.Objects;
9+
import java.util.regex.Pattern;
10+
11+
/**
12+
* This manager is responsible for all the main interactions that affect the
13+
* help system's channels.
14+
*/
15+
@Slf4j
16+
public class HelpChannelManager {
17+
private final HelpConfig config;
18+
19+
public HelpChannelManager(HelpConfig config) {
20+
this.config = config;
21+
}
22+
23+
public boolean isOpen(TextChannel channel) {
24+
return channel.getName().startsWith(config.getOpenChannelPrefix());
25+
}
26+
27+
public boolean isReserved(TextChannel channel) {
28+
return channel.getName().startsWith(config.getReservedChannelPrefix());
29+
}
30+
31+
/**
32+
* Opens a text channel so that it is ready for a new question.
33+
*/
34+
public void openNew() {
35+
var category = config.getHelpChannelCategory();
36+
if (category == null) throw new IllegalStateException("Missing help channel category. Cannot open a new help channel.");
37+
String name = this.config.getChannelNamingStrategy().getName(category.getTextChannels(), config);
38+
category.createTextChannel(name).queue(channel -> {
39+
channel.getManager().setPosition(0).setTopic("Ask a question here!").queue();
40+
log.info("Created new help channel {}.", channel.getAsMention());
41+
});
42+
}
43+
44+
/**
45+
* Reserves a text channel for a user.
46+
* @param channel The channel to reserve.
47+
* @param reservingUser The user who is reserving the channel.
48+
*/
49+
public void reserve(TextChannel channel, User reservingUser) {
50+
String rawChannelName = channel.getName().substring(config.getOpenChannelPrefix().length());
51+
channel.getManager()
52+
.setName(config.getReservedChannelPrefix() + rawChannelName)
53+
.setPosition(Objects.requireNonNull(channel.getParent()).getTextChannels().size())
54+
.setTopic(String.format(
55+
"Reserved for %s\n(_id=%s_)",
56+
reservingUser.getAsTag(),
57+
reservingUser.getId()
58+
)).queue();
59+
log.info("Reserved channel {} for {}.", channel.getAsMention(), reservingUser.getAsTag());
60+
openNew(); // Open a new channel immediately, to keep things balanced.
61+
}
62+
63+
/**
64+
* Gets the owner of a reserved channel.
65+
* @param channel The channel to get the owner of.
66+
* @return The user who reserved the channel, or null.
67+
*/
68+
public User getReservedChannelOwner(TextChannel channel) {
69+
var pattern = Pattern.compile("\\(_id=(\\d+)_\\)");
70+
if (channel.getTopic() != null) {
71+
var matcher = pattern.matcher(channel.getTopic());
72+
if (matcher.find()) {
73+
String id = matcher.group(1);
74+
return channel.getJDA().retrieveUserById(id).complete();
75+
}
76+
}
77+
return null;
78+
}
79+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package com.javadiscord.javabot.help;
2+
3+
import com.javadiscord.javabot.properties.config.guild.HelpConfig;
4+
import lombok.extern.slf4j.Slf4j;
5+
import net.dv8tion.jda.api.JDA;
6+
import net.dv8tion.jda.api.entities.Message;
7+
import net.dv8tion.jda.api.entities.TextChannel;
8+
import net.dv8tion.jda.api.entities.User;
9+
import net.dv8tion.jda.api.interactions.components.ButtonStyle;
10+
import net.dv8tion.jda.internal.interactions.ButtonImpl;
11+
12+
import java.time.OffsetDateTime;
13+
import java.util.concurrent.TimeUnit;
14+
15+
/**
16+
* Task that updates all help channels in a particular guild.
17+
*/
18+
@Slf4j
19+
public class HelpChannelUpdater implements Runnable {
20+
private final JDA jda;
21+
private final HelpConfig config;
22+
private final HelpChannelManager channelManager;
23+
24+
public HelpChannelUpdater(JDA jda, HelpConfig config) {
25+
this.jda = jda;
26+
this.config = config;
27+
this.channelManager = new HelpChannelManager(config);
28+
}
29+
30+
31+
@Override
32+
public void run() {
33+
var category = config.getHelpChannelCategory();
34+
if (category == null) throw new IllegalStateException("Missing required help channel category. Cannot update help channels.");
35+
var channels = category.getTextChannels();
36+
int openChannelCount = 0;
37+
for (var channel : channels) {
38+
if (channelManager.isReserved(channel)) {
39+
this.checkReservedChannel(channel);
40+
} else if (channelManager.isOpen(channel) && this.checkOpenChannel(channel)) {
41+
openChannelCount++;
42+
}
43+
}
44+
while (openChannelCount < config.getPreferredOpenChannelCount()) {
45+
channelManager.openNew();
46+
openChannelCount++;
47+
}
48+
}
49+
50+
/**
51+
* Performs checks on a reserved channel. This will do several things:
52+
* <ul>
53+
* <li>
54+
* If the most recent message in the channel is old enough, it will
55+
* send an activity check message in the channel, asking the user to
56+
* confirm whether they're still using it.
57+
* </li>
58+
* <li>
59+
* If the most recent message is an activity check message, and it
60+
* has stuck around long enough without any response, then the
61+
* channel will be removed.
62+
* </li>
63+
* <li>
64+
* If for some reason we can't retrieve the owner of the channel,
65+
* like if they left the server, the channel will be removed.
66+
* </li>
67+
* </ul>
68+
* @param channel The channel to check.
69+
*/
70+
private void checkReservedChannel(TextChannel channel) {
71+
User owner = this.channelManager.getReservedChannelOwner(channel);
72+
if (owner == null) {
73+
log.info("Removing reserved channel {} because no owner could be found.", channel.getAsMention());
74+
channel.delete().queue();
75+
return;
76+
}
77+
channel.getHistory().retrievePast(1).queue(messages -> {
78+
Message mostRecentMessage = messages.isEmpty() ? null : messages.get(0);
79+
if (mostRecentMessage == null) {
80+
log.info("Removing reserved channel {} because no recent messages could be found.", channel.getAsMention());
81+
channel.delete().queue();
82+
return;
83+
}
84+
85+
// Check if the most recent message is a channel inactivity check, and check that it's old enough to surpass the remove timeout.
86+
if (
87+
mostRecentMessage.getAuthor().equals(this.jda.getSelfUser()) &&
88+
mostRecentMessage.getContentRaw().contains("Are you finished with this channel?") &&
89+
mostRecentMessage.getTimeCreated().plusMinutes(config.getRemoveTimeoutMinutes()).isBefore(OffsetDateTime.now())
90+
) {
91+
log.info("Removing reserved channel {} because of inactivity for {} minutes following inactive check.", channel.getAsMention(), config.getRemoveTimeoutMinutes());
92+
channel.sendMessage(String.format(
93+
"%s, this channel will be closed in 30 seconds due to prolonged inactivity. If your question still isn't answered, please ask again in an open channel.",
94+
owner.getAsMention()
95+
)).queue();
96+
channel.delete().queueAfter(30, TimeUnit.SECONDS);
97+
return;
98+
}
99+
100+
// The most recent message is not an activity check, so check if it's old enough to warrant sending an activity check.
101+
if (mostRecentMessage.getTimeCreated().plusMinutes(config.getInactivityTimeoutMinutes()).isBefore(OffsetDateTime.now())) {
102+
log.info("Sending inactivity check to {} because of no activity after {} minutes.", channel.getAsMention(), config.getInactivityTimeoutMinutes());
103+
channel.sendMessage(String.format(
104+
"Hey %s, it looks like this channel is inactive. Are you finished with this channel?\n\n> _If no response is received after %d minutes, this channel will be removed._",
105+
owner.getAsMention(),
106+
config.getRemoveTimeoutMinutes()
107+
))
108+
.setActionRow(
109+
new ButtonImpl("help-channel:done", "Yes, I'm done here!", ButtonStyle.SUCCESS, false, null),
110+
new ButtonImpl("help-channel:not-done", "No, I'm still using it.", ButtonStyle.DANGER, false, null)
111+
)
112+
.queue();
113+
}
114+
});
115+
}
116+
117+
private boolean checkOpenChannel(TextChannel channel) {
118+
boolean isEmpty = channel.getHistoryFromBeginning(1).complete().isEmpty();
119+
if (!isEmpty) {
120+
log.info("Removing non-empty open channel {}.", channel.getAsMention());
121+
channel.delete().complete();
122+
return false;
123+
}
124+
return true;
125+
}
126+
}

0 commit comments

Comments
 (0)