From f1d294b68fc8c206d21d6ae464d7ad95b9e827e2 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 15 Sep 2024 13:56:55 +0200 Subject: [PATCH 1/2] add custom voice channels --- .../data/config/guild/ModerationConfig.java | 10 +++ .../custom_vc/CustomVCButtonHandler.java | 73 ++++++++++++++++ .../systems/custom_vc/CustomVCListener.java | 82 ++++++++++++++++++ .../systems/custom_vc/CustomVCRepository.java | 63 ++++++++++++++ .../commands/CustomVCAddMemberSubcommand.java | 31 +++++++ .../CustomVCChangeMembersSubcommand.java | 86 +++++++++++++++++++ .../commands/CustomVCControlCommand.java | 21 +++++ .../CustomVCRemoveMemberSubcommand.java | 53 ++++++++++++ .../migrations/09-15-2024_custom_vcs.sql | 4 + 9 files changed, 423 insertions(+) create mode 100644 src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java create mode 100644 src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCListener.java create mode 100644 src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCRepository.java create mode 100644 src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCAddMemberSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCChangeMembersSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCControlCommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCRemoveMemberSubcommand.java create mode 100644 src/main/resources/database/migrations/09-15-2024_custom_vcs.sql diff --git a/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java b/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java index 6bc915ee8..88f369205 100644 --- a/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java +++ b/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java @@ -6,6 +6,7 @@ import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.channel.concrete.ForumChannel; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; import java.util.List; @@ -92,6 +93,11 @@ public class ModerationConfig extends GuildConfigItem { * Invite links AutoMod should exclude. */ private List automodInviteExcludes = List.of(); + + /** + * The ID of the voice channel template that lets users create their own voice channels. + */ + private long customVoiceChannelId; /** * Text that is sent to users when they're banned. @@ -149,4 +155,8 @@ public Role getExpertRole() { public TextChannel getNotificationThreadChannel() { return this.getGuild().getTextChannelById(this.notificationThreadChannelId); } + + public VoiceChannel getCustomVoiceChannel() { + return this.getGuild().getVoiceChannelById(customVoiceChannelId); + } } diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java new file mode 100644 index 000000000..0ae2ebb09 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java @@ -0,0 +1,73 @@ +package net.discordjug.javabot.systems.custom_vc; + +import java.util.function.BiConsumer; + +import lombok.RequiredArgsConstructor; +import net.discordjug.javabot.annotations.AutoDetectableComponentHandler; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.interactions.components.buttons.Button; +import net.dv8tion.jda.api.requests.restaction.PermissionOverrideAction; +import xyz.dynxsty.dih4jda.interactions.components.ButtonHandler; +import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; + +/** + * Handles buttons for managing custom voice channels. + */ +@AutoDetectableComponentHandler(CustomVCButtonHandler.COMPONENT_ID) +@RequiredArgsConstructor +public class CustomVCButtonHandler implements ButtonHandler { + static final String COMPONENT_ID = "custom-vc"; + + private static final String MAKE_PRIVATE_ID = "make-private"; + private static final String MAKE_PUBLIC_ID = "make-public"; + + private final CustomVCRepository repository; + + public Button createMakePrivateButton() { + return Button.primary(ComponentIdBuilder.build(COMPONENT_ID, MAKE_PRIVATE_ID), "make VC private"); + } + + public Button createMakePublicButton() { + return Button.primary(ComponentIdBuilder.build(COMPONENT_ID, MAKE_PUBLIC_ID), "make VC public"); + } + + @Override + public void handleButton(ButtonInteractionEvent event, Button button) { + if(!repository.isCustomVoiceChannel(event.getChannelIdLong()) || + repository.getOwnerId(event.getChannelIdLong()) != event.getMember().getIdLong()) { + Responses.error(event, "Only the VC owner can use this.").queue(); + return; + } + String[] id = ComponentIdBuilder.split(button.getId()); + switch (id[1]) { + case MAKE_PRIVATE_ID -> changeVisibility(event, createMakePublicButton(), "This voice channel is now private.", + PermissionOverrideAction::setDenied); + + case MAKE_PUBLIC_ID -> changeVisibility(event, createMakePrivateButton(), "This voice channel is now public.", + PermissionOverrideAction::setAllowed); + default -> Responses.error(event, "Unknown button").queue(); + } + } + + private void changeVisibility(ButtonInteractionEvent event, Button newButton, String messageContent, + BiConsumer permissionModifier) { + PermissionOverrideAction permissionOverrideAction = event + .getGuildChannel() + .asVoiceChannel() + .upsertPermissionOverride(event.getGuild().getPublicRole()); + permissionModifier.accept(permissionOverrideAction, Permission.VIEW_CHANNEL); + permissionOverrideAction + .queue(); + event + .editButton(newButton) + .flatMap(edited -> { + return event + .getHook() + .setEphemeral(true) + .sendMessage(messageContent); + }) + .queue(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCListener.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCListener.java new file mode 100644 index 000000000..8ecd66edb --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCListener.java @@ -0,0 +1,82 @@ +package net.discordjug.javabot.systems.custom_vc; + +import java.util.List; + +import lombok.RequiredArgsConstructor; +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.util.ExceptionLogger; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.Permission; +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 net.dv8tion.jda.api.events.session.ReadyEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.requests.restaction.ChannelAction; + +/** + * Automatically creates a temporary voice channel when a user joins the {@link ModerationConfig#getCustomVoiceChannel() custom voice channel template} and deletes that voice channel when there are no users left. + */ +@RequiredArgsConstructor +public class CustomVCListener extends ListenerAdapter { + private final BotConfig botConfig; + private final CustomVCRepository repository; + private final CustomVCButtonHandler buttonHandler; + + @Override + public void onGuildVoiceUpdate(GuildVoiceUpdateEvent event) { + AudioChannelUnion vcJoined = event.getChannelJoined(); + long customVoiceChannelId = botConfig.get(event.getGuild()).getModerationConfig().getCustomVoiceChannelId(); + if (vcJoined != null && vcJoined.getIdLong() == customVoiceChannelId) { + createCustomVC(event, vcJoined); + } + AudioChannelUnion vcLeft = event.getChannelLeft(); + if (vcLeft != null && repository.isCustomVoiceChannel(vcLeft.getIdLong()) && vcLeft.getMembers().isEmpty()) { + vcLeft.delete().queue(); + } + } + + private void createCustomVC(GuildVoiceUpdateEvent event, AudioChannelUnion vcJoined) { + ChannelAction copy = vcJoined.createCopy(); + copy + .setName("custom-" + event.getMember().getId()) + .addMemberPermissionOverride( + event.getMember().getIdLong(), + List.of(Permission.MANAGE_CHANNEL, Permission.VIEW_CHANNEL), + List.of()) + .queue(newChannel -> { + repository.addCustomVoiceChannel(newChannel.getIdLong(), event.getMember().getIdLong()); + if (!(newChannel instanceof VoiceChannel newVC)) { + ExceptionLogger.capture(new IllegalStateException("expected VoiceChannel to be created, got " + newChannel.getClass().getCanonicalName())); + newChannel.delete().queue(); + return; + } + event.getGuild().moveVoiceMember(event.getMember(), newVC).queue(); + newVC.sendMessageEmbeds(new EmbedBuilder() + .setTitle("Your personal Voice Channel") + .setDescription(""" + This is your personal, temporary voice channel. + You can configure this channel using the button below, the `/vc-control` command or the Discord settings. + This channel will be deleted as soon as all people leave this channel. + """) + .build()) + .addContent(event.getMember().getAsMention()) + .addActionRow(buttonHandler.createMakePrivateButton()) + .queue(); + }); + } + + @Override + public void onReady(ReadyEvent event) { + for (long channelId : repository.getAllCustomVoiceChannels()) { + VoiceChannel vc = event.getJDA().getVoiceChannelById(channelId); + if (vc == null) { + repository.removeCustomVoiceChannel(channelId); + } else if (vc.getMembers().isEmpty()) { + vc.delete().queue(); + repository.removeCustomVoiceChannel(channelId); + } + } + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCRepository.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCRepository.java new file mode 100644 index 000000000..ba4f3d5ad --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCRepository.java @@ -0,0 +1,63 @@ +package net.discordjug.javabot.systems.custom_vc; + +import java.util.List; + +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +/** + * Stores currently active custom voice channels and the owners of these channels. + */ +@Repository +@RequiredArgsConstructor +public class CustomVCRepository { + + private final JdbcTemplate jdbcTemplate; + + /** + * Stores a new custom voice channel. + * @param id the channel ID + * @param ownerId the ID of the owner of the voice channel + */ + public void addCustomVoiceChannel(long id, long ownerId) { + jdbcTemplate.update("INSERT INTO custom_vc (channel_id, owner_id) VALUES (?, ?)", + id, ownerId); + } + + /** + * Removes a custom voice channel. + * @param id the channel ID + */ + public void removeCustomVoiceChannel(long id) { + jdbcTemplate.update("DELETE FROM custom_vc WHERE channel_id = ?", id); + } + + /** + * Checks whether a channel is a custom voice channel. + * @param id the channel ID + * @return {@code true} if the channel is a custom voice channel, else {@code false} + */ + public boolean isCustomVoiceChannel(long id) { + return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM custom_vc WHERE channel_id = ?", (rs, rowId) -> rs.getInt(1), id) > 0; + } + + /** + * Gets the owner of a custom voice channel. + * @param voiceChannelId the ID of the voice channel + * @return the ID of the owner of the custom voice channel + */ + public long getOwnerId(long voiceChannelId) { + return jdbcTemplate.queryForObject("SELECT owner_id FROM custom_vc WHERE channel_id = ?", + (rs, rowId) -> rs.getLong(1), + voiceChannelId); + } + + /** + * Gets all custom voice channels of all guilds. + * @return a {@link List} of all custom voice channel IDs + */ + public List getAllCustomVoiceChannels() { + return jdbcTemplate.query("SELECT channel_id FROM custom_vc", (rs, row) -> rs.getLong(1)); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCAddMemberSubcommand.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCAddMemberSubcommand.java new file mode 100644 index 000000000..23ea8e91d --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCAddMemberSubcommand.java @@ -0,0 +1,31 @@ +package net.discordjug.javabot.systems.custom_vc.commands; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.custom_vc.CustomVCRepository; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; + +/** + * Adds a member to a custom voice channel. + */ +public class CustomVCAddMemberSubcommand extends CustomVCChangeMembersSubcommand { + + public CustomVCAddMemberSubcommand(CustomVCRepository dataStorage, + BotConfig botConfig) { + super(new SubcommandData("add-member", "adds a member to the voice channel"), dataStorage, botConfig); + } + + @Override + protected void apply(VoiceChannel vc, Member member, SlashCommandInteractionEvent event) { + vc.upsertPermissionOverride(member) + .setAllowed(Permission.VIEW_CHANNEL) + .queue(); + event + .reply("Successfully added " + member.getAsMention() + " to " + vc.getAsMention() + ". They can now join the voice channel.") + .setEphemeral(true) + .queue(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCChangeMembersSubcommand.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCChangeMembersSubcommand.java new file mode 100644 index 000000000..ef4c02a67 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCChangeMembersSubcommand.java @@ -0,0 +1,86 @@ +package net.discordjug.javabot.systems.custom_vc.commands; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.custom_vc.CustomVCRepository; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.entities.GuildVoiceState; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; + +/** + * Abstract/base subcommand for changing the allowed members in custom voice channels. + */ +abstract class CustomVCChangeMembersSubcommand extends SlashCommand.Subcommand { + + protected final BotConfig botConfig; + private final CustomVCRepository repository; + + /** + * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SubcommandData}. + * @param subcommandData the configuration (name and description) of the subcommand + * @param repository The repository storing information about custom voice channels + * @param botConfig the main configuration of the bot + */ + protected CustomVCChangeMembersSubcommand(SubcommandData subcommandData, CustomVCRepository repository, BotConfig botConfig) { + this.botConfig = botConfig; + this.repository = repository; + setCommandData( + subcommandData + .addOption(OptionType.USER, "member", "The member in question") + ); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (botConfig.get(event.getGuild()).getModerationConfig().getCustomVoiceChannelId() == 0) { + Responses.error(event, "This feature is disabled.").queue(); + return; + } + VoiceChannel vc = getCustomVoiceChannel(event); + if (vc == null) { + Responses.error(event, "This command be used in custom voice channels only. You can create a custom voice channel by joining " + botConfig.get(event.getGuild()).getModerationConfig().getCustomVoiceChannel().getAsMention() + ".") + .queue(); + return; + } + if (repository.getOwnerId(vc.getIdLong()) != event.getMember().getIdLong()) { + Responses.error(event, "Only the owner of the custom voice channel can use this command. You can create your own custom voice channel by joining " + botConfig.get(event.getGuild()).getModerationConfig().getCustomVoiceChannel().getAsMention() + ".") + .queue(); + return; + } + Member member = event.getOption("member", null, OptionMapping::getAsMember); + if (member == null) { + Responses.replyMissingArguments(event).queue(); + return; + } + + apply(vc, member, event); + } + + protected abstract void apply(VoiceChannel vc, Member member, SlashCommandInteractionEvent event); + + private VoiceChannel getCustomVoiceChannel(SlashCommandInteractionEvent event) { + if (repository.isCustomVoiceChannel(event.getChannelIdLong()) && event.getChannel().getType() == ChannelType.VOICE) { + return event.getChannel().asVoiceChannel(); + } + GuildVoiceState voiceState = event.getMember().getVoiceState(); + if (voiceState == null) { + return null; + } + AudioChannelUnion channel = voiceState.getChannel(); + if (channel == null || channel.getType() != ChannelType.VOICE) { + return null; + } + if (repository.isCustomVoiceChannel(voiceState.getChannel().getIdLong())) { + return voiceState.getChannel().asVoiceChannel(); + } + return null; + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCControlCommand.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCControlCommand.java new file mode 100644 index 000000000..aac9ffecf --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCControlCommand.java @@ -0,0 +1,21 @@ +package net.discordjug.javabot.systems.custom_vc.commands; + +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; + +/** + * Command for managing custom voice channels. + */ +public class CustomVCControlCommand extends SlashCommand { + /** + * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData}. + * @param addMemberSubcommand /vc-control add-member + * @param removeMemberSubcommand /vc-control remove-member + */ + public CustomVCControlCommand(CustomVCAddMemberSubcommand addMemberSubcommand, CustomVCRemoveMemberSubcommand removeMemberSubcommand) { + setCommandData(Commands.slash("vc-control", "Manages custom voice channels") + .setGuildOnly(true) + ); + addSubcommands(addMemberSubcommand, removeMemberSubcommand); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCRemoveMemberSubcommand.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCRemoveMemberSubcommand.java new file mode 100644 index 000000000..4ad26d139 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCRemoveMemberSubcommand.java @@ -0,0 +1,53 @@ +package net.discordjug.javabot.systems.custom_vc.commands; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.custom_vc.CustomVCRepository; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; + +/** + * Command for removing a member from a custom voice channel. + * Only the owner of the custom voice channel can use this command. + * This command ensures that the member in question is no longer in the custom voice channel and can no longer join that channel. + */ +public class CustomVCRemoveMemberSubcommand extends CustomVCChangeMembersSubcommand { + + public CustomVCRemoveMemberSubcommand(CustomVCRepository dataStorage, + BotConfig botConfig) { + super(new SubcommandData("remove-member", "removes a member to the voice channel"), dataStorage, botConfig); + } + + @Override + protected void apply(VoiceChannel vc, Member member, SlashCommandInteractionEvent event) { + if (member.getRoles().contains(botConfig.get(event.getGuild()).getModerationConfig().getStaffRole())) { + Responses.error(event, "Cannot remove staff members from custom voice channels.").queue(); + return; + } + + if (event.getMember().getIdLong() == member.getIdLong()) { + Responses.error(event, "You cannot perform this action on yourself.").queue(); + return; + } + + if (member.getVoiceState().getChannel() != null && member.getVoiceState().getChannel().getIdLong() == vc.getIdLong()) { + VoiceChannel afkChannel = event.getGuild().getAfkChannel(); + if (afkChannel == null) { + event.getGuild().kickVoiceMember(member).queue(); + } else { + event.getGuild().moveVoiceMember(member, afkChannel).queue(); + } + } + + vc.upsertPermissionOverride(member) + .setDenied(Permission.VIEW_CHANNEL) + .queue(); + event + .reply("Successfully removed " + member.getAsMention() + " from " + vc.getAsMention() + ". They can no longer join the voice channel.") + .setEphemeral(true) + .queue(); + } +} diff --git a/src/main/resources/database/migrations/09-15-2024_custom_vcs.sql b/src/main/resources/database/migrations/09-15-2024_custom_vcs.sql new file mode 100644 index 000000000..e08363295 --- /dev/null +++ b/src/main/resources/database/migrations/09-15-2024_custom_vcs.sql @@ -0,0 +1,4 @@ +CREATE TABLE custom_vc ( + channel_id BIGINT NOT NULL PRIMARY KEY, + owner_id BIGINT NOT NULL +) From 81dd54243244bafa3a685e46c8299dc22cc0cf5c Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 15 Sep 2024 14:05:25 +0200 Subject: [PATCH 2/2] remove a newline --- .../javabot/systems/custom_vc/CustomVCButtonHandler.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java index 0ae2ebb09..541d9136c 100644 --- a/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java @@ -58,8 +58,7 @@ private void changeVisibility(ButtonInteractionEvent event, Button newButton, St .asVoiceChannel() .upsertPermissionOverride(event.getGuild().getPublicRole()); permissionModifier.accept(permissionOverrideAction, Permission.VIEW_CHANNEL); - permissionOverrideAction - .queue(); + permissionOverrideAction.queue(); event .editButton(newButton) .flatMap(edited -> {