Skip to content
Open
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: 6 additions & 1 deletion application/config.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -204,5 +204,10 @@
"rolePattern": "Top Helper.*",
"assignmentChannelPattern": "community-commands",
"announcementChannelPattern": "hall-of-fame"
}
},
"dynamicVoiceChannelPatterns": [
"Gaming",
"Support/Studying Room",
"Chit Chat"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public final class Config {
private final String memberCountCategoryPattern;
private final QuoteBoardConfig quoteBoardConfig;
private final TopHelpersConfig topHelpers;
private final List<String> dynamicVoiceChannelPatterns;

@SuppressWarnings("ConstructorWithTooManyParameters")
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
Expand Down Expand Up @@ -105,7 +106,9 @@ private Config(@JsonProperty(value = "token", required = true) String token,
required = true) String selectRolesChannelPattern,
@JsonProperty(value = "quoteBoardConfig",
required = true) QuoteBoardConfig quoteBoardConfig,
@JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers) {
@JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers,
@JsonProperty(value = "dynamicVoiceChannelPatterns",
required = true) List<String> dynamicVoiceChannelPatterns) {
this.token = Objects.requireNonNull(token);
this.githubApiKey = Objects.requireNonNull(githubApiKey);
this.databasePath = Objects.requireNonNull(databasePath);
Expand Down Expand Up @@ -142,6 +145,7 @@ private Config(@JsonProperty(value = "token", required = true) String token,
this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern);
this.quoteBoardConfig = Objects.requireNonNull(quoteBoardConfig);
this.topHelpers = Objects.requireNonNull(topHelpers);
this.dynamicVoiceChannelPatterns = Objects.requireNonNull(dynamicVoiceChannelPatterns);
}

/**
Expand Down Expand Up @@ -473,4 +477,13 @@ public RSSFeedsConfig getRSSFeedsConfig() {
public TopHelpersConfig getTopHelpers() {
return topHelpers;
}

/**
* Gets the list of voice channel patterns that are treated dynamically.
*
* @return the list of dynamic voice channel patterns
*/
public List<String> getDynamicVoiceChannelPatterns() {
return dynamicVoiceChannelPatterns;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
import org.togetherjava.tjbot.features.tophelper.TopHelpersMessageListener;
import org.togetherjava.tjbot.features.tophelper.TopHelpersPurgeMessagesRoutine;
import org.togetherjava.tjbot.features.tophelper.TopHelpersService;
import org.togetherjava.tjbot.features.voicechat.DynamicVoiceChat;

import java.util.ArrayList;
import java.util.Collection;
Expand Down Expand Up @@ -164,6 +165,9 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
features.add(new PinnedNotificationRemover(config));
features.add(new QuoteBoardForwarder(config));

// Voice receivers
features.add(new DynamicVoiceChat(config));

// Event receivers
features.add(new RejoinModerationRoleListener(actionsStore, config));
features.add(new GuildLeaveCloseThreadListener(config));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package org.togetherjava.tjbot.features.voicechat;

import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.MessageHistory;
import net.dv8tion.jda.api.entities.channel.concrete.Category;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion;
import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent;
import net.dv8tion.jda.api.managers.channel.middleman.AudioChannelManager;
import net.dv8tion.jda.api.requests.RestAction;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.togetherjava.tjbot.config.Config;
import org.togetherjava.tjbot.features.VoiceReceiverAdapter;

import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;

/**
* Handles dynamic voice channel creation and deletion based on user activity.
* <p>
* When a member joins a configured root channel, a temporary copy is created and the member is
* moved into it. Once the channel becomes empty, it is archived and further deleted using a
* {@link VoiceChatCleanupStrategy}.
*/
public final class DynamicVoiceChat extends VoiceReceiverAdapter {
private static final Logger logger = LoggerFactory.getLogger(DynamicVoiceChat.class);

private static final String ARCHIVE_CATEGORY_NAME = "Voice Channel Archives";
private static final int CLEAN_CHANNELS_AMOUNT = 25;
private static final int MINIMUM_CHANNELS_AMOUNT = 50;

private final VoiceChatCleanupStrategy voiceChatCleanupStrategy;
private final List<Pattern> dynamicVoiceChannelPatterns;

public DynamicVoiceChat(Config config) {
this.dynamicVoiceChannelPatterns =
config.getDynamicVoiceChannelPatterns().stream().map(Pattern::compile).toList();
this.voiceChatCleanupStrategy =
new OldestVoiceChatCleanup(CLEAN_CHANNELS_AMOUNT, MINIMUM_CHANNELS_AMOUNT);
}

@Override
public void onVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) {
AudioChannelUnion channelJoined = event.getChannelJoined();
AudioChannelUnion channelLeft = event.getChannelLeft();

if (channelJoined != null && eventHappenOnDynamicRootChannel(channelJoined)) {
logger.debug("Event happened on joined channel {}", channelJoined);
createDynamicVoiceChannel(event, channelJoined.asVoiceChannel());
}

if (channelLeft != null && !eventHappenOnDynamicRootChannel(channelLeft)) {
logger.debug("Event happened on left channel {}", channelLeft);

MessageHistory messageHistory = channelLeft.asVoiceChannel().getHistory();
messageHistory.retrievePast(2).queue(messages -> {
// Don't forget that there is always one
// embed message sent by the bot every time.
if (messages.size() > 1) {
archiveDynamicVoiceChannel(channelLeft);
} else {
channelLeft.delete().queue();
}
});
Comment on lines +64 to +72
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't you simply use MessageHistory#size instead?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In order to make use of MessageHistory#size, a method like MessageHistory#retrievePast needs to be invoked first because otherwise the size method will always return 0 regardless of the channel's history.

}
}

private boolean eventHappenOnDynamicRootChannel(AudioChannelUnion channel) {
return dynamicVoiceChannelPatterns.stream()
.anyMatch(pattern -> pattern.matcher(channel.getName()).matches());
}

private void createDynamicVoiceChannel(@NotNull GuildVoiceUpdateEvent event,
VoiceChannel channel) {
Guild guild = event.getGuild();
Member member = event.getMember();
String newChannelName = "%s's %s".formatted(member.getEffectiveName(), channel.getName());

channel.createCopy()
.setName(newChannelName)
.setPosition(channel.getPositionRaw())
.onSuccess(newChannel -> {
moveMember(guild, member, newChannel);
sendWarningEmbed(newChannel);
})
.queue(newChannel -> logger.trace("Successfully created {} voice channel.",
newChannel.getName()),
error -> logger.error("Failed to create dynamic voice channel", error));
}

private void moveMember(Guild guild, Member member, AudioChannel channel) {
guild.moveVoiceMember(member, channel)
.queue(_ -> logger.trace(
"Successfully moved {} to newly created dynamic voice channel {}",
member.getEffectiveName(), channel.getName()),
error -> logger.error(
"Failed to move user into dynamically created voice channel {}, {}",
member.getNickname(), channel.getName(), error));
}

private void archiveDynamicVoiceChannel(AudioChannelUnion channel) {
int memberCount = channel.getMembers().size();
String channelName = channel.getName();

if (memberCount > 0) {
logger.debug("Voice channel {} not empty ({} members), so not removing.", channelName,
memberCount);
return;
}

Optional<Category> archiveCategoryOptional = channel.getGuild()
.getCategoryCache()
.stream()
.filter(c -> c.getName().equalsIgnoreCase(ARCHIVE_CATEGORY_NAME))
.findFirst();

AudioChannelManager<?, ?> channelManager = channel.getManager();
RestAction<Void> restActionChain =
channelManager.setName(String.format("%s (Archived)", channelName))
.and(channel.getPermissionContainer().getManager().clearOverridesAdded());

if (archiveCategoryOptional.isEmpty()) {
logger.warn("Could not find archive category. Attempting to create one...");
channel.getGuild()
.createCategory(ARCHIVE_CATEGORY_NAME)
.queue(newCategory -> restActionChain.and(channelManager.setParent(newCategory))
.queue());
return;
}

archiveCategoryOptional.ifPresent(archiveCategory -> restActionChain
.and(channelManager.setParent(archiveCategory))
.queue(_ -> voiceChatCleanupStrategy.cleanup(archiveCategory.getVoiceChannels()),
err -> logger.error("Could not archive dynamic voice chat", err)));
}

private void sendWarningEmbed(VoiceChannel channel) {
MessageEmbed messageEmbed = new EmbedBuilder()
.addField("👋 Heads up!",
"""
This is a **temporary** voice chat channel. Messages sent here will be *cleared* once \
the channel is deleted when everyone leaves. If you need to keep something important, \
make sure to save it elsewhere. 💬
""",
false)
.build();

channel.sendMessageEmbeds(messageEmbed).queue();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package org.togetherjava.tjbot.features.voicechat;

import net.dv8tion.jda.api.entities.ISnowflake;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;

import java.util.Comparator;
import java.util.List;

/**
* Cleans up voice chats from an archive prioritizing the oldest {@link VoiceChannel}.
* <p>
* Considering a list of voice channels is provided with all of them obviously having a different
* addition time, the first few {@link VoiceChannel} elements provided, amounting to the value of
* <code>cleanChannelsAmount</code> will be removed from the guild.
* <p>
* The cleanup strategy will <i>not</i> be executed if the amount of voice channels does not exceed
* the value of <code>minimumChannelsAmountToTrigger</code>.
*/
final class OldestVoiceChatCleanup implements VoiceChatCleanupStrategy {

private final int cleanChannelsAmount;
private final int minimumChannelsAmountToTrigger;

OldestVoiceChatCleanup(int cleanChannelsAmount, int minimumChannelsAmountToTrigger) {
this.cleanChannelsAmount = cleanChannelsAmount;
this.minimumChannelsAmountToTrigger = minimumChannelsAmountToTrigger;
}

@Override
public void cleanup(List<VoiceChannel> voiceChannels) {
if (voiceChannels.size() < minimumChannelsAmountToTrigger) {
return;
}

voiceChannels.stream()
.sorted(Comparator.comparing(ISnowflake::getTimeCreated))
.limit(cleanChannelsAmount)
.forEach(voiceChannel -> voiceChannel.delete().queue());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.togetherjava.tjbot.features.voicechat;

import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;

import java.util.List;

/**
* Voice chat cleanup strategy interface for handling voice chat archive removal.
* <p>
* See provided implementation {@link OldestVoiceChatCleanup} for a more concrete usage example.
*/
public interface VoiceChatCleanupStrategy {

/**
* Attempts to delete the {@link VoiceChannel} channels from the Discord guild found in the
* inputted list.
*
* @param voiceChannels a list of voice channels to be considered for removal
*/
void cleanup(List<VoiceChannel> voiceChannels);
}
Loading