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
4 changes: 3 additions & 1 deletion src/main/java/com/javadiscord/javabot/Bot.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.javadiscord.javabot.data.H2DataSource;
import com.javadiscord.javabot.events.*;
import com.javadiscord.javabot.help.HelpChannelListener;
import com.javadiscord.javabot.properties.config.BotConfig;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
Expand Down Expand Up @@ -92,7 +93,8 @@ private static void addEventListeners(JDA jda) {
new AutoMod(),
new SubmissionListener(),
new StarboardListener(),
new InteractionListener()
new InteractionListener(),
new HelpChannelListener()
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.javadiscord.javabot.Bot;
import com.javadiscord.javabot.help.HelpChannelManager;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.MongoDatabase;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.entities.*;
import net.dv8tion.jda.api.events.interaction.ButtonClickEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.bson.Document;
Expand All @@ -15,6 +16,7 @@
import static com.javadiscord.javabot.events.Startup.preferredGuild;
import static com.mongodb.client.model.Filters.eq;

@Slf4j
public class InteractionListener extends ListenerAdapter {

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

Expand Down Expand Up @@ -81,4 +84,30 @@ private void handleReactionRoles(ButtonClickEvent event) {
event.getHook().sendMessage("Added Role: " + role.getAsMention()).setEphemeral(true).queue();
}
}

private void handleHelpChannel(ButtonClickEvent event, String action) {
var config = Bot.config.get(event.getGuild()).getHelp();
var channelManager = new HelpChannelManager(config);
TextChannel channel = event.getTextChannel();
User owner = channelManager.getReservedChannelOwner(channel);
if (owner == null) {
return; // This channel will be pruned automatically.
}

if (
event.getUser().equals(owner) ||
(event.getMember() != null && event.getMember().getRoles().contains(Bot.config.get(event.getGuild()).getModeration().getStaffRole()))
) {
if (action.equals("done")) {
log.info("Removing reserved channel {} because it was marked as done.", channel.getAsMention());
channel.delete().queue();
} else if (action.equals("not-done")) {
if (event.getMessage() != null) {
log.info("Removing timeout check message in {} because it was marked as not-done.", channel.getAsMention());
event.getMessage().delete().queue();
channel.sendMessage("Okay, we'll keep this channel reserved for you, and check again in **" + config.getInactivityTimeoutMinutes() + "** minutes.").queue();
}
}
}
}
}
5 changes: 5 additions & 0 deletions src/main/java/com/javadiscord/javabot/events/Startup.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import ch.qos.logback.classic.LoggerContext;
import com.javadiscord.javabot.Bot;
import com.javadiscord.javabot.commands.other.Version;
import com.javadiscord.javabot.help.HelpChannelUpdater;
import com.javadiscord.javabot.other.Database;
import com.javadiscord.javabot.other.Misc;
import com.mongodb.MongoClient;
Expand Down Expand Up @@ -92,6 +93,10 @@ public void onReady(ReadyEvent event) {
new Database().deleteOpenSubmissions(guild);
new StarboardListener().updateAllSBM(guild);
Bot.slashCommands.registerSlashCommands(guild);

// Schedule the help channel updater to run periodically for each guild.
var helpConfig = Bot.config.get(guild).getHelp();
Bot.asyncPool.scheduleAtFixedRate(new HelpChannelUpdater(event.getJDA(), helpConfig), 5, helpConfig.getUpdateIntervalSeconds(), TimeUnit.SECONDS);
}


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.javadiscord.javabot.help;

import com.javadiscord.javabot.properties.config.guild.HelpConfig;
import net.dv8tion.jda.api.entities.TextChannel;

import java.util.List;
import java.util.concurrent.ThreadLocalRandom;

/**
* Naming strategy that names help channels with a random letter. Note that the
* chance for duplicates is quite high!
*/
public class AlphabetNamingStrategy implements ChannelNamingStrategy {
private static final String ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

@Override
public String getName(List<TextChannel> channels, HelpConfig config) {
return config.getOpenChannelPrefix() + "help-" + ALPHABET.charAt(ThreadLocalRandom.current().nextInt(ALPHABET.length()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.javadiscord.javabot.help;

import com.javadiscord.javabot.properties.config.guild.HelpConfig;
import net.dv8tion.jda.api.entities.TextChannel;

import java.util.List;
import java.util.concurrent.ThreadLocalRandom;

/**
* A naming strategy that names channels with a random animal name.
*/
public class AnimalNamingStrategy implements ChannelNamingStrategy {
private static final String[] ANIMALS = {
"bear", "tiger", "lion", "snake", "cheetah", "panther", "bat", "mosquito", "opossum", "raccoon", "beaver",
"walrus", "seal", "dolphin", "shark", "narwhal", "orca", "whale", "squid", "tuna", "nautilus", "jellyfish",
"seagull", "eagle", "hawk", "flamingo", "spoonbill", "puffin", "condor", "albatross", "parrot", "parakeet",
"rabbit", "sloth", "deer", "boar", "ferret", "dog", "cat", "marmoset", "mole", "lizard", "kangaroo"
};

@Override
public String getName(List<TextChannel> channels, HelpConfig config) {
String name = ANIMALS[ThreadLocalRandom.current().nextInt(ANIMALS.length)];
return config.getOpenChannelPrefix() + "help-" + name;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.javadiscord.javabot.help;

import com.javadiscord.javabot.properties.config.guild.HelpConfig;
import net.dv8tion.jda.api.entities.TextChannel;

import java.util.List;

/**
* A strategy to use to generate names for new help channels as they're needed.
*/
public interface ChannelNamingStrategy {
String getName(List<TextChannel> channels, HelpConfig config);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.javadiscord.javabot.help;

import com.javadiscord.javabot.Bot;
import net.dv8tion.jda.api.entities.Category;
import net.dv8tion.jda.api.entities.TextChannel;
import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.jetbrains.annotations.NotNull;

/**
* This listener is responsible for handling messages that are sent in one or
* more designated help channels.
*/
public class HelpChannelListener extends ListenerAdapter {

@Override
public void onGuildMessageReceived(@NotNull GuildMessageReceivedEvent event) {
if (event.getAuthor().isBot() || event.getAuthor().isSystem()) return;

var config = Bot.config.get(event.getGuild()).getHelp();
TextChannel channel = event.getChannel();
Category category = channel.getParent();
if (category == null || !category.equals(config.getHelpChannelCategory())) return;
var channelManager = new HelpChannelManager(config);

// If a message was sent in an open text channel, reserve it.
if (channel.getName().startsWith(config.getOpenChannelPrefix())) {
channelManager.reserve(channel, event.getAuthor());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.javadiscord.javabot.help;

import com.javadiscord.javabot.properties.config.guild.HelpConfig;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.entities.TextChannel;
import net.dv8tion.jda.api.entities.User;

import java.util.Objects;
import java.util.regex.Pattern;

/**
* This manager is responsible for all the main interactions that affect the
* help system's channels.
*/
@Slf4j
public class HelpChannelManager {
private final HelpConfig config;

public HelpChannelManager(HelpConfig config) {
this.config = config;
}

public boolean isOpen(TextChannel channel) {
return channel.getName().startsWith(config.getOpenChannelPrefix());
}

public boolean isReserved(TextChannel channel) {
return channel.getName().startsWith(config.getReservedChannelPrefix());
}

/**
* Opens a text channel so that it is ready for a new question.
*/
public void openNew() {
var category = config.getHelpChannelCategory();
if (category == null) throw new IllegalStateException("Missing help channel category. Cannot open a new help channel.");
String name = this.config.getChannelNamingStrategy().getName(category.getTextChannels(), config);
category.createTextChannel(name).queue(channel -> {
channel.getManager().setPosition(0).setTopic("Ask a question here!").queue();
log.info("Created new help channel {}.", channel.getAsMention());
});
}

/**
* Reserves a text channel for a user.
* @param channel The channel to reserve.
* @param reservingUser The user who is reserving the channel.
*/
public void reserve(TextChannel channel, User reservingUser) {
String rawChannelName = channel.getName().substring(config.getOpenChannelPrefix().length());
channel.getManager()
.setName(config.getReservedChannelPrefix() + rawChannelName)
.setPosition(Objects.requireNonNull(channel.getParent()).getTextChannels().size())
.setTopic(String.format(
"Reserved for %s\n(_id=%s_)",
reservingUser.getAsTag(),
reservingUser.getId()
)).queue();
log.info("Reserved channel {} for {}.", channel.getAsMention(), reservingUser.getAsTag());
openNew(); // Open a new channel immediately, to keep things balanced.
}

/**
* Gets the owner of a reserved channel.
* @param channel The channel to get the owner of.
* @return The user who reserved the channel, or null.
*/
public User getReservedChannelOwner(TextChannel channel) {
var pattern = Pattern.compile("\\(_id=(\\d+)_\\)");
if (channel.getTopic() != null) {
var matcher = pattern.matcher(channel.getTopic());
if (matcher.find()) {
String id = matcher.group(1);
return channel.getJDA().retrieveUserById(id).complete();
}
}
return null;
}
}
126 changes: 126 additions & 0 deletions src/main/java/com/javadiscord/javabot/help/HelpChannelUpdater.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.javadiscord.javabot.help;

import com.javadiscord.javabot.properties.config.guild.HelpConfig;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.TextChannel;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.interactions.components.ButtonStyle;
import net.dv8tion.jda.internal.interactions.ButtonImpl;

import java.time.OffsetDateTime;
import java.util.concurrent.TimeUnit;

/**
* Task that updates all help channels in a particular guild.
*/
@Slf4j
public class HelpChannelUpdater implements Runnable {
private final JDA jda;
private final HelpConfig config;
private final HelpChannelManager channelManager;

public HelpChannelUpdater(JDA jda, HelpConfig config) {
this.jda = jda;
this.config = config;
this.channelManager = new HelpChannelManager(config);
}


@Override
public void run() {
var category = config.getHelpChannelCategory();
if (category == null) throw new IllegalStateException("Missing required help channel category. Cannot update help channels.");
var channels = category.getTextChannels();
int openChannelCount = 0;
for (var channel : channels) {
if (channelManager.isReserved(channel)) {
this.checkReservedChannel(channel);
} else if (channelManager.isOpen(channel) && this.checkOpenChannel(channel)) {
openChannelCount++;
}
}
while (openChannelCount < config.getPreferredOpenChannelCount()) {
channelManager.openNew();
openChannelCount++;
}
}

/**
* Performs checks on a reserved channel. This will do several things:
* <ul>
* <li>
* If the most recent message in the channel is old enough, it will
* send an activity check message in the channel, asking the user to
* confirm whether they're still using it.
* </li>
* <li>
* If the most recent message is an activity check message, and it
* has stuck around long enough without any response, then the
* channel will be removed.
* </li>
* <li>
* If for some reason we can't retrieve the owner of the channel,
* like if they left the server, the channel will be removed.
* </li>
* </ul>
* @param channel The channel to check.
*/
private void checkReservedChannel(TextChannel channel) {
User owner = this.channelManager.getReservedChannelOwner(channel);
if (owner == null) {
log.info("Removing reserved channel {} because no owner could be found.", channel.getAsMention());
channel.delete().queue();
return;
}
channel.getHistory().retrievePast(1).queue(messages -> {
Message mostRecentMessage = messages.isEmpty() ? null : messages.get(0);
if (mostRecentMessage == null) {
log.info("Removing reserved channel {} because no recent messages could be found.", channel.getAsMention());
channel.delete().queue();
return;
}

// Check if the most recent message is a channel inactivity check, and check that it's old enough to surpass the remove timeout.
if (
mostRecentMessage.getAuthor().equals(this.jda.getSelfUser()) &&
mostRecentMessage.getContentRaw().contains("Are you finished with this channel?") &&
mostRecentMessage.getTimeCreated().plusMinutes(config.getRemoveTimeoutMinutes()).isBefore(OffsetDateTime.now())
) {
log.info("Removing reserved channel {} because of inactivity for {} minutes following inactive check.", channel.getAsMention(), config.getRemoveTimeoutMinutes());
channel.sendMessage(String.format(
"%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.",
owner.getAsMention()
)).queue();
channel.delete().queueAfter(30, TimeUnit.SECONDS);
return;
}

// The most recent message is not an activity check, so check if it's old enough to warrant sending an activity check.
if (mostRecentMessage.getTimeCreated().plusMinutes(config.getInactivityTimeoutMinutes()).isBefore(OffsetDateTime.now())) {
log.info("Sending inactivity check to {} because of no activity after {} minutes.", channel.getAsMention(), config.getInactivityTimeoutMinutes());
channel.sendMessage(String.format(
"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._",
owner.getAsMention(),
config.getRemoveTimeoutMinutes()
))
.setActionRow(
new ButtonImpl("help-channel:done", "Yes, I'm done here!", ButtonStyle.SUCCESS, false, null),
new ButtonImpl("help-channel:not-done", "No, I'm still using it.", ButtonStyle.DANGER, false, null)
)
.queue();
}
});
}

private boolean checkOpenChannel(TextChannel channel) {
boolean isEmpty = channel.getHistoryFromBeginning(1).complete().isEmpty();
if (!isEmpty) {
log.info("Removing non-empty open channel {}.", channel.getAsMention());
channel.delete().complete();
return false;
}
return true;
}
}
Loading