From 41a827f0fbdccc3c075d92fda9387e6fe94e3bfa Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Sun, 4 Jan 2026 02:28:02 +0200 Subject: [PATCH 1/8] feat(DynamicVoiceChat): implement main logic Co-authored-by: Suraj Kumar <76599223+surajkumar@users.noreply.github.com> Signed-off-by: Chris Sdogkos --- application/config.json.template | 7 +- .../org/togetherjava/tjbot/config/Config.java | 15 ++- .../togetherjava/tjbot/features/Features.java | 4 + .../features/voicechat/DynamicVoiceChat.java | 108 ++++++++++++++++++ 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java diff --git a/application/config.json.template b/application/config.json.template index 5cfe9ac38e..a19b1aa028 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -199,5 +199,10 @@ "rolePattern": "Top Helper.*", "assignmentChannelPattern": "community-commands", "announcementChannelPattern": "hall-of-fame" - } + }, + "dynamicVoiceChannelPatterns": [ + "Gaming", + "Support/Studying Room", + "Chit Chat" + ] } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/Config.java b/application/src/main/java/org/togetherjava/tjbot/config/Config.java index 60e6622cbc..a52292ea10 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -49,6 +49,7 @@ public final class Config { private final String selectRolesChannelPattern; private final String memberCountCategoryPattern; private final TopHelpersConfig topHelpers; + private final List dynamicVoiceChannelPatterns; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -102,7 +103,9 @@ private Config(@JsonProperty(value = "token", required = true) String token, @JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig, @JsonProperty(value = "selectRolesChannelPattern", required = true) String selectRolesChannelPattern, - @JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers) { + @JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers, + @JsonProperty(value = "dynamicVoiceChannelPatterns", + required = true) List dynamicVoiceChannelPatterns) { this.token = Objects.requireNonNull(token); this.githubApiKey = Objects.requireNonNull(githubApiKey); this.databasePath = Objects.requireNonNull(databasePath); @@ -138,6 +141,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, this.rssFeedsConfig = Objects.requireNonNull(rssFeedsConfig); this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern); this.topHelpers = Objects.requireNonNull(topHelpers); + this.dynamicVoiceChannelPatterns = Objects.requireNonNull(dynamicVoiceChannelPatterns); } /** @@ -457,4 +461,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 getDynamicVoiceChannelPatterns() { + return dynamicVoiceChannelPatterns; + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index 463c3b5248..3cbc15081e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -77,6 +77,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; @@ -161,6 +162,9 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new SlashCommandEducator()); features.add(new PinnedNotificationRemover(config)); + // Voice receivers + features.add(new DynamicVoiceChat(config)); + // Event receivers features.add(new RejoinModerationRoleListener(actionsStore, config)); features.add(new GuildLeaveCloseThreadListener(config)); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java new file mode 100644 index 0000000000..d26218ba40 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java @@ -0,0 +1,108 @@ +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.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 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.regex.Pattern; + +public class DynamicVoiceChat extends VoiceReceiverAdapter { + private static final Logger logger = LoggerFactory.getLogger(DynamicVoiceChat.class); + private final List dynamicVoiceChannelPatterns; + + public DynamicVoiceChat(Config config) { + this.dynamicVoiceChannelPatterns = + config.getDynamicVoiceChannelPatterns().stream().map(Pattern::compile).toList(); + } + + @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); + deleteDynamicVoiceChannel(channelLeft); + } + } + + 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.info("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.info( + "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 deleteDynamicVoiceChannel(AudioChannelUnion channel) { + int memberCount = channel.getMembers().size(); + + if (memberCount > 0) { + logger.debug("Voice channel {} not empty ({} members), so not removing.", + channel.getName(), memberCount); + return; + } + + channel.delete() + .queue(_ -> logger.info("Deleted dynamically created voice channel: {} ", + channel.getName()), + error -> logger.error("Failed to delete dynamically created voice channel: {} ", + channel.getName(), error)); + } + + 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(); + } +} From cabd30a0c3475a67b85c70b1b674ba466b96aacd Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Sun, 4 Jan 2026 02:40:27 +0200 Subject: [PATCH 2/8] DynamicVoiceChat.java: use trace instead of info Using 'Logger#info' is too spammy in the console, use 'Logger#trace' instead. Signed-off-by: Chris Sdogkos --- .../tjbot/features/voicechat/DynamicVoiceChat.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java index d26218ba40..457b29f7e7 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java @@ -68,7 +68,7 @@ private void createDynamicVoiceChannel(@NotNull GuildVoiceUpdateEvent event, private void moveMember(Guild guild, Member member, AudioChannel channel) { guild.moveVoiceMember(member, channel) - .queue(_ -> logger.info( + .queue(_ -> logger.trace( "Successfully moved {} to newly created dynamic voice channel {}", member.getEffectiveName(), channel.getName()), error -> logger.error( @@ -86,7 +86,7 @@ private void deleteDynamicVoiceChannel(AudioChannelUnion channel) { } channel.delete() - .queue(_ -> logger.info("Deleted dynamically created voice channel: {} ", + .queue(_ -> logger.trace("Deleted dynamically created voice channel: {} ", channel.getName()), error -> logger.error("Failed to delete dynamically created voice channel: {} ", channel.getName(), error)); From 42d61f693ca86e11a1b5df63c71460200ed9a691 Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Sun, 4 Jan 2026 12:31:50 +0200 Subject: [PATCH 3/8] DynamicVoiceChat.java: more trace instead of info Signed-off-by: Chris Sdogkos --- .../togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java index 457b29f7e7..77ea04332b 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java @@ -61,7 +61,7 @@ private void createDynamicVoiceChannel(@NotNull GuildVoiceUpdateEvent event, moveMember(guild, member, newChannel); sendWarningEmbed(newChannel); }) - .queue(newChannel -> logger.info("Successfully created {} voice channel.", + .queue(newChannel -> logger.trace("Successfully created {} voice channel.", newChannel.getName()), error -> logger.error("Failed to create dynamic voice channel", error)); } From e93861d26c8e07d31d44182332dbb965a2c70047 Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Sun, 4 Jan 2026 12:56:24 +0200 Subject: [PATCH 4/8] Make class final and add JavaDoc Signed-off-by: Chris Sdogkos --- .../tjbot/features/voicechat/DynamicVoiceChat.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java index 77ea04332b..988b8892a7 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java @@ -18,7 +18,13 @@ import java.util.List; import java.util.regex.Pattern; -public class DynamicVoiceChat extends VoiceReceiverAdapter { +/** + * Handles dynamic voice channel creation and deletion based on user activity. + *

+ * 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 deleted. + */ +public final class DynamicVoiceChat extends VoiceReceiverAdapter { private static final Logger logger = LoggerFactory.getLogger(DynamicVoiceChat.class); private final List dynamicVoiceChannelPatterns; From 11d2f5371c0b2ce71be5ee6d77df1108db26907a Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Tue, 20 Jan 2026 13:49:10 +0200 Subject: [PATCH 5/8] Archive instead of simply deleting voice channels Currently there is no way to moderate ephemeral voice channels. Members could very easily break the rules in the channel, send NSFW content and it can go undetected by the moderation team. Introduce a new archival system where the ephemeral voice channels are instead stored in an archival category. Depending on the archival strategy, channels are removed once they are not needed any more. Routines are not being used since we are able to get away with attempting cleanup every time a user leaves an ephemeral voice channel. This results in superior performance and no scheduling involved. Do _not_ archive ephemeral voice channels with no contents sent by members. Signed-off-by: Chris Sdogkos --- .../features/voicechat/DynamicVoiceChat.java | 67 ++++++++++++++++--- .../voicechat/OldestVoiceChatCleanup.java | 40 +++++++++++ .../voicechat/VoiceChatCleanupStrategy.java | 21 ++++++ 3 files changed, 118 insertions(+), 10 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/voicechat/OldestVoiceChatCleanup.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/voicechat/VoiceChatCleanupStrategy.java diff --git a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java index 988b8892a7..3ac0fac638 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java @@ -4,10 +4,14 @@ 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; @@ -16,21 +20,34 @@ 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. *

* 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 deleted. + * 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); + + // @christolis: Unless somebody is willing to make the category name configurable, + // I will leave this here as a constant since I don't see a justification for naming + // this category name into something different. + private static final String ARCHIVE_CATEGORY_NAME = "Voice Channel Archives"; + private static final int CLEAN_CHANNELS_AMOUNT = 2; + private static final int MINIMUM_CHANNELS_AMOUNT = 3; + + private final VoiceChatCleanupStrategy voiceChatCleanupStrategy; private final List 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 @@ -45,7 +62,17 @@ public void onVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) { if (channelLeft != null && !eventHappenOnDynamicRootChannel(channelLeft)) { logger.debug("Event happened on left channel {}", channelLeft); - deleteDynamicVoiceChannel(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(); + } + }); } } @@ -82,20 +109,40 @@ private void moveMember(Guild guild, Member member, AudioChannel channel) { member.getNickname(), channel.getName(), error)); } - private void deleteDynamicVoiceChannel(AudioChannelUnion channel) { + 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.", - channel.getName(), memberCount); + logger.debug("Voice channel {} not empty ({} members), so not removing.", channelName, + memberCount); + return; + } + + Optional archiveCategoryOptional = channel.getGuild() + .getCategoryCache() + .stream() + .filter(c -> c.getName().equalsIgnoreCase(ARCHIVE_CATEGORY_NAME)) + .findFirst(); + + AudioChannelManager channelManager = channel.getManager(); + RestAction 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; } - channel.delete() - .queue(_ -> logger.trace("Deleted dynamically created voice channel: {} ", - channel.getName()), - error -> logger.error("Failed to delete dynamically created voice channel: {} ", - channel.getName(), error)); + 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) { diff --git a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/OldestVoiceChatCleanup.java b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/OldestVoiceChatCleanup.java new file mode 100644 index 0000000000..6648589922 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/OldestVoiceChatCleanup.java @@ -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}. + *

+ * 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 + * cleanChannelsAmount will be removed from the guild. + *

+ * The cleanup strategy will not be executed if the amount of voice channels does not exceed + * the value of minimumChannelsAmountToTrigger. + */ +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 voiceChannels) { + if (voiceChannels.size() < minimumChannelsAmountToTrigger) { + return; + } + + voiceChannels.stream() + .sorted(Comparator.comparing(ISnowflake::getTimeCreated)) + .limit(cleanChannelsAmount) + .forEach(voiceChannel -> voiceChannel.delete().queue()); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/VoiceChatCleanupStrategy.java b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/VoiceChatCleanupStrategy.java new file mode 100644 index 0000000000..fe067b703a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/VoiceChatCleanupStrategy.java @@ -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. + *

+ * 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 voiceChannels); +} From 7903563041888be1778f3b054e7d52a64972a722 Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Tue, 20 Jan 2026 18:48:02 +0200 Subject: [PATCH 6/8] Use proper constant values This is why breaks are important, I committed my previous changes while being hungry... Set realistic constant values for CLEAN_CHANNELS_AMOUNT and MINIMUM_CHANNELS_AMOUNT so that more channels are kept in the archive. Signed-off-by: Chris Sdogkos --- .../tjbot/features/voicechat/DynamicVoiceChat.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java index 3ac0fac638..ecc7602e24 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java @@ -37,8 +37,8 @@ public final class DynamicVoiceChat extends VoiceReceiverAdapter { // I will leave this here as a constant since I don't see a justification for naming // this category name into something different. private static final String ARCHIVE_CATEGORY_NAME = "Voice Channel Archives"; - private static final int CLEAN_CHANNELS_AMOUNT = 2; - private static final int MINIMUM_CHANNELS_AMOUNT = 3; + private static final int CLEAN_CHANNELS_AMOUNT = 25; + private static final int MINIMUM_CHANNELS_AMOUNT = 50; private final VoiceChatCleanupStrategy voiceChatCleanupStrategy; private final List dynamicVoiceChannelPatterns; From 3ba0b7a0b0dfeacd4e643240d94cf5b30bc348d7 Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Tue, 20 Jan 2026 21:00:31 +0200 Subject: [PATCH 7/8] DynamicVoiceChat.java: Remove defensive comment The constant name is self-documenting. If someone wants to make it as well as other constants in this class configurable later, they can submit a pull request with _actual reasoning_ instead of bikeshedding over a defensive comment. The constant is hardcoded because it's _unlikely_ anyone will want to change it. If that assumption proves wrong, it's trivial to refactor. Signed-off-by: Chris Sdogkos --- .../tjbot/features/voicechat/DynamicVoiceChat.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java index ecc7602e24..71f0a4b5f6 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java @@ -33,9 +33,6 @@ public final class DynamicVoiceChat extends VoiceReceiverAdapter { private static final Logger logger = LoggerFactory.getLogger(DynamicVoiceChat.class); - // @christolis: Unless somebody is willing to make the category name configurable, - // I will leave this here as a constant since I don't see a justification for naming - // this category name into something different. 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; From 9a5c6c8937d9b5e0c9825e90364f16ec201888cf Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Fri, 23 Jan 2026 14:29:22 +0200 Subject: [PATCH 8/8] Make the already existing constants configurable from config.json For the purposes of speeding up the process of reviews and since it really doesn't matter if they are configurable or not (just extra unnecessary development time), move all the constants into the config.json so that users who run the bot can easily configure these constants without touching the code. Signed-off-by: Chris Sdogkos --- application/config.json.template | 15 ++++++---- .../org/togetherjava/tjbot/config/Config.java | 16 +++++----- .../tjbot/config/DynamicVoiceChatConfig.java | 30 +++++++++++++++++++ .../features/voicechat/DynamicVoiceChat.java | 24 +++++++-------- 4 files changed, 59 insertions(+), 26 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/config/DynamicVoiceChatConfig.java diff --git a/application/config.json.template b/application/config.json.template index 92ce1bb21a..e2e1963c80 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -205,9 +205,14 @@ "assignmentChannelPattern": "community-commands", "announcementChannelPattern": "hall-of-fame" }, - "dynamicVoiceChannelPatterns": [ - "Gaming", - "Support/Studying Room", - "Chit Chat" - ] + "dynamicVoiceChatConfig": { + "dynamicChannelPatterns": [ + "Gaming", + "Support/Studying Room", + "Chit Chat" + ], + "archiveCategoryPattern": "Voice Channel Archives", + "cleanChannelsAmount": 20, + "minimumChannelsAmount": 40 + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/Config.java b/application/src/main/java/org/togetherjava/tjbot/config/Config.java index 6e34599802..33362afcb0 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -50,7 +50,7 @@ public final class Config { private final String memberCountCategoryPattern; private final QuoteBoardConfig quoteBoardConfig; private final TopHelpersConfig topHelpers; - private final List dynamicVoiceChannelPatterns; + private final DynamicVoiceChatConfig dynamicVoiceChatConfig; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -107,8 +107,8 @@ private Config(@JsonProperty(value = "token", required = true) String token, @JsonProperty(value = "quoteBoardConfig", required = true) QuoteBoardConfig quoteBoardConfig, @JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers, - @JsonProperty(value = "dynamicVoiceChannelPatterns", - required = true) List dynamicVoiceChannelPatterns) { + @JsonProperty(value = "dynamicVoiceChatConfig", + required = true) DynamicVoiceChatConfig dynamicVoiceChatConfig) { this.token = Objects.requireNonNull(token); this.githubApiKey = Objects.requireNonNull(githubApiKey); this.databasePath = Objects.requireNonNull(databasePath); @@ -145,7 +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); + this.dynamicVoiceChatConfig = Objects.requireNonNull(dynamicVoiceChatConfig); } /** @@ -479,11 +479,11 @@ public TopHelpersConfig getTopHelpers() { } /** - * Gets the list of voice channel patterns that are treated dynamically. + * Gets the dynamic voice chat configuration * - * @return the list of dynamic voice channel patterns + * @return the dynamic voice chat configuration */ - public List getDynamicVoiceChannelPatterns() { - return dynamicVoiceChannelPatterns; + public DynamicVoiceChatConfig getDynamicVoiceChatConfig() { + return dynamicVoiceChatConfig; } } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/DynamicVoiceChatConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/DynamicVoiceChatConfig.java new file mode 100644 index 0000000000..bac0a14318 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/DynamicVoiceChatConfig.java @@ -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 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); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java index 71f0a4b5f6..8fbb4c3751 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java @@ -17,11 +17,10 @@ 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.List; import java.util.Optional; -import java.util.regex.Pattern; /** * Handles dynamic voice channel creation and deletion based on user activity. @@ -33,18 +32,15 @@ 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 dynamicVoiceChannelPatterns; + private final DynamicVoiceChatConfig dynamicVoiceChannelConfig; public DynamicVoiceChat(Config config) { - this.dynamicVoiceChannelPatterns = - config.getDynamicVoiceChannelPatterns().stream().map(Pattern::compile).toList(); + this.dynamicVoiceChannelConfig = config.getDynamicVoiceChatConfig(); + this.voiceChatCleanupStrategy = - new OldestVoiceChatCleanup(CLEAN_CHANNELS_AMOUNT, MINIMUM_CHANNELS_AMOUNT); + new OldestVoiceChatCleanup(dynamicVoiceChannelConfig.cleanChannelsAmount(), + dynamicVoiceChannelConfig.minimumChannelsAmount()); } @Override @@ -74,7 +70,8 @@ public void onVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) { } private boolean eventHappenOnDynamicRootChannel(AudioChannelUnion channel) { - return dynamicVoiceChannelPatterns.stream() + return dynamicVoiceChannelConfig.dynamicChannelPatterns() + .stream() .anyMatch(pattern -> pattern.matcher(channel.getName()).matches()); } @@ -119,7 +116,8 @@ private void archiveDynamicVoiceChannel(AudioChannelUnion channel) { Optional archiveCategoryOptional = channel.getGuild() .getCategoryCache() .stream() - .filter(c -> c.getName().equalsIgnoreCase(ARCHIVE_CATEGORY_NAME)) + .filter(c -> c.getName() + .equalsIgnoreCase(dynamicVoiceChannelConfig.archiveCategoryPattern())) .findFirst(); AudioChannelManager channelManager = channel.getManager(); @@ -130,7 +128,7 @@ private void archiveDynamicVoiceChannel(AudioChannelUnion channel) { if (archiveCategoryOptional.isEmpty()) { logger.warn("Could not find archive category. Attempting to create one..."); channel.getGuild() - .createCategory(ARCHIVE_CATEGORY_NAME) + .createCategory(dynamicVoiceChannelConfig.archiveCategoryPattern()) .queue(newCategory -> restActionChain.and(channelManager.setParent(newCategory)) .queue()); return;