diff --git a/README.md b/README.md index 66804ca..41b3ed1 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ A Discord music bot using JDA and Lavaplayer. -Commands: `/play` (URL or search), `/pause`, `/skip`, `/stop`, `/queue`, and `/volume` (0–100). +Commands: `/play` (URL or search), `/pause`, `/skip`, `/stop`, `/queue`, `/volume` (0–100), and `/timer`. ## Run (with yt-cipher) -Create a Discord application/bot and invite it with the `bot` and `applications.commands` scopes. Grant View Channels, Send Messages, Embed Links, Connect, and Speak in the channels it uses. No privileged gateway intents are required. +Create a Discord application/bot and invite it with the `bot` and `applications.commands` scopes. Grant View Channels, Send Messages, Embed Links, Connect, and Speak in the channels it uses. Timers need View Channels and Send Messages in their target text channel; voice return additionally requires Move Members in every source voice/stage channel and View Channel/Connect in the destination voice channel. Mentioning roles may require mention permissions. No privileged gateway intents are required. With Docker Compose installed and the GHCR image published: diff --git a/TESTING.md b/TESTING.md index 1021a5f..2e41606 100644 --- a/TESTING.md +++ b/TESTING.md @@ -17,6 +17,12 @@ JUnit Jupiter and Mockito test our decisions, not the external services: - `YoutubeConfigurationTest`: pure environment parsing, blank/trimmed values, independent optional settings, paired poToken/visitor data, and credential redaction in parsed settings' string representation and validation errors. +- `TimerTest`: default and interval notice schedules, one-minute warning + deduplication, mentions, guild-only invocation, and completion-time voice return + for connected members only, Administrator defaults without caller enforcement, + and bot-permission preflight (text, destination voice, source voice/stage, and + non-mentionable roles). Scheduled notices and completion callbacks are + captured without waiting or contacting Discord. Tests inject the loader and activity callback, manually deliver load/playback callbacks, and parse configuration from a map. They never construct a connected @@ -37,6 +43,8 @@ agent for modern JDKs; no application runtime dependency is added. - Add a focused regression test when fixing a substantive bug. After deployment or dependency updates, manually check Discord connectivity, -playback, queue advancement, pause, and stop on the intended host. YouTube/IP, -cipher, and voice transport issues belong to this operational smoke check, not +playback, queue advancement, pause, and stop on the intended host. Also create a +short `/timer` with updates, a mention, and a voice-return destination; verify +notice delivery and voice movement with the bot's actual channel permissions. +YouTube/IP, cipher, and voice transport issues belong to this operational smoke check, not the deterministic Gradle suite. diff --git a/src/main/java/net/theinfinitymc/infinitybot/Command.java b/src/main/java/net/theinfinitymc/infinitybot/Command.java index 2d7f68a..101ca57 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/Command.java +++ b/src/main/java/net/theinfinitymc/infinitybot/Command.java @@ -2,6 +2,7 @@ import lombok.Data; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions; import net.dv8tion.jda.api.interactions.commands.build.OptionData; import java.util.List; @@ -13,5 +14,9 @@ public abstract class Command { private final String description; private final List options; + public DefaultMemberPermissions getDefaultPermissions() { + return DefaultMemberPermissions.ENABLED; + } + public abstract void execute(SlashCommandInteractionEvent event); } diff --git a/src/main/java/net/theinfinitymc/infinitybot/CommandListener.java b/src/main/java/net/theinfinitymc/infinitybot/CommandListener.java index f60474e..9513027 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/CommandListener.java +++ b/src/main/java/net/theinfinitymc/infinitybot/CommandListener.java @@ -29,7 +29,9 @@ public void registerCommands(JDA jda, AudioManager audioManager) { for (Class commandClass : commandClasses) { try { Command command = commandClass.asSubclass(Command.class).getConstructor(AudioManager.class).newInstance(audioManager); - commandListUpdateAction = commandListUpdateAction.addCommands(Commands.slash(command.getName(), command.getDescription()).addOptions(command.getOptions())); + commandListUpdateAction = commandListUpdateAction.addCommands(Commands.slash(command.getName(), command.getDescription()) + .setDefaultPermissions(command.getDefaultPermissions()) + .addOptions(command.getOptions())); commands.put(command.getName(), command); } catch (Exception e) { throw new RuntimeException(e); diff --git a/src/main/java/net/theinfinitymc/infinitybot/commands/Timer.java b/src/main/java/net/theinfinitymc/infinitybot/commands/Timer.java new file mode 100644 index 0000000..17af7bc --- /dev/null +++ b/src/main/java/net/theinfinitymc/infinitybot/commands/Timer.java @@ -0,0 +1,149 @@ +package net.theinfinitymc.infinitybot.commands; + +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.Guild; +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.middleman.AudioChannel; +import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +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.DefaultMemberPermissions; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.theinfinitymc.infinitybot.AudioManager; +import net.theinfinitymc.infinitybot.Command; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +public class Timer extends Command { + + public Timer(AudioManager audioManager) { + super( + audioManager, + "timer", + "Sets a timer for the specified time. Will pull users back to channel at end if specified.", + List.of( + new OptionData(OptionType.STRING, "name", "Name for the timer") + .setRequired(true), + new OptionData(OptionType.CHANNEL, "text-channel", "Text channel to post timer in") + .setRequired(true).setChannelTypes(ChannelType.TEXT), + new OptionData(OptionType.INTEGER, "length", "The length of the timer, in minutes") + .setRequired(true).setMinValue(1), + new OptionData(OptionType.INTEGER, "notify-interval", "How often to post separate update notifications, in minutes") + .setRequired(false).setMinValue(1), + new OptionData(OptionType.BOOLEAN, "one-minute-warning", "Whether or not to notify with one minute remaining") + .setRequired(false), + new OptionData(OptionType.MENTIONABLE, "notify-mention", "Who to mention for updates") + .setRequired(false), + new OptionData(OptionType.CHANNEL, "return-voice-channel", "Channel to send all voice users to when the timer completes") + .setRequired(false).setChannelTypes(ChannelType.VOICE) + ) + ); + } + + @Override + public DefaultMemberPermissions getDefaultPermissions() { + return DefaultMemberPermissions.enabledFor(Permission.ADMINISTRATOR); + } + + public void execute(SlashCommandInteractionEvent event) { + if (!event.isFromGuild()) { + event.getHook().editOriginal("Timers can only be created in a server.").queue(); + return; + } + + // Required parameters + + final String timerName = Objects.requireNonNull(event.getOption("name")).getAsString(); + final TextChannel textChannel = Objects.requireNonNull(event.getOption("text-channel")).getAsChannel().asTextChannel(); + final long timerLength = Objects.requireNonNull(event.getOption("length")).getAsLong(); + + // Optional parameters + + final OptionMapping notifyIntervalOption = event.getOption("notify-interval"); + final long notifyInterval = notifyIntervalOption != null ? notifyIntervalOption.getAsLong() : timerLength; + + final OptionMapping oneMinuteWarningOption = event.getOption("one-minute-warning"); + boolean sendOneMinuteWarning = oneMinuteWarningOption != null ? oneMinuteWarningOption.getAsBoolean() : false; + + final OptionMapping mentionOption = event.getOption("notify-mention"); + final String mention = mentionOption != null ? mentionOption.getAsMentionable().getAsMention() + " " : ""; + + final OptionMapping voiceChannelOption = event.getOption("return-voice-channel"); + final VoiceChannel voiceChannel = voiceChannelOption != null ? voiceChannelOption.getAsChannel().asVoiceChannel() : null; + + // Validate bot permissions before sending or scheduling any notices. + // Caller access is controlled by Discord's command permissions, not here. + final Member self = Objects.requireNonNull(event.getGuild()).getSelfMember(); + if (!self.hasPermission(textChannel, Permission.VIEW_CHANNEL, Permission.MESSAGE_SEND)) { + event.getHook().editOriginal("I need View Channel and Send Messages in the timer's text channel.").queue(); + return; + } + if (mentionOption != null && mentionOption.getAsMentionable() instanceof Role role + && !role.isMentionable() && !self.hasPermission(textChannel, Permission.MESSAGE_MENTION_EVERYONE)) { + event.getHook().editOriginal("I need Mention Everyone permission in the timer's text channel to mention that role.").queue(); + return; + } + if (voiceChannel != null) { + if (!self.hasPermission(voiceChannel, Permission.VIEW_CHANNEL, Permission.VOICE_CONNECT)) { + event.getHook().editOriginal("I need View Channel and Connect in the return voice channel.").queue(); + return; + } + // Members may change channels during the timer, so check every possible source, + // including stage channels, rather than just currently occupied channels. + for (GuildChannel channel : Objects.requireNonNull(event.getGuild()).getChannels()) { + if (channel instanceof AudioChannel && !self.hasPermission(channel, Permission.VOICE_MOVE_OTHERS)) { + event.getHook().editOriginal("I need Move Members in every voice and stage channel to return all users.").queue(); + return; + } + } + } + + // Reply to the actual slash command + + event.getHook().editOriginal("Creating timer...").queue(); + + // Send start of timer message + + textChannel.sendMessage(mention + "Timer for *" + timerName + "* set for **" + timerLength + "** minutes").queue(); + + // Schedule interval messages + + for (long notifyTime = notifyInterval; notifyTime < timerLength; notifyTime += notifyInterval) { + if (notifyTime == timerLength - 1) { + sendOneMinuteWarning = true; + break; + } + + textChannel.sendMessage(mention + "**" + (timerLength - notifyTime) + "** minutes remaining for *" + timerName + "*").queueAfter(notifyTime, TimeUnit.MINUTES); + } + + // Schedule one-minute warning + + if (sendOneMinuteWarning && timerLength > 1) { + textChannel.sendMessage(mention + "Only ***1*** minute remaining for *" + timerName + "*").queueAfter(timerLength - 1, TimeUnit.MINUTES); + } + + // Schedule final message and voice channel movement + + textChannel.sendMessage(mention + "*" + timerName + "* complete!").onSuccess(a -> { + if (voiceChannel != null) { + final Guild guild = voiceChannel.getGuild(); + for (Member member : voiceChannel.getGuild().getMembers()) { + GuildVoiceState memberVoiceState = Objects.requireNonNull(member.getVoiceState()); + if (memberVoiceState.inAudioChannel()) { + guild.moveVoiceMember(member, voiceChannel).queueAfter(3, TimeUnit.SECONDS); + } + } + } + }).queueAfter(timerLength, TimeUnit.MINUTES); + } + +} diff --git a/src/test/java/net/theinfinitymc/infinitybot/commands/TimerTest.java b/src/test/java/net/theinfinitymc/infinitybot/commands/TimerTest.java new file mode 100644 index 0000000..514e351 --- /dev/null +++ b/src/test/java/net/theinfinitymc/infinitybot/commands/TimerTest.java @@ -0,0 +1,216 @@ +package net.theinfinitymc.infinitybot.commands; + +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.SelfMember; +import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.GuildVoiceState; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.concrete.StageChannel; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +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.OptionMapping; +import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.util.ArrayList; +import java.util.List; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class TimerTest { + private record Notice(String text, long minutes, MessageCreateAction action) {} + private final List notices = new ArrayList<>(); + private final Map> callbacks = new HashMap<>(); + private final SelfMember self = mock(SelfMember.class); + private final TextChannel channel = mock(TextChannel.class); + private final SlashCommandInteractionEvent event = mock(SlashCommandInteractionEvent.class, RETURNS_DEEP_STUBS); + + private void prepare(long length, Long interval, boolean warning) { + when(event.isFromGuild()).thenReturn(true); + when(event.getGuild().getSelfMember()).thenReturn(self); + when(self.hasPermission(any(GuildChannel.class), any(Permission[].class))).thenReturn(true); + when(event.getOption("name").getAsString()).thenReturn("Game"); + when(event.getOption("text-channel").getAsChannel().asTextChannel()).thenReturn(channel); + when(event.getOption("length").getAsLong()).thenReturn(length); + if (interval == null) when(event.getOption("notify-interval")).thenReturn(null); + else when(event.getOption("notify-interval").getAsLong()).thenReturn(interval); + when(event.getOption("one-minute-warning").getAsBoolean()).thenReturn(warning); + when(event.getOption("notify-mention")).thenReturn(null); + when(event.getOption("return-voice-channel")).thenReturn(null); + when(channel.sendMessage(anyString())).thenAnswer(invocation -> { + String text = invocation.getArgument(0); + MessageCreateAction action = mock(MessageCreateAction.class, RETURNS_SELF); + doAnswer(ignored -> { notices.add(new Notice(text, 0, action)); return null; }).when(action).queue(); + when(action.queueAfter(anyLong(), eq(TimeUnit.MINUTES))).thenAnswer(call -> { + notices.add(new Notice(text, call.getArgument(0), action)); + return null; + }); + when(action.onSuccess(any())).thenAnswer(call -> { + callbacks.put(action, call.getArgument(0)); + return action; + }); + return action; + }); + } + + @ParameterizedTest + @CsvSource(value = { + "10|null|false|0,10", "10|20|false|0,10", + "10|3|false|0,3,6,9,10", "10|3|true|0,3,6,9,10", + "10|4|true|0,4,8,9,10", "10|5|false|0,5,10", + "1|1|true|0,1" + }, delimiter = '|', nullValues = "null") + void schedulesOnlyIntendedNotices(long length, Long interval, boolean warning, String expected) { + prepare(length, interval, warning); + OptionMapping mention = mock(OptionMapping.class, RETURNS_DEEP_STUBS); + when(mention.getAsMentionable().getAsMention()).thenReturn("<@123>"); + when(event.getOption("notify-mention")).thenReturn(mention); + new Timer(null).execute(event); + assertTrue(notices.stream().allMatch(n -> n.text().startsWith("<@123> "))); + assertEquals(expected, String.join(",", notices.stream().map(n -> Long.toString(n.minutes())).toList())); + for (Notice notice : notices.subList(1, notices.size() - 1)) { + long remaining = length - notice.minutes(); + assertTrue(notice.text().contains(remaining == 1 ? "***1*** minute" : "**" + remaining + "** minutes")); + } + complete(length); // No destination is a valid completion path. + } + + @Test + void omittedOptionalSettingsUseDefaults() { + prepare(3, null, false); + when(event.getOption("one-minute-warning")).thenReturn(null); + new Timer(null).execute(event); + assertEquals(List.of(0L, 3L), notices.stream().map(Notice::minutes).toList()); + } + + @Test + void voiceReturnReadsMembershipAtCompletionAndMovesOnlyConnectedMembers() { + prepare(5, null, false); + VoiceChannel destination = mock(VoiceChannel.class); + Guild guild = mock(Guild.class, RETURNS_DEEP_STUBS); + when(destination.getGuild()).thenReturn(guild); + OptionMapping option = mock(OptionMapping.class, RETURNS_DEEP_STUBS); + when(option.getAsChannel().asVoiceChannel()).thenReturn(destination); + when(event.getOption("return-voice-channel")).thenReturn(option); + new Timer(null).execute(event); + verify(guild, never()).getMembers(); + Member connected = member(true); + Member disconnected = member(false); + when(guild.getMembers()).thenReturn(List.of(connected, disconnected)); + complete(5); + verify(guild.moveVoiceMember(connected, destination)).queueAfter(3, TimeUnit.SECONDS); + verify(guild, never()).moveVoiceMember(disconnected, destination); + } + + @Test + void rejectsNonGuildInvocationBeforeReadingOptions() { + when(event.isFromGuild()).thenReturn(false); + new Timer(null).execute(event); + verify(event, never()).getOption(anyString()); + verifyNoInteractions(channel); + } + + @Test + void administratorIsOnlyTheDefaultNotAnExecutionRequirement() { + Timer timer = new Timer(null); + assertEquals(Long.valueOf(Permission.ADMINISTRATOR.getRawValue()), + timer.getDefaultPermissions().getPermissionsRaw()); + prepare(3, null, false); + timer.execute(event); + assertEquals(List.of(0L, 3L), notices.stream().map(Notice::minutes).toList()); + verify(event, never()).getMember(); + } + + @ParameterizedTest + @CsvSource({ + "VIEW_CHANNEL,false", "MESSAGE_SEND,false", + "VIEW_CHANNEL,true", "VOICE_CONNECT,true", "VOICE_MOVE_OTHERS,true" + }) + void missingBotPermissionRejectsBeforeSendingOrScheduling(Permission missing, boolean voice) { + prepare(5, null, false); + GuildChannel target = channel; + if (voice) { + VoiceChannel destination = mock(VoiceChannel.class); + OptionMapping option = mock(OptionMapping.class, RETURNS_DEEP_STUBS); + when(option.getAsChannel().asVoiceChannel()).thenReturn(destination); + when(event.getOption("return-voice-channel")).thenReturn(option); + when(event.getGuild().getChannels()).thenReturn(List.of(destination)); + target = destination; + } + when(self.hasPermission(eq(target), any(Permission[].class))).thenAnswer(call -> + !List.of((Permission[]) call.getRawArguments()[1]).contains(missing)); + new Timer(null).execute(event); + verify(channel, never()).sendMessage(anyString()); + assertTrue(notices.isEmpty()); + String error = missing == Permission.VOICE_MOVE_OTHERS ? "Move Members" + : voice ? "return voice channel" : "text channel"; + verify(event.getHook()).editOriginal(contains(error)); + } + + @Test + void voiceReturnChecksUnoccupiedSourceStageChannelsToo() { + prepare(3, null, false); + VoiceChannel destination = mock(VoiceChannel.class); + StageChannel source = mock(StageChannel.class); + OptionMapping option = mock(OptionMapping.class, RETURNS_DEEP_STUBS); + when(option.getAsChannel().asVoiceChannel()).thenReturn(destination); + when(event.getOption("return-voice-channel")).thenReturn(option); + when(event.getGuild().getChannels()).thenReturn(List.of(destination, source)); + when(self.hasPermission(source, Permission.VOICE_MOVE_OTHERS)).thenReturn(false); + new Timer(null).execute(event); + verify(channel, never()).sendMessage(anyString()); + verify(event.getHook()).editOriginal(contains("Move Members")); + } + + @ParameterizedTest + @CsvSource({"true,false,true", "false,true,true", "false,false,false"}) + void roleMentionRequiresMentionableRoleOrBotMentionPermission( + boolean mentionable, boolean permission, boolean accepted) { + prepare(3, null, false); + Role role = mock(Role.class); + when(role.getAsMention()).thenReturn("<@&123>"); + when(role.isMentionable()).thenReturn(mentionable); + OptionMapping option = mock(OptionMapping.class); + when(option.getAsMentionable()).thenReturn(role); + when(event.getOption("notify-mention")).thenReturn(option); + when(self.hasPermission(channel, Permission.MESSAGE_MENTION_EVERYONE)).thenReturn(permission); + new Timer(null).execute(event); + if (accepted) { + assertEquals(List.of(0L, 3L), notices.stream().map(Notice::minutes).toList()); + assertTrue(notices.stream().allMatch(n -> n.text().startsWith("<@&123> "))); + } else { + verify(channel, never()).sendMessage(anyString()); + verify(event.getHook()).editOriginal(contains("Mention Everyone")); + } + } + + private void complete(long length) { + Notice last = notices.getLast(); + assertEquals(length, last.minutes()); + assertTrue(last.text().contains("complete")); + assertEquals(1, callbacks.size(), "Only the completion notice should have a callback"); + Consumer completion = callbacks.get(last.action()); + assertNotNull(completion, "Voice return must be attached to the completion notice"); + completion.accept(mock(Message.class)); + } + + private static Member member(boolean inVoice) { + Member member = mock(Member.class); + GuildVoiceState state = mock(GuildVoiceState.class); + when(member.getVoiceState()).thenReturn(state); + when(state.inAudioChannel()).thenReturn(inVoice); + return member; + } +}