diff --git a/application/config.json.template b/application/config.json.template index a950170fb4..e2e1963c80 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -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 } } 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 a1ee80363d..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,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) @@ -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); @@ -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); } /** @@ -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; + } } 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/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index 64bf86c166..9c4f326cc5 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -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; @@ -164,6 +165,9 @@ public static Collection 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)); 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..8fbb4c3751 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java @@ -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. + *

+ * 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 archiveCategoryOptional = channel.getGuild() + .getCategoryCache() + .stream() + .filter(c -> c.getName() + .equalsIgnoreCase(dynamicVoiceChannelConfig.archiveCategoryPattern())) + .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(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(); + } +} 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); +}