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] 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; + } +}