diff --git a/application/config.json.template b/application/config.json.template index a1aec8f470..f9f498fd50 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -115,5 +115,10 @@ "fallbackChannelPattern": "java-news-and-changes", "pollIntervalInMinutes": 10 }, - "memberCountCategoryPattern": "Info" + "memberCountCategoryPattern": "Info", + "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 e819f8e7d1..a09c6e0ac9 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -46,6 +46,7 @@ public final class Config { private final RSSFeedsConfig rssFeedsConfig; private final String selectRolesChannelPattern; private final String memberCountCategoryPattern; + private final List dynamicVoiceChannelPatterns; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -94,7 +95,9 @@ private Config(@JsonProperty(value = "token", required = true) String token, required = true) FeatureBlacklistConfig featureBlacklistConfig, @JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig, @JsonProperty(value = "selectRolesChannelPattern", - required = true) String selectRolesChannelPattern) { + required = true) String selectRolesChannelPattern, + @JsonProperty(value = "dynamicVoiceChannelPatterns", + required = true) List dynamicVoiceChannelPatterns) { this.token = Objects.requireNonNull(token); this.githubApiKey = Objects.requireNonNull(githubApiKey); this.databasePath = Objects.requireNonNull(databasePath); @@ -127,6 +130,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, this.featureBlacklistConfig = Objects.requireNonNull(featureBlacklistConfig); this.rssFeedsConfig = Objects.requireNonNull(rssFeedsConfig); this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern); + this.dynamicVoiceChannelPatterns = Objects.requireNonNull(dynamicVoiceChannelPatterns); } /** @@ -418,4 +422,14 @@ public String getMemberCountCategoryPattern() { public RSSFeedsConfig getRSSFeedsConfig() { return rssFeedsConfig; } + + /** + * Gets the list of voice channel patterns that are treated dynamically. + * + * @return the list of dynamic voice channel patterns + */ + public List getDynamicVoiceChannelPatterns() { + return this.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 893adbc00f..50a10b2ff5 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -20,6 +20,7 @@ import org.togetherjava.tjbot.features.code.CodeMessageAutoDetection; import org.togetherjava.tjbot.features.code.CodeMessageHandler; import org.togetherjava.tjbot.features.code.CodeMessageManualDetection; +import org.togetherjava.tjbot.features.dynamicvc.DynamicVoiceListener; import org.togetherjava.tjbot.features.filesharing.FileSharingMessageListener; import org.togetherjava.tjbot.features.github.GitHubCommand; import org.togetherjava.tjbot.features.github.GitHubReference; @@ -151,6 +152,9 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new SlashCommandEducator()); features.add(new PinnedNotificationRemover(config)); + // Voice receivers + features.add(new DynamicVoiceListener(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/VoiceReceiver.java b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiver.java new file mode 100644 index 0000000000..170dabd4fe --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiver.java @@ -0,0 +1,69 @@ +package org.togetherjava.tjbot.features; + +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceDeafenEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceMuteEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceStreamEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceVideoEvent; + +import java.util.regex.Pattern; + +/** + * Receives incoming Discord guild events from voice channels matching a given pattern. + *

+ * All voice receivers have to implement this interface. For convenience, there is a + * {@link VoiceReceiverAdapter} available that implemented most methods already. A new receiver can + * then be registered by adding it to {@link Features}. + *

+ *

+ * After registration, the system will notify a receiver whenever a new event was sent or an + * existing event was updated in any channel matching the {@link #getChannelNamePattern()} the bot + * is added to. + */ +public interface VoiceReceiver extends Feature { + /** + * Retrieves the pattern matching the names of channels of which this receiver is interested in + * receiving events from. Called by the core system once during the startup in order to register + * the receiver accordingly. + *

+ * Changes on the pattern returned by this method afterwards will not be picked up. + * + * @return the pattern matching the names of relevant channels + */ + Pattern getChannelNamePattern(); + + /** + * Triggered by the core system whenever a member joined, left or moved voice channels. + * + * @param event the event that triggered this + */ + void onVoiceUpdate(GuildVoiceUpdateEvent event); + + /** + * Triggered by the core system whenever a member toggled their camera in a voice channel. + * + * @param event the event that triggered this + */ + void onVideoToggle(GuildVoiceVideoEvent event); + + /** + * Triggered by the core system whenever a member started or stopped a stream. + * + * @param event the event that triggered this + */ + void onStreamToggle(GuildVoiceStreamEvent event); + + /** + * Triggered by the core system whenever a member toggled their mute status. + * + * @param event the event that triggered this + */ + void onMuteToggle(GuildVoiceMuteEvent event); + + /** + * Triggered by the core system whenever a member toggled their deafened status. + * + * @param event the event that triggered this + */ + void onDeafenToggle(GuildVoiceDeafenEvent event); +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java new file mode 100644 index 0000000000..c92fbb339a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java @@ -0,0 +1,52 @@ +package org.togetherjava.tjbot.features; + +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceDeafenEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceMuteEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceStreamEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceVideoEvent; + +import java.util.regex.Pattern; + +public class VoiceReceiverAdapter implements VoiceReceiver { + + private final Pattern channelNamePattern; + + protected VoiceReceiverAdapter() { + this(Pattern.compile(".*")); + } + + protected VoiceReceiverAdapter(Pattern channelNamePattern) { + this.channelNamePattern = channelNamePattern; + } + + @Override + public Pattern getChannelNamePattern() { + return channelNamePattern; + } + + @Override + public void onVoiceUpdate(GuildVoiceUpdateEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } + + @Override + public void onVideoToggle(GuildVoiceVideoEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } + + @Override + public void onStreamToggle(GuildVoiceStreamEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } + + @Override + public void onMuteToggle(GuildVoiceMuteEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } + + @Override + public void onDeafenToggle(GuildVoiceDeafenEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceListener.java b/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceListener.java new file mode 100644 index 0000000000..4158ca3b6c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceListener.java @@ -0,0 +1,266 @@ +package org.togetherjava.tjbot.features.dynamicvc; + +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import net.dv8tion.jda.api.entities.channel.middleman.StandardGuildChannel; +import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import org.apache.commons.lang3.tuple.Pair; +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.ArrayList; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +/** + * {@link DynamicVoiceListener} is a feature that dynamically manages voice channels within a + * Discord guild based on user activity. + *

+ * It is designed to handle events related to voice channel updates (e.g. when users join or leave + * voice channels). It dynamically creates or deletes voice channels to ensure there is always + * one available empty channel for users to join, and removes duplicate empty channels to + * avoid clutter. + *

+ * This feature relies on configurations provided at initialization to determine the patterns for + * channel names it should manage. The configuration is expected to provide a list of regular + * expression patterns for these channel names. + */ +public class DynamicVoiceListener extends VoiceReceiverAdapter { + + private final Logger logger = LoggerFactory.getLogger(DynamicVoiceListener.class); + + private final Map> channelPredicates = new HashMap<>(); + private static final Pattern channelTopicPattern = Pattern.compile("(\\s+\\d+)$"); + + /** Map of event queues for each channel topic. */ + private final Map> eventQueues = new HashMap<>(); + + /** Map to track if an event queue is currently being processed for each channel topic. */ + private final Map activeQueuesMap = new HashMap<>(); + + /** Boolean to track if events from all queues should be handled at a slower rate. */ + private final AtomicBoolean voiceActivityCongestion = new AtomicBoolean(false); + private final Executor eventQueueExecutor = + CompletableFuture.delayedExecutor(1L, TimeUnit.SECONDS); + private static final int CONGESTION_THRESHOLD = 5; + + /** + * Initializes a new {@link DynamicVoiceListener} with the specified configuration. + * + * @param config the configuration containing dynamic voice channel patterns + */ + public DynamicVoiceListener(Config config) { + config.getDynamicVoiceChannelPatterns().forEach(pattern -> { + channelPredicates.put(pattern, Pattern.compile(pattern).asMatchPredicate()); + activeQueuesMap.put(pattern, new AtomicBoolean(false)); + eventQueues.put(pattern, new LinkedList<>()); + }); + } + + @Override + public void onVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) { + AudioChannelUnion joinChannel = event.getChannelJoined(); + AudioChannelUnion leftChannel = event.getChannelLeft(); + + if (joinChannel != null) { + insertEventToQueue(event, getChannelTopic(joinChannel.getName())); + } + + if (leftChannel != null) { + insertEventToQueue(event, getChannelTopic(leftChannel.getName())); + } + } + + private void insertEventToQueue(GuildVoiceUpdateEvent event, String channelTopic) { + Queue eventQueue = eventQueues.get(channelTopic); + + if (eventQueue == null) { + return; + } + + eventQueue.add(event); + voiceActivityCongestion.set(eventQueue.size() >= CONGESTION_THRESHOLD); + + if (activeQueuesMap.get(channelTopic).get()) { + return; + } + + if (voiceActivityCongestion.get()) { + final String logMessage = String.format( + "Congestion detected in the event queue of voice channel '%s', responding to event %s asynchronously.", + channelTopic, event); + + logger.info(logMessage); + CompletableFuture.runAsync(() -> processEventFromQueue(channelTopic), + eventQueueExecutor); + return; + } + + processEventFromQueue(channelTopic); + } + + private void processEventFromQueue(String channelTopic) { + AtomicBoolean activeQueueFlag = activeQueuesMap.get(channelTopic); + GuildVoiceUpdateEvent event = eventQueues.get(channelTopic).poll(); + + if (event == null) { + activeQueueFlag.set(false); + return; + } + + activeQueueFlag.set(true); + + handleTopicUpdate(event, channelTopic); + } + + private void handleTopicUpdate(GuildVoiceUpdateEvent event, String channelTopic) { + AtomicBoolean activeQueueFlag = activeQueuesMap.get(channelTopic); + Guild guild = event.getGuild(); + List> restActionTasks = new ArrayList<>(); + + if (channelPredicates.get(channelTopic) == null) { + activeQueueFlag.set(false); + return; + } + + long emptyChannelsCount = getEmptyChannelsCountFromTopic(guild, channelTopic); + + if (emptyChannelsCount == 0) { + long channelCount = getChannelCountFromTopic(guild, channelTopic); + + restActionTasks + .add(makeCreateVoiceChannelFromTopicFuture(guild, channelTopic, channelCount)); + } else if (emptyChannelsCount != 1) { + restActionTasks.addAll(makeRemoveDuplicateEmptyChannelsFutures(guild, channelTopic)); + restActionTasks.addAll(makeRenameTopicChannelsFutures(guild, channelTopic)); + } + + if (!restActionTasks.isEmpty()) { + CompletableFuture.allOf(restActionTasks.toArray(CompletableFuture[]::new)) + .thenCompose(v -> { + List> renameTasks = + makeRenameTopicChannelsFutures(guild, channelTopic); + return CompletableFuture.allOf(renameTasks.toArray(CompletableFuture[]::new)); + }) + .handle((result, exception) -> { + processEventFromQueue(channelTopic); + activeQueueFlag.set(false); + return null; + }); + return; + } + + processEventFromQueue(channelTopic); + activeQueueFlag.set(false); + } + + private static CompletableFuture makeCreateVoiceChannelFromTopicFuture( + Guild guild, String channelTopic, long topicChannelsCount) { + Optional originalTopicChannelOptional = + getOriginalTopicChannel(guild, channelTopic); + + if (originalTopicChannelOptional.isPresent()) { + VoiceChannel originalTopicChannel = originalTopicChannelOptional.orElseThrow(); + + return originalTopicChannel.createCopy() + .setName(getNumberedChannelTopic(channelTopic, topicChannelsCount + 1)) + .setPosition(originalTopicChannel.getPositionRaw()) + .submit(); + } + + return CompletableFuture.completedFuture(null); + } + + private static Optional getOriginalTopicChannel(Guild guild, + String channelTopic) { + return guild.getVoiceChannels() + .stream() + .filter(channel -> channel.getName().equals(channelTopic)) + .findFirst(); + } + + private List> makeRemoveDuplicateEmptyChannelsFutures(Guild guild, + String channelTopic) { + List channelsToRemove = getVoiceChannelsFromTopic(guild, channelTopic) + .filter(channel -> channel.getMembers().isEmpty()) + .toList(); + final List> restActionTasks = new ArrayList<>(); + + channelsToRemove.subList(1, channelsToRemove.size()) + .forEach(channel -> restActionTasks.add(channel.delete().submit())); + + return restActionTasks; + } + + private List> makeRenameTopicChannelsFutures(Guild guild, + String channelTopic) { + List topicChannels = getVoiceChannelsFromTopic(guild, channelTopic).toList(); + List> restActionTasks = new ArrayList<>(); + + IntStream.range(0, topicChannels.size()) + .asLongStream() + .mapToObj(channelId -> Pair.of(channelId + 1, topicChannels.get((int) channelId))) + .filter(pair -> pair.getLeft() != 1) + .forEach(pair -> { + long channelId = pair.getLeft(); + VoiceChannel voiceChannel = pair.getRight(); + String voiceChannelNameTopic = getChannelTopic(voiceChannel.getName()); + + restActionTasks.add(voiceChannel.getManager() + .setName(getNumberedChannelTopic(voiceChannelNameTopic, channelId)) + .submit()); + }); + + return restActionTasks; + } + + private long getChannelCountFromTopic(Guild guild, String channelTopic) { + return getVoiceChannelsFromTopic(guild, channelTopic).count(); + } + + private Stream getVoiceChannelsFromTopic(Guild guild, String channelTopic) { + return guild.getVoiceChannels() + .stream() + .filter(channel -> channelPredicates.get(channelTopic) + .test(getChannelTopic(channel.getName()))); + } + + private long getEmptyChannelsCountFromTopic(Guild guild, String channelTopic) { + return getVoiceChannelsFromTopic(guild, channelTopic) + .map(channel -> channel.getMembers().size()) + .filter(number -> number == 0) + .count(); + } + + private static String getChannelTopic(String channelName) { + Matcher channelTopicPatternMatcher = channelTopicPattern.matcher(channelName); + + if (channelTopicPatternMatcher.find()) { + return channelTopicPatternMatcher.replaceAll(""); + } + + return channelName; + } + + private static String getNumberedChannelTopic(String channelTopic, long channelId) { + return String.format("%s %d", channelTopic, channelId); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java b/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java index 869e978a17..7dc47daee4 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java @@ -2,6 +2,12 @@ import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.channel.Channel; +import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceDeafenEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceMuteEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceStreamEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceVideoEvent; import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent; @@ -16,6 +22,8 @@ import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; import net.dv8tion.jda.api.interactions.components.ComponentInteraction; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Unmodifiable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,6 +40,7 @@ import org.togetherjava.tjbot.features.UserContextCommand; import org.togetherjava.tjbot.features.UserInteractionType; import org.togetherjava.tjbot.features.UserInteractor; +import org.togetherjava.tjbot.features.VoiceReceiver; import org.togetherjava.tjbot.features.componentids.ComponentId; import org.togetherjava.tjbot.features.componentids.ComponentIdParser; import org.togetherjava.tjbot.features.componentids.ComponentIdStore; @@ -75,6 +84,7 @@ public final class BotCore extends ListenerAdapter implements CommandProvider { private final ComponentIdParser componentIdParser; private final ComponentIdStore componentIdStore; private final Map channelNameToMessageReceiver = new HashMap<>(); + private final Map channelNameToVoiceReceiver = new HashMap<>(); /** * Creates a new command system which uses the given database to allow commands to persist data. @@ -96,6 +106,13 @@ public BotCore(JDA jda, Database database, Config config) { .forEach(messageReceiver -> channelNameToMessageReceiver .put(messageReceiver.getChannelNamePattern(), messageReceiver)); + // Voice receivers + features.stream() + .filter(VoiceReceiver.class::isInstance) + .map(VoiceReceiver.class::cast) + .forEach(voiceReceiver -> channelNameToVoiceReceiver + .put(voiceReceiver.getChannelNamePattern(), voiceReceiver)); + // Event receivers features.stream() .filter(EventReceiver.class::isInstance) @@ -238,6 +255,76 @@ public void onMessageDelete(final MessageDeleteEvent event) { } } + /** + * @param joinChannel the join channel + * @param leftChannel the leave channel + * @return the join channel if not null, otherwise the leave channel, otherwise an empty + * optional + */ + private Optional calculateSubscribeTarget(@Nullable AudioChannelUnion joinChannel, + @Nullable AudioChannelUnion leftChannel) { + if (joinChannel != null) { + return Optional.of(joinChannel); + } + + return Optional.ofNullable(leftChannel); + } + + @Override + public void onGuildVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) { + calculateSubscribeTarget(event.getChannelJoined(), event.getChannelLeft()) + .ifPresent(channel -> getVoiceReceiversSubscribedTo(channel) + .forEach(voiceReceiver -> voiceReceiver.onVoiceUpdate(event))); + } + + @Override + public void onGuildVoiceVideo(@NotNull GuildVoiceVideoEvent event) { + AudioChannelUnion channel = event.getVoiceState().getChannel(); + + if (channel == null) { + return; + } + + getVoiceReceiversSubscribedTo(channel) + .forEach(voiceReceiver -> voiceReceiver.onVideoToggle(event)); + } + + @Override + public void onGuildVoiceStream(@NotNull GuildVoiceStreamEvent event) { + AudioChannelUnion channel = event.getVoiceState().getChannel(); + + if (channel == null) { + return; + } + + getVoiceReceiversSubscribedTo(channel) + .forEach(voiceReceiver -> voiceReceiver.onStreamToggle(event)); + } + + @Override + public void onGuildVoiceMute(@NotNull GuildVoiceMuteEvent event) { + AudioChannelUnion channel = event.getVoiceState().getChannel(); + + if (channel == null) { + return; + } + + getVoiceReceiversSubscribedTo(channel) + .forEach(voiceReceiver -> voiceReceiver.onMuteToggle(event)); + } + + @Override + public void onGuildVoiceDeafen(@NotNull GuildVoiceDeafenEvent event) { + AudioChannelUnion channel = event.getVoiceState().getChannel(); + + if (channel == null) { + return; + } + + getVoiceReceiversSubscribedTo(channel) + .forEach(voiceReceiver -> voiceReceiver.onDeafenToggle(event)); + } + private Stream getMessageReceiversSubscribedTo(Channel channel) { String channelName = channel.getName(); return channelNameToMessageReceiver.entrySet() @@ -248,6 +335,16 @@ private Stream getMessageReceiversSubscribedTo(Channel channel) .map(Map.Entry::getValue); } + private Stream getVoiceReceiversSubscribedTo(Channel channel) { + String channelName = channel.getName(); + return channelNameToVoiceReceiver.entrySet() + .stream() + .filter(patternAndReceiver -> patternAndReceiver.getKey() + .matcher(channelName) + .matches()) + .map(Map.Entry::getValue); + } + @Override public void onSlashCommandInteraction(SlashCommandInteractionEvent event) { String name = event.getName();