diff --git a/pom.xml b/pom.xml
index 591badd..fc91d51 100644
--- a/pom.xml
+++ b/pom.xml
@@ -159,6 +159,11 @@
spotify-web-api-java
8.4.0
+
+ com.github.ben-manes.caffeine
+ caffeine
+ 3.1.8
+
diff --git a/src/main/java/cc/fascinated/bat/features/drag/DragFeature.java b/src/main/java/cc/fascinated/bat/features/drag/DragFeature.java
new file mode 100644
index 0000000..0ca83a1
--- /dev/null
+++ b/src/main/java/cc/fascinated/bat/features/drag/DragFeature.java
@@ -0,0 +1,23 @@
+package cc.fascinated.bat.features.drag;
+
+import cc.fascinated.bat.command.Category;
+import cc.fascinated.bat.features.Feature;
+import cc.fascinated.bat.features.drag.command.DragCommand;
+import cc.fascinated.bat.service.CommandService;
+import lombok.NonNull;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationContext;
+import org.springframework.stereotype.Component;
+
+/**
+ * @author Fascinated (fascinated7)
+ */
+@Component
+public class DragFeature extends Feature {
+ @Autowired
+ public DragFeature(@NonNull ApplicationContext context, @NonNull CommandService commandService) {
+ super("Drag", true,Category.GENERAL);
+
+ super.registerCommand(commandService, context.getBean(DragCommand.class));
+ }
+}
diff --git a/src/main/java/cc/fascinated/bat/features/drag/DragRequest.java b/src/main/java/cc/fascinated/bat/features/drag/DragRequest.java
new file mode 100644
index 0000000..4372f41
--- /dev/null
+++ b/src/main/java/cc/fascinated/bat/features/drag/DragRequest.java
@@ -0,0 +1,50 @@
+package cc.fascinated.bat.features.drag;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.Setter;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.Message;
+import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
+import net.dv8tion.jda.api.interactions.InteractionHook;
+
+import java.util.Date;
+
+/**
+ * @author Fascinated (fascinated7)
+ */
+@RequiredArgsConstructor
+@Getter @Setter
+public class DragRequest {
+ /**
+ * The date the request was made
+ */
+ private final Date requestDate = new Date();
+
+ /**
+ * The user that wants to join the voice channel
+ */
+ private final Member member;
+
+ /**
+ * The user that the member wants to join
+ */
+ private final Member target;
+
+ /**
+ * The voice channel the user wants to join
+ */
+ private final VoiceChannel voiceChannel;
+
+ /**
+ * The interaction hook that the request was made from
+ */
+ private final InteractionHook interactionHook;
+
+ /**
+ * The request message sent in the voice channel
+ */
+ private Message requestMessage;
+
+}
diff --git a/src/main/java/cc/fascinated/bat/features/drag/command/DragCommand.java b/src/main/java/cc/fascinated/bat/features/drag/command/DragCommand.java
new file mode 100644
index 0000000..f4a7fe9
--- /dev/null
+++ b/src/main/java/cc/fascinated/bat/features/drag/command/DragCommand.java
@@ -0,0 +1,21 @@
+package cc.fascinated.bat.features.drag.command;
+
+import cc.fascinated.bat.command.BatCommand;
+import cc.fascinated.bat.command.CommandInfo;
+import io.sentry.protocol.App;
+import lombok.NonNull;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationContext;
+import org.springframework.stereotype.Component;
+
+/**
+ * @author Fascinated (fascinated7)
+ */
+@Component
+@CommandInfo(name = "drag", description = "Drag command")
+public class DragCommand extends BatCommand {
+ @Autowired
+ public DragCommand(@NonNull ApplicationContext context) {
+ super.addSubCommand(context.getBean(RequestSubCommand.class));
+ }
+}
diff --git a/src/main/java/cc/fascinated/bat/features/drag/command/RequestSubCommand.java b/src/main/java/cc/fascinated/bat/features/drag/command/RequestSubCommand.java
new file mode 100644
index 0000000..c19ecba
--- /dev/null
+++ b/src/main/java/cc/fascinated/bat/features/drag/command/RequestSubCommand.java
@@ -0,0 +1,152 @@
+package cc.fascinated.bat.features.drag.command;
+
+import cc.fascinated.bat.command.BatSubCommand;
+import cc.fascinated.bat.command.CommandInfo;
+import cc.fascinated.bat.common.EmbedUtils;
+import cc.fascinated.bat.common.TimerUtils;
+import cc.fascinated.bat.event.EventListener;
+import cc.fascinated.bat.features.drag.DragRequest;
+import cc.fascinated.bat.model.BatGuild;
+import cc.fascinated.bat.model.BatUser;
+import lombok.NonNull;
+import net.dv8tion.jda.api.entities.GuildVoiceState;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
+import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
+import net.dv8tion.jda.api.interactions.commands.OptionMapping;
+import net.dv8tion.jda.api.interactions.commands.OptionType;
+import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction;
+import net.dv8tion.jda.api.interactions.components.ActionRow;
+import net.dv8tion.jda.api.interactions.components.buttons.Button;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Handles requests to be moved to a voice channel.
+ * Author: Fascinated (fascinated7)
+ */
+@Component
+@CommandInfo(name = "request", description = "Request to be moved to a voice channel")
+public class RequestSubCommand extends BatSubCommand implements EventListener {
+ /**
+ * A list of join requests
+ */
+ public static final Set JOIN_REQUESTS = new HashSet<>();
+
+ private final long requestTimeout = Duration.ofMinutes(30).toMillis();
+ private final long checkInterval = Duration.ofSeconds(10).toMillis();
+
+ public RequestSubCommand() {
+ super.addOption(OptionType.USER, "user", "The user you want to join", true);
+
+ TimerUtils.scheduleRepeating(() -> {
+ Set toRemove = new HashSet<>();
+ for (DragRequest joinRequest : JOIN_REQUESTS) {
+ if (System.currentTimeMillis() - joinRequest.getRequestDate().getTime() < requestTimeout) {
+ return;
+ }
+ // The request has timed out
+ joinRequest.getInteractionHook().editOriginalEmbeds(EmbedUtils.errorEmbed()
+ .setDescription("The request to join %s's voice channel has timed out.".formatted(joinRequest.getTarget().getAsMention()))
+ .build()).queue();
+ joinRequest.getVoiceChannel().sendMessageEmbeds(EmbedUtils.errorEmbed()
+ .setDescription("%s's request to join your voice channel has timed out.".formatted(joinRequest.getMember().getAsMention()))
+ .build()).queue();
+ joinRequest.getRequestMessage().delete().queue();
+ toRemove.add(joinRequest);
+ }
+ JOIN_REQUESTS.removeAll(toRemove);
+ }, checkInterval, checkInterval);
+ }
+
+ @Override
+ public void execute(BatGuild guild, @NonNull BatUser user, @NonNull MessageChannel channel, Member member, @NonNull SlashCommandInteraction event) {
+ GuildVoiceState voiceState = member.getVoiceState();
+ // Check if the user is in a voice channel
+ if (voiceState == null || voiceState.getChannel() == null) {
+ event.replyEmbeds(EmbedUtils.errorEmbed()
+ .setDescription("You are not in a voice channel.")
+ .build())
+ .setEphemeral(true)
+ .queue();
+ return;
+ }
+
+ OptionMapping userOption = event.getOption("user");
+ if (userOption == null) return;
+
+ // Check if the user is in a voice channel
+ Member target = userOption.getAsMember();
+ if (target == null || target.getId().equals(member.getId())) {
+ event.replyEmbeds(EmbedUtils.errorEmbed()
+ .setDescription("You cannot request to join your own voice channel.")
+ .build())
+ .setEphemeral(true)
+ .queue();
+ return;
+ }
+
+ // Check if the target user is in a voice channel
+ GuildVoiceState targetVoiceState = target.getVoiceState();
+ if (targetVoiceState == null || targetVoiceState.getChannel() == null) {
+ event.replyEmbeds(EmbedUtils.errorEmbed()
+ .setDescription("The user %s is not in a voice channel.".formatted(target.getAsMention()))
+ .build())
+ .setEphemeral(true)
+ .queue();
+ return;
+ }
+
+ VoiceChannel targetChannel = targetVoiceState.getChannel().asVoiceChannel();
+
+ // User is already in the target channel
+ if (voiceState.getChannel().getId().equals(targetChannel.getId())) {
+ event.replyEmbeds(EmbedUtils.errorEmbed()
+ .setDescription("You are already in the voice channel %s.".formatted(voiceState.getChannel().getAsMention()))
+ .build())
+ .setEphemeral(true)
+ .queue();
+ return;
+ }
+
+ // Check if the user has already requested to join the target channel
+ DragRequest existingRequest = JOIN_REQUESTS.stream()
+ .filter(request -> request.getMember().getId().equals(member.getId()) && request.getVoiceChannel().getId().equals(targetChannel.getId()))
+ .findFirst()
+ .orElse(null);
+ if (existingRequest != null) {
+ event.replyEmbeds(EmbedUtils.errorEmbed()
+ .setDescription("You have already requested to join %s's voice channel.".formatted(target.getAsMention()))
+ .build())
+ .setEphemeral(true)
+ .queue();
+ return;
+ }
+
+ // Add the request to the list
+ JOIN_REQUESTS.add(new DragRequest(member, target, targetChannel, event.getHook()));
+
+ // Send the request to the target user
+ targetChannel.sendMessage(target.getAsMention()).queue();
+ targetChannel.sendMessageEmbeds(EmbedUtils.successEmbed()
+ .setDescription("User %s has requested to join your voice channel.".formatted(member.getAsMention()))
+ .build())
+ .addComponents(ActionRow.of(
+ Button.primary("drag-request-accept", "Accept"),
+ Button.danger("drag-request-decline", "Decline")
+ ))
+ .queue(message -> {
+ JOIN_REQUESTS.stream()
+ .filter(r -> r.getVoiceChannel().getId().equals(targetChannel.getId()))
+ .findFirst().ifPresent(request -> request.setRequestMessage(message));
+ });
+ event.replyEmbeds(EmbedUtils.successEmbed()
+ .setDescription("Request to join %s's voice channel has been sent.".formatted(target.getAsMention()))
+ .build())
+ .setComponents(ActionRow.of(Button.secondary("drag-request-cancel", "Cancel")))
+ .queue();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/cc/fascinated/bat/features/drag/listeners/request/RequestListener.java b/src/main/java/cc/fascinated/bat/features/drag/listeners/request/RequestListener.java
new file mode 100644
index 0000000..406df14
--- /dev/null
+++ b/src/main/java/cc/fascinated/bat/features/drag/listeners/request/RequestListener.java
@@ -0,0 +1,43 @@
+package cc.fascinated.bat.features.drag.listeners.request;
+
+import cc.fascinated.bat.common.EmbedUtils;
+import cc.fascinated.bat.event.EventListener;
+import cc.fascinated.bat.features.drag.DragRequest;
+import cc.fascinated.bat.features.drag.command.RequestSubCommand;
+import cc.fascinated.bat.model.BatGuild;
+import cc.fascinated.bat.model.BatUser;
+import lombok.NonNull;
+import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
+import net.dv8tion.jda.api.interactions.InteractionHook;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+/**
+ * @author Fascinated (fascinated7)
+ */
+@Component
+public class RequestListener implements EventListener {
+ @Override
+ public void onButtonInteraction(BatGuild guild, @NonNull BatUser user, @NonNull ButtonInteractionEvent event) {
+ if (!event.getComponentId().equals("drag-request-cancel")) {
+ return;
+ }
+ Optional optionalDragRequest = RequestSubCommand.JOIN_REQUESTS.stream()
+ .filter(request -> request.getMember().getId().equals(event.getUser().getId()))
+ .findFirst();
+ if (optionalDragRequest.isEmpty()) {
+ return;
+ }
+ DragRequest dragRequest = optionalDragRequest.get();
+ InteractionHook interactionHook = dragRequest.getInteractionHook();
+ interactionHook.editOriginalEmbeds(EmbedUtils.errorEmbed()
+ .setDescription("You have cancelled your request to join %s's voice channel.".formatted(dragRequest.getTarget().getAsMention()))
+ .build()).queue(message -> message.editMessageComponents().queue());
+ dragRequest.getVoiceChannel().sendMessageEmbeds(EmbedUtils.errorEmbed()
+ .setDescription("%s has cancelled their request to join your voice channel.".formatted(dragRequest.getMember().getAsMention()))
+ .build()).queue();
+ dragRequest.getRequestMessage().delete().queue();
+ RequestSubCommand.JOIN_REQUESTS.remove(dragRequest);
+ }
+}
diff --git a/src/main/java/cc/fascinated/bat/features/drag/listeners/request/TargetChannelListener.java b/src/main/java/cc/fascinated/bat/features/drag/listeners/request/TargetChannelListener.java
new file mode 100644
index 0000000..27d38be
--- /dev/null
+++ b/src/main/java/cc/fascinated/bat/features/drag/listeners/request/TargetChannelListener.java
@@ -0,0 +1,54 @@
+package cc.fascinated.bat.features.drag.listeners.request;
+
+import cc.fascinated.bat.common.EmbedUtils;
+import cc.fascinated.bat.event.EventListener;
+import cc.fascinated.bat.features.drag.DragRequest;
+import cc.fascinated.bat.features.drag.command.RequestSubCommand;
+import cc.fascinated.bat.model.BatGuild;
+import cc.fascinated.bat.model.BatUser;
+import lombok.NonNull;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.User;
+import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
+import org.springframework.stereotype.Component;
+
+/**
+ * @author Fascinated (fascinated7)
+ */
+@Component
+public class TargetChannelListener implements EventListener {
+ @Override
+ public void onButtonInteraction(BatGuild guild, @NonNull BatUser user, @NonNull ButtonInteractionEvent event) {
+ User buttonUser = event.getUser();
+ Member member = guild.getDiscordGuild().getMember(buttonUser);
+ if (member == null) return;
+
+ DragRequest joinRequest = RequestSubCommand.JOIN_REQUESTS.stream()
+ .filter(request -> request.getVoiceChannel().getId().equals(event.getChannel().getId()))
+ .findFirst()
+ .orElse(null);
+ if (joinRequest == null) return;
+
+ if (event.getComponentId().equals("drag-request-accept")) {
+ joinRequest.getVoiceChannel().getGuild().moveVoiceMember(joinRequest.getMember(), joinRequest.getVoiceChannel()).queue();
+ event.replyEmbeds(EmbedUtils.successEmbed()
+ .setDescription("You have accepted %s's request to join your voice channel!".formatted(joinRequest.getMember().getAsMention()))
+ .build())
+ .queue();
+ } else if (event.getComponentId().equals("drag-request-decline")) {
+ event.replyEmbeds(EmbedUtils.errorEmbed()
+ .setDescription("You have declined %s's request to join your voice channel!".formatted(joinRequest.getMember().getAsMention()))
+ .build())
+ .queue();
+ joinRequest.getInteractionHook().retrieveOriginal().queue(message -> {
+ message.editMessageEmbeds(EmbedUtils.errorEmbed()
+ .setDescription("%s has declined your request to join their voice channel.".formatted(joinRequest.getTarget().getAsMention()))
+ .build()).queue();
+ message.editMessageComponents().queue();
+ });
+ }
+ RequestSubCommand.JOIN_REQUESTS.remove(joinRequest);
+ // Remove the buttons from the embed
+ event.getInteraction().getMessage().editMessageComponents().queue();
+ }
+}