From c9fb7a27a67458283d5dbd1d94697a43f61af476 Mon Sep 17 00:00:00 2001 From: Ian Ryan <10286358+nextinfinity@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:55:41 -0700 Subject: [PATCH 1/4] Add TimerBot reminders and voice return to InfinityBot --- README.md | 26 +++- TESTING.md | 10 +- .../infinitybot/commands/Timer.java | 112 +++++++++++++++ .../infinitybot/commands/TimerTest.java | 134 ++++++++++++++++++ 4 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 src/main/java/net/theinfinitymc/infinitybot/commands/Timer.java create mode 100644 src/test/java/net/theinfinitymc/infinitybot/commands/TimerTest.java diff --git a/README.md b/README.md index 66804ca..c65d2c1 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,32 @@ # InfinityBot -A Discord music bot using JDA and Lavaplayer. +A Discord music and timer 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`. + +## Timers + +The `/timer` command incorporates [TimerBot](https://github.com/nextinfinity/TimerBot)'s timed reminders and optional voice return, useful for activities where users split into separate voice channels and regroup later. + +| Parameter | Purpose | +| --- | --- | +| `name` | Required friendly name for the timer. | +| `text-channel` | Required text channel for the start, updates, and completion notice. | +| `length` | Required duration in minutes (minimum 1). | +| `notify-interval` | Optional update interval in minutes (minimum 1); defaults to no intermediate updates. | +| `one-minute-warning` | Optional one-minute warning; off by default. | +| `notify-mention` | Optional user or role to mention in every notice. | +| `return-voice-channel` | Optional voice channel to move **all connected voice users in the server** to at completion. | + +For example: `/timer name:Break text-channel:#general length:10 notify-interval:5 one-minute-warning:True`. + +An interval update with one minute remaining becomes a single one-minute warning, even if the warning option is off. One-minute timers send only start and completion notices. Voice return runs three seconds after the completion message is successfully sent and uses membership at completion, not when the timer starts. + +Timers run in memory: restarting the bot loses pending notices and voice returns. Music commands remain independent of timers. ## 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 and access to 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..9e54a92 100644 --- a/TESTING.md +++ b/TESTING.md @@ -17,6 +17,10 @@ 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. 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 +41,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/commands/Timer.java b/src/main/java/net/theinfinitymc/infinitybot/commands/Timer.java new file mode 100644 index 0000000..6e566d9 --- /dev/null +++ b/src/main/java/net/theinfinitymc/infinitybot/commands/Timer.java @@ -0,0 +1,112 @@ +package net.theinfinitymc.infinitybot.commands; + +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.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.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) + ) + ); + } + + 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; + + // 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..462ac19 --- /dev/null +++ b/src/test/java/net/theinfinitymc/infinitybot/commands/TimerTest.java @@ -0,0 +1,134 @@ +package net.theinfinitymc.infinitybot.commands; + +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.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 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.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); + } + + 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; + } +} From 04344dac8126fc821284beac48eb8855f705286c Mon Sep 17 00:00:00 2001 From: Ian Ryan <10286358+nextinfinity@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:05:58 -0700 Subject: [PATCH 2/4] Default timers to Administrator and preflight bot permissions --- README.md | 6 +- TESTING.md | 4 +- .../theinfinitymc/infinitybot/Command.java | 5 ++ .../infinitybot/CommandListener.java | 4 +- .../infinitybot/commands/Timer.java | 36 +++++++++ .../infinitybot/commands/TimerTest.java | 81 +++++++++++++++++++ 6 files changed, 133 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c65d2c1..d5fd129 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ The `/timer` command incorporates [TimerBot](https://github.com/nextinfinity/Tim | `notify-mention` | Optional user or role to mention in every notice. | | `return-voice-channel` | Optional voice channel to move **all connected voice users in the server** to at completion. | +`/timer` defaults to **Administrator** access. Server admins can grant access to other roles or users through Discord's standard command permissions; the bot does not impose an additional caller permission check. + +Before scheduling, the bot checks its own text-channel permissions, permission to mention a non-mentionable role when requested, destination voice-channel access, and Move Members in every voice/stage channel when voice return is requested. Missing permissions reject the entire timer with an explanatory reply. Permissions can still change while a timer is running. + For example: `/timer name:Break text-channel:#general length:10 notify-interval:5 one-minute-warning:True`. An interval update with one minute remaining becomes a single one-minute warning, even if the warning option is off. One-minute timers send only start and completion notices. Voice return runs three seconds after the completion message is successfully sent and uses membership at completion, not when the timer starts. @@ -26,7 +30,7 @@ Timers run in memory: restarting the bot loses pending notices and voice returns ## 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. Timers need View Channels and Send Messages in their target text channel; voice return additionally requires Move Members and access to the destination voice channel. Mentioning roles may require mention permissions. 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 9e54a92..2e41606 100644 --- a/TESTING.md +++ b/TESTING.md @@ -19,7 +19,9 @@ JUnit Jupiter and Mockito test our decisions, not the external services: 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. Scheduled notices and completion callbacks are + 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 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 index 6e566d9..cfdb7c0 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/commands/Timer.java +++ b/src/main/java/net/theinfinitymc/infinitybot/commands/Timer.java @@ -1,12 +1,16 @@ 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.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; @@ -43,6 +47,11 @@ public Timer(AudioManager audioManager) { ); } + @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(); @@ -69,6 +78,33 @@ public void execute(SlashCommandInteractionEvent event) { 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 (var 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(); diff --git a/src/test/java/net/theinfinitymc/infinitybot/commands/TimerTest.java b/src/test/java/net/theinfinitymc/infinitybot/commands/TimerTest.java index 462ac19..a774b72 100644 --- a/src/test/java/net/theinfinitymc/infinitybot/commands/TimerTest.java +++ b/src/test/java/net/theinfinitymc/infinitybot/commands/TimerTest.java @@ -1,5 +1,9 @@ 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; @@ -28,11 +32,14 @@ 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); @@ -114,6 +121,80 @@ void rejectsNonGuildInvocationBeforeReadingOptions() { 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); + var source = mock(net.dv8tion.jda.api.entities.channel.concrete.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()); From 5c22cf42a535444794205b4164bd5459760bab8b Mon Sep 17 00:00:00 2001 From: Ian Ryan <10286358+nextinfinity@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:17:40 -0700 Subject: [PATCH 3/4] Use explicit channel types in timer command and tests --- .../java/net/theinfinitymc/infinitybot/commands/Timer.java | 3 ++- .../java/net/theinfinitymc/infinitybot/commands/TimerTest.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/theinfinitymc/infinitybot/commands/Timer.java b/src/main/java/net/theinfinitymc/infinitybot/commands/Timer.java index cfdb7c0..17af7bc 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/commands/Timer.java +++ b/src/main/java/net/theinfinitymc/infinitybot/commands/Timer.java @@ -7,6 +7,7 @@ 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; @@ -97,7 +98,7 @@ public void execute(SlashCommandInteractionEvent event) { } // Members may change channels during the timer, so check every possible source, // including stage channels, rather than just currently occupied channels. - for (var channel : Objects.requireNonNull(event.getGuild()).getChannels()) { + 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; diff --git a/src/test/java/net/theinfinitymc/infinitybot/commands/TimerTest.java b/src/test/java/net/theinfinitymc/infinitybot/commands/TimerTest.java index a774b72..514e351 100644 --- a/src/test/java/net/theinfinitymc/infinitybot/commands/TimerTest.java +++ b/src/test/java/net/theinfinitymc/infinitybot/commands/TimerTest.java @@ -8,6 +8,7 @@ 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; @@ -162,7 +163,7 @@ void missingBotPermissionRejectsBeforeSendingOrScheduling(Permission missing, bo void voiceReturnChecksUnoccupiedSourceStageChannelsToo() { prepare(3, null, false); VoiceChannel destination = mock(VoiceChannel.class); - var source = mock(net.dv8tion.jda.api.entities.channel.concrete.StageChannel.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); From 66798e8d5d2ecc72124e579ad445c2fc160cd0a8 Mon Sep 17 00:00:00 2001 From: Ian Ryan <10286358+nextinfinity@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:19:31 -0700 Subject: [PATCH 4/4] Update README.md --- README.md | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/README.md b/README.md index d5fd129..41b3ed1 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,9 @@ # InfinityBot -A Discord music and timer bot using JDA and Lavaplayer. +A Discord music bot using JDA and Lavaplayer. Commands: `/play` (URL or search), `/pause`, `/skip`, `/stop`, `/queue`, `/volume` (0–100), and `/timer`. -## Timers - -The `/timer` command incorporates [TimerBot](https://github.com/nextinfinity/TimerBot)'s timed reminders and optional voice return, useful for activities where users split into separate voice channels and regroup later. - -| Parameter | Purpose | -| --- | --- | -| `name` | Required friendly name for the timer. | -| `text-channel` | Required text channel for the start, updates, and completion notice. | -| `length` | Required duration in minutes (minimum 1). | -| `notify-interval` | Optional update interval in minutes (minimum 1); defaults to no intermediate updates. | -| `one-minute-warning` | Optional one-minute warning; off by default. | -| `notify-mention` | Optional user or role to mention in every notice. | -| `return-voice-channel` | Optional voice channel to move **all connected voice users in the server** to at completion. | - -`/timer` defaults to **Administrator** access. Server admins can grant access to other roles or users through Discord's standard command permissions; the bot does not impose an additional caller permission check. - -Before scheduling, the bot checks its own text-channel permissions, permission to mention a non-mentionable role when requested, destination voice-channel access, and Move Members in every voice/stage channel when voice return is requested. Missing permissions reject the entire timer with an explanatory reply. Permissions can still change while a timer is running. - -For example: `/timer name:Break text-channel:#general length:10 notify-interval:5 one-minute-warning:True`. - -An interval update with one minute remaining becomes a single one-minute warning, even if the warning option is off. One-minute timers send only start and completion notices. Voice return runs three seconds after the completion message is successfully sent and uses membership at completion, not when the timer starts. - -Timers run in memory: restarting the bot loses pending notices and voice returns. Music commands remain independent of timers. - ## 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. 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.