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
10 changes: 10 additions & 0 deletions application/config.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -204,5 +204,15 @@
"rolePattern": "Top Helper.*",
"assignmentChannelPattern": "community-commands",
"announcementChannelPattern": "hall-of-fame"
},
"dynamicVoiceChatConfig": {
"dynamicChannelPatterns": [
"Gaming",
"Support/Studying Room",
"Chit Chat"
],
"archiveCategoryPattern": "Voice Channel Archives",
"cleanChannelsAmount": 20,
"minimumChannelsAmount": 40
}
}
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 DynamicVoiceChatConfig dynamicVoiceChatConfig;

@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 = "dynamicVoiceChatConfig",
required = true) DynamicVoiceChatConfig dynamicVoiceChatConfig) {
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.dynamicVoiceChatConfig = Objects.requireNonNull(dynamicVoiceChatConfig);
}

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

/**
* Gets the dynamic voice chat configuration
*
* @return the dynamic voice chat configuration
*/
public DynamicVoiceChatConfig getDynamicVoiceChatConfig() {
return dynamicVoiceChatConfig;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.togetherjava.tjbot.config;

import com.fasterxml.jackson.annotation.JsonProperty;

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

/**
* Configuration for the dynamic voice chat feature.
*
* @param archiveCategoryPattern the name of the Discord Guild category in which the archived
* channels will go
* @param cleanChannelsAmount the amount of channels to clean once a cleanup is triggered
* @param minimumChannelsAmount the amount of voice channels for the archive category to have before
* a cleanup triggers
*/
public record DynamicVoiceChatConfig(
@JsonProperty(value = "dynamicChannelPatterns",
required = true) List<Pattern> dynamicChannelPatterns,
@JsonProperty(value = "archiveCategoryPattern",
required = true) String archiveCategoryPattern,
@JsonProperty(value = "cleanChannelsAmount") int cleanChannelsAmount,
@JsonProperty(value = "minimumChannelsAmount", required = true) int minimumChannelsAmount) {

public DynamicVoiceChatConfig {
Objects.requireNonNull(dynamicChannelPatterns);
Objects.requireNonNull(archiveCategoryPattern);
}
}
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,156 @@
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.config.DynamicVoiceChatConfig;
import org.togetherjava.tjbot.features.VoiceReceiverAdapter;

import java.util.Optional;

/**
* 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 final VoiceChatCleanupStrategy voiceChatCleanupStrategy;
private final DynamicVoiceChatConfig dynamicVoiceChannelConfig;

public DynamicVoiceChat(Config config) {
this.dynamicVoiceChannelConfig = config.getDynamicVoiceChatConfig();

this.voiceChatCleanupStrategy =
new OldestVoiceChatCleanup(dynamicVoiceChannelConfig.cleanChannelsAmount(),
dynamicVoiceChannelConfig.minimumChannelsAmount());
}

@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();
}
});
}
}

private boolean eventHappenOnDynamicRootChannel(AudioChannelUnion channel) {
return dynamicVoiceChannelConfig.dynamicChannelPatterns()
.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(dynamicVoiceChannelConfig.archiveCategoryPattern()))
.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(dynamicVoiceChannelConfig.archiveCategoryPattern())
.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