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..7c337e2efb 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,96 @@ public void onMessageDelete(final MessageDeleteEvent event) { } } + /** + * 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 selectPreferredAudioChannel(@Nullable AudioChannelUnion channelJoined, + @Nullable AudioChannelUnion channelLeft) { + if (channelJoined != null) { + return Optional.of(channelJoined); + } + + return Optional.ofNullable(channelLeft); + } + + @Override + public void onGuildVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) { + selectPreferredAudioChannel(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 +355,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();