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..988b8892a7 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java @@ -0,0 +1,114 @@ +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; + +/** + * 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; + + 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.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 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.trace("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(); + } +}