Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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:

Expand Down
10 changes: 8 additions & 2 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
112 changes: 112 additions & 0 deletions src/main/java/net/theinfinitymc/infinitybot/commands/Timer.java
Original file line number Diff line number Diff line change
@@ -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);
}

}
134 changes: 134 additions & 0 deletions src/test/java/net/theinfinitymc/infinitybot/commands/TimerTest.java
Original file line number Diff line number Diff line change
@@ -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<Notice> notices = new ArrayList<>();
private final Map<MessageCreateAction, Consumer<Message>> 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<Message> 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;
}
}
Loading