From 41a827f0fbdccc3c075d92fda9387e6fe94e3bfa Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Sun, 4 Jan 2026 02:28:02 +0200 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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;