From a1039f9770f3e16c5125268de0cb0dfe1a722e9f Mon Sep 17 00:00:00 2001 From: christolis Date: Tue, 29 Oct 2024 20:32:26 +0200 Subject: [PATCH 1/7] feat(dynamic-vcs): add VoiceReceiver and config changes --- application/config.json.template | 7 +- .../org/togetherjava/tjbot/config/Config.java | 16 ++- .../tjbot/features/VoiceReceiver.java | 69 +++++++++++++ .../tjbot/features/VoiceReceiverAdapter.java | 52 ++++++++++ .../tjbot/features/system/BotCore.java | 97 +++++++++++++++++++ 5 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiver.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java 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/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/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(); From 4bca50ecca9b89479a26660172cbd4c93c1ede72 Mon Sep 17 00:00:00 2001 From: christolis Date: Tue, 29 Oct 2024 20:36:09 +0200 Subject: [PATCH 2/7] feat(dynamic-vcs): add DynamicVoiceListener code --- .../togetherjava/tjbot/features/Features.java | 4 + .../dynamicvc/DynamicVoiceListener.java | 262 ++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceListener.java 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/dynamicvc/DynamicVoiceListener.java b/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceListener.java new file mode 100644 index 0000000000..da32a72be7 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceListener.java @@ -0,0 +1,262 @@ +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 slowmode = new AtomicBoolean(false); + private final Executor eventQueueExecutor = + CompletableFuture.delayedExecutor(1L, TimeUnit.SECONDS); + private static final int SLOWMODE_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) { + var eventQueue = eventQueues.get(channelTopic); + + if (eventQueue == null) { + return; + } + + eventQueue.add(event); + slowmode.set(eventQueue.size() >= SLOWMODE_THRESHOLD); + + if (activeQueuesMap.get(channelTopic).get()) { + return; + } + + if (slowmode.get()) { + logger.info("Running with slowmode"); + 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); + } +} From 66aa74d9955cb55c24d4cbee71026f098361d3f1 Mon Sep 17 00:00:00 2001 From: christolis Date: Tue, 29 Oct 2024 20:40:33 +0200 Subject: [PATCH 3/7] refactor: use variable type instead of 'var' --- .../tjbot/features/dynamicvc/DynamicVoiceListener.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index da32a72be7..a7667f9dcb 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceListener.java @@ -90,7 +90,7 @@ public void onVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) { } private void insertEventToQueue(GuildVoiceUpdateEvent event, String channelTopic) { - var eventQueue = eventQueues.get(channelTopic); + Queue eventQueue = eventQueues.get(channelTopic); if (eventQueue == null) { return; From 0287536f19bbf2bcb63b76df5c2872cfe54f69d1 Mon Sep 17 00:00:00 2001 From: christolis Date: Tue, 29 Oct 2024 21:00:31 +0200 Subject: [PATCH 4/7] refactor: improve variable naming and log message --- .../features/dynamicvc/DynamicVoiceListener.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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 index a7667f9dcb..4158ca3b6c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceListener.java @@ -57,10 +57,10 @@ public class DynamicVoiceListener extends VoiceReceiverAdapter { private final Map activeQueuesMap = new HashMap<>(); /** Boolean to track if events from all queues should be handled at a slower rate. */ - private final AtomicBoolean slowmode = new AtomicBoolean(false); + private final AtomicBoolean voiceActivityCongestion = new AtomicBoolean(false); private final Executor eventQueueExecutor = CompletableFuture.delayedExecutor(1L, TimeUnit.SECONDS); - private static final int SLOWMODE_THRESHOLD = 5; + private static final int CONGESTION_THRESHOLD = 5; /** * Initializes a new {@link DynamicVoiceListener} with the specified configuration. @@ -97,14 +97,18 @@ private void insertEventToQueue(GuildVoiceUpdateEvent event, String channelTopic } eventQueue.add(event); - slowmode.set(eventQueue.size() >= SLOWMODE_THRESHOLD); + voiceActivityCongestion.set(eventQueue.size() >= CONGESTION_THRESHOLD); if (activeQueuesMap.get(channelTopic).get()) { return; } - if (slowmode.get()) { - logger.info("Running with slowmode"); + 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; From 1b166afaae83a1548d00eaad42802a705129ccdb Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Sun, 28 Dec 2025 20:43:32 +0300 Subject: [PATCH 5/7] fix(channelTopicPattern): SonarS5852 vulnerability Most of the regular expression engines use backtracking to try all possible execution paths of the regular expression when evaluating an input, in some cases it can cause performance issues, called catastrophic backtracking situations. In the worst case, the complexity of the regular expression is exponential in the size of the input, this means that a small carefully-crafted input (like 20 chars) can trigger catastrophic backtracking and cause a denial of service of the application. Super-linear regex complexity can lead to the same impact too with, in this case, a large carefully-crafted input (thousands chars). Signed-off-by: Chris Sdogkos --- .../tjbot/features/dynamicvc/DynamicVoiceListener.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 4158ca3b6c..611d294ec4 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceListener.java @@ -48,7 +48,8 @@ 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+)$"); + + private static final Pattern channelTopicPattern = Pattern.compile("(\\s{1,100}\\d+)$"); /** Map of event queues for each channel topic. */ private final Map> eventQueues = new HashMap<>(); From 85c79c1d215ba004a933ee9fc1c4334802519d4e Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Sun, 28 Dec 2025 20:26:28 +0300 Subject: [PATCH 6/7] Address code review comments from @Zabuzard - docs(calculateSubscribeTarget): improve JavaDocs - refactor: rename to DynamicVoiceChannelListener Signed-off-by: Chris Sdogkos --- .../togetherjava/tjbot/features/Features.java | 4 +-- ....java => DynamicVoiceChannelListener.java} | 10 +++--- .../tjbot/features/system/BotCore.java | 34 +++++++++++++++---- 3 files changed, 34 insertions(+), 14 deletions(-) rename application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/{DynamicVoiceListener.java => DynamicVoiceChannelListener.java} (96%) 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 c66296db1c..eaa89eb6c1 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -20,7 +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.dynamicvc.DynamicVoiceChannelListener; import org.togetherjava.tjbot.features.filesharing.FileSharingMessageListener; import org.togetherjava.tjbot.features.github.GitHubCommand; import org.togetherjava.tjbot.features.github.GitHubReference; @@ -163,7 +163,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new PinnedNotificationRemover(config)); // Voice receivers - features.add(new DynamicVoiceListener(config)); + features.add(new DynamicVoiceChannelListener(config)); // Event receivers features.add(new RejoinModerationRoleListener(actionsStore, config)); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceListener.java b/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceChannelListener.java similarity index 96% rename from application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceListener.java rename to application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceChannelListener.java index 611d294ec4..5b3864d981 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceChannelListener.java @@ -31,7 +31,7 @@ import java.util.stream.Stream; /** - * {@link DynamicVoiceListener} is a feature that dynamically manages voice channels within a + * {@link DynamicVoiceChannelListener} 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 @@ -43,9 +43,9 @@ * 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 { +public class DynamicVoiceChannelListener extends VoiceReceiverAdapter { - private final Logger logger = LoggerFactory.getLogger(DynamicVoiceListener.class); + private final Logger logger = LoggerFactory.getLogger(DynamicVoiceChannelListener.class); private final Map> channelPredicates = new HashMap<>(); @@ -64,11 +64,11 @@ public class DynamicVoiceListener extends VoiceReceiverAdapter { private static final int CONGESTION_THRESHOLD = 5; /** - * Initializes a new {@link DynamicVoiceListener} with the specified configuration. + * Initializes a new {@link DynamicVoiceChannelListener} with the specified configuration. * * @param config the configuration containing dynamic voice channel patterns */ - public DynamicVoiceListener(Config config) { + public DynamicVoiceChannelListener(Config config) { config.getDynamicVoiceChannelPatterns().forEach(pattern -> { channelPredicates.put(pattern, Pattern.compile(pattern).asMatchPredicate()); activeQueuesMap.put(pattern, new AtomicBoolean(false)); 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 7dc47daee4..610b0ee85a 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 @@ -256,18 +256,38 @@ public void onMessageDelete(final MessageDeleteEvent event) { } /** - * @param joinChannel the join channel - * @param leftChannel the leave channel + * Calculates the correct voice channel to act upon. + * + *

+ * If there is a channelJoined and a channelLeft, then the + * channelJoined is prioritized and returned. Otherwise, it returns + * channelLeft. + * + *

+ * This is an essential method due to the need of updating both channel categories that a member + * utilizes. For example, take the scenario of a user browsing through voice channels: + * + *

+     *     - User joins General -> channelJoined = General | channelLeft = null
+     *     - User switches to Gaming -> channelJoined = Gaming | channelLeft = General
+     *     - User leaves Discord -> channelJoined = null | channelLeft = Gaming
+     * 
+ * + *

+ * This way, we make sure that all relevant voice channels are updated. + * + * @param channelJoined the channel that the member has connected to, if any + * @param channelLeft the channel that the member left from, if any * @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); + private Optional calculateSubscribeTarget(@Nullable AudioChannelUnion channelJoined, + @Nullable AudioChannelUnion channelLeft) { + if (channelJoined != null) { + return Optional.of(channelJoined); } - return Optional.ofNullable(leftChannel); + return Optional.ofNullable(channelLeft); } @Override From a13ed7c66c4a0c612812bce1a0b9fc64284e4182 Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Sun, 28 Dec 2025 21:17:44 +0300 Subject: [PATCH 7/7] Send embed notifying that voice chat is temporary Signed-off-by: Chris Sdogkos --- .../dynamicvc/DynamicVoiceChannelListener.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceChannelListener.java b/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceChannelListener.java index 5b3864d981..6fd634a6bf 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceChannelListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/dynamicvc/DynamicVoiceChannelListener.java @@ -1,6 +1,8 @@ package org.togetherjava.tjbot.features.dynamicvc; +import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.MessageEmbed; 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; @@ -184,7 +186,19 @@ private static CompletableFuture makeCreateVoice return originalTopicChannel.createCopy() .setName(getNumberedChannelTopic(channelTopic, topicChannelsCount + 1)) .setPosition(originalTopicChannel.getPositionRaw()) - .submit(); + .submit() + .whenComplete((voiceChannel, _) -> { + 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(); + voiceChannel.sendMessageEmbeds(messageEmbed).queue(); + }); } return CompletableFuture.completedFuture(null);