Author SHA1 Message Date
anthorob 363e02f409 Delay autoplay triggering until the current track nears its end. 2026-10-31 17:09:46 -04:00
anthorob d43e032c37 fix 2026-10-31 16:15:24 -04:00
anthorob d97c4c6da2 **Add autoplay support for seamless music playback**
Introduced autoplay functionality to keep music playing when the queue ends. Added new `/autoplay` API endpoints, command handling, and service implementations. Enhanced `TrackScheduler`, queue operations, and testing to support autoplay behavior. Integrated YouTube radio mix as the initial provider.
2026-10-31 16:13:04 -04:00
anthorob 764adb6cbc Refactor event handling into modular handlers
Introduce discrete event handlers (`InteractionHandlers`, `GuildLifecycleHandler`, `MessageReceivedHandler`, and more) to replace centralized logic in `EventsHandler`. Simplify handler registration, improve separation of concerns, and enable better testability. Added unit tests to verify isolated execution and failure resilience of handlers.
2026-10-30 22:31:25 -04:00
anthorob e989bff9e3 Remove search results limit in SearchResultHandler 2026-10-30 09:01:39 -04:00
anthorob 0863e0723b **Add playlist identifier to LoadPlaylistTask**
Introduced a new `playlistIdentifier` field in `LoadPlaylistTask` to enhance playlist tracking. Updated relevant method calls to include and utilize the identifier, ensuring more robust metadata management and consistency across the codebase.
2026-10-28 16:55:58 -04:00
anthorob 142cc77ec8 **Add playlist metadata support to queue and events**
Enhanced queueing functionality to include playlist metadata, such as name, identifier, and track count. Updated event payloads to handle playlist information and introduced a new `playlist_added` event for streamlined notifications. Adjusted tests and documentation to reflect these changes.
2026-10-28 16:34:38 -04:00
anthorob d376e245b7 Add author and duration to queue metadata
Included track author and duration in queue metadata. Adjusted key naming for consistency with snake_case formatting.
2026-10-28 04:48:54 -04:00
anthorob 6e99fe5477 **Add artwork URL to queue metadata**
Enhanced queue metadata by including the track's artwork URL in the serialized output. This allows clients to display album art or related visuals alongside track information.
2026-10-28 04:21:13 -04:00
anthorob dba7a84819 **Fix nickname retrieval in GuildRoutes**
Replaced `getNickname()` with `getEffectiveName()` to ensure the correct name is retrieved, falling back to the username when no nickname is set. This improves accuracy and consistency in member summaries.
2026-10-28 03:58:31 -04:00
anthorob 87ba09b001 **Improve event handling with new music and queue updates**
Added new `MusicEvent` types, including `TRACK_ERROR`, `TRACK_STUCK`, and `PLAYER_POSITION`, providing enhanced diagnostics and feedback. Improved queue mutation notifications by introducing granular events like `added`, `removed`, `moved`, `cleared`, and `shuffled`. Updated tests and documentation to reflect the changes, ensuring consistency and clarity across the event system.
2026-10-28 03:58:19 -04:00
anthorob 75b495b08c **Replace JDA with ShardProvider for improved sharding**
Refactored core classes and APIs to use `ShardProvider` instead of `JDA` directly. Added `ShardService` for managing shard connections and activity, improving scalability and separation of concerns. Integrated test coverage to validate shard-level operations and bot endpoints.
2026-10-28 02:59:24 -04:00
anthorob b0664e8835 **Add webhook event publishing for music state changes**
Introduced webhook event publishing to push music-state updates to a configured endpoint. Implemented `WebhookDispatcher` and `NoopEventPublisher` for dispatching events asynchronously or disabling them when no URL is set. Added handling of events like track start/end, queue updates, volume changes, and voice connection changes, enhancing integration with external systems.
2026-10-26 11:43:07 -04:00
anthorob 7443cbfb4b **Refactor command usage printing into CommandUsageFormatter**
Extracted command usage formatting logic from `CommandContainer` into a dedicated `CommandUsageFormatter` class, improving separation of concerns and maintainability. Updated `CommandManager` initialization and added comprehensive unit tests to validate the formatter's functionality.
2026-10-26 09:49:56 -04:00
anthorob f96c2677b6 **Add unit tests for command framework preconditions**
Introduced comprehensive unit tests for precondition classes, including `DisabledCommandPrecondition`, `GuildOnlyPrecondition`, `SlashCommandOnlyPrecondition`, and `PermissionPrecondition`. Also added integration tests for `CommandManager` and `CommandProcessor` to validate functionality.
2026-10-24 05:39:06 -04:00
anthorob 8e4e77cd31 Rename SLASH type to CHAT in command framework.
Replaced all references to the `SLASH` command type with `CHAT` for improved clarity and alignment with its intended functionality. Updated relevant classes, methods, and test cases to reflect this change.
2026-10-24 05:32:51 -04:00
73 changed files with 4184 additions and 541 deletions

No files matched your search

+2
View File
@@ -25,6 +25,8 @@ NETNEUTRALITY_WEBPO_URL=http://webpo:8080
NETNEUTRALITY_WEBPO_PASSWORD=
NETNEUTRALITY_SENTRY_DSN=
NETNEUTRALITY_WEBHOOK_URL=
NETNEUTRALITY_WEBHOOK_SECRET=
GITEA_HOST=
GITEA_TOKEN=
GITEA_OWNER=
+11
View File
@@ -37,6 +37,17 @@ Key variables:
| `NETNEUTRALITY_LAVALINK_URI`, `NETNEUTRALITY_LAVALINK_PASSWORD` | Lavalink connection settings |
| `NETNEUTRALITY_SENTRY_DSN` | Optional Sentry DSN for error reporting |
| `GITEA_HOST`, `GITEA_TOKEN`, `GITEA_OWNER`, `GITEA_REPO` | Optional Gitea settings for issue reporting |
| `NETNEUTRALITY_WEBHOOK_URL`, `NETNEUTRALITY_WEBHOOK_SECRET` | Optional frontend webhook URL and HMAC secret (see below) |
### Webhook events
When `NETNEUTRALITY_WEBHOOK_URL` is set, the bot POSTs JSON music-state events (`TRACK_START`, `TRACK_END`, `QUEUE_UPDATED`, `PLAYER_PAUSED`, `PLAYER_RESUMED`, `VOLUME_CHANGED`, `VOICE_CONNECTED`, `VOICE_DISCONNECTED`) to it asynchronously. Each event has `type`, `guildId`, `timestamp`, a per-guild `seq` and a `payload`. Requests carry an `X-Signature: sha256=<hex HMAC-SHA256 of the body>` header computed with `NETNEUTRALITY_WEBHOOK_SECRET`. Failed deliveries are retried 3 times with exponential backoff and then dropped, so the frontend should still do a full resync on load and periodically. The polling endpoints remain available.
`PLAYER_SEEKED` is sent after a seek with payload `{ position }` (milliseconds). `VOTE_SKIP_UPDATED` is sent after a vote that doesn't skip the track yet, with payload `{ userId, votes, threshold }` (`threshold` is the percentage of listeners needed). A vote that does skip produces the usual `TRACK_END` / `TRACK_START` / `QUEUE_UPDATED` events instead.
`TRACK_START` / `TRACK_END` payloads are `{ identifier, title, author, length, uri, artworkUrl }` (plus `reason` on `TRACK_END`). `uri` and `artworkUrl` may be `null` when the source doesn't provide them.
`VOICE_CONNECTED` / `VOICE_DISCONNECTED` are sent for the bot itself and for any member joining or leaving the voice channel the bot is currently in. Their payload is `{ channelId, userId, username, nickname, bot, self }`, where `nickname` is the member's effective display name in the guild, `bot` marks bot accounts and `self` marks this bot's own voice changes.
## Running with Docker Compose
+4
View File
@@ -6,6 +6,8 @@ NETNEUTRALITY_BOT_PREFIX=$
NETNEUTRALITY_BOT_OWNER=
NETNEUTRALITY_BOT_INVITE=
NETNEUTRALITY_API_PORT=9000
# Optional: number of gateway shards. Leave unset to use Discord's recommended count.
# NETNEUTRALITY_SHARD_TOTAL=1
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
@@ -25,6 +27,8 @@ NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass
NETNEUTRALITY_LAVALINK_NAME=main
NETNEUTRALITY_SENTRY_DSN=
NETNEUTRALITY_WEBHOOK_URL=
NETNEUTRALITY_WEBHOOK_SECRET=
GITEA_HOST=
GITEA_TOKEN=
GITEA_OWNER=
+1
View File
@@ -147,6 +147,7 @@ Concrete commands live under `me.bobert.netneutrality.command.*`, grouped by fea
| `JoinCommand` / `LeaveCommand` | `music` | Slash | Join / leave the voice channel. |
| `CurrentlyPlayingCommand` | `music` | Slash | Show the currently playing track. |
| `ResumeCommand` / `StopCommand` / `VolumeCommand` | `music` | Slash | Playback controls. |
| `AutoplayCommand` | `music` | Slash | Toggle autoplay: queues the YouTube radio mix of the last track so music keeps playing. |
| `BitconnectCommand` | `music` | Slash | Novelty music command. |
| `CreateEmojiReaction` / `ModifyEmojiReaction` | `react` | Message menu | Create/modify emoji-role mappings from a message. |
| `DatabaseCommand`, `PurgeCommand`, `ShutdownCommand`, `JoinClanCommand`, `TestCommand`, `SlashCommandTest`, `TrackStuckTest` | `custom` | Mixed | Owner/debug and personal utilities (the `custom` package is gitignored per-deployment). |
+15
View File
@@ -91,6 +91,21 @@ Key points:
`TaskManager.getInstance().handle(context)`.
- **`null` contexts** (bot messages, consumer-only events) are simply not routed anywhere.
## Webhook music events
`MusicEvent` instances are delivered by `WebhookDispatcher`. Every event carries `type`, `guildId`,
`timestamp` and `seq`; the payloads of the queue and player events are:
| Type | Payload |
|---|---|
| `QUEUE_UPDATED` | `size` (int), `change` (`added`/`playlist_added`/`removed`/`moved`/`cleared`/`shuffled`), `items` (list). Each item has `index` (0-based position, before the mutation for removals), `identifier`, `title`, `author`, `duration_ms`, `uri`, `artwork_url`, `requester_id` and `playlist` (`{name, identifier, track_count}` or `null` when the track was not queued from a playlist). `moved` items also carry `fromIndex` and `toIndex`. `cleared` and `shuffled` have an empty `items` (reload the queue on `shuffled`). `playlist_added` is emitted once per playlist load (instead of one `added` event per track), lists only the tracks actually added (duplicates are skipped) and carries a top-level `playlist` object (`name`, `identifier`, `track_count`, the number actually added). |
| `QUEUE_SHUFFLED` | `size` |
| `TRACK_ERROR` | `track` (`identifier`, `title`, `author`, `length`, `uri`, `artworkUrl`), `message`, `severity` |
| `TRACK_STUCK` | `track` (same fields as above), `thresholdMs` |
| `PLAYER_POSITION` | `position` (ms), `timestamp` (node time, epoch ms), `connected`, `ping` (ms). Sent from Lavalink `PlayerUpdateEvent`, at most once every 5 seconds per guild and only while a track is playing. |
Consumers must handle the `playlist_added` change value and the per-item `playlist` field. The REST queue listing (`GET /music/{guildId}/queue`) also reports `playlist` on every entry.
## Extending the event system
To handle a new event type:
+1 -1
View File
@@ -166,5 +166,5 @@ scope of this migration and is tracked as a follow-up.
All music commands extend [`MusicCommand`](commands.md#base-implementations-corecommandimpl) (guild
only + music role/channel enforced): `PlayCommand`, `SkipCommand`/`VoteSkipCommand`,
`QueueCommand`/`ViewQueueCommand`, `ClearQueueCommand`, `ShuffleCommand`, `JoinCommand`/`LeaveCommand`,
`CurrentlyPlayingCommand`, `ResumeCommand`/`StopCommand`, `VolumeCommand`, `BitconnectCommand`. See
`CurrentlyPlayingCommand`, `ResumeCommand`/`StopCommand`, `VolumeCommand`, `AutoplayCommand`, `BitconnectCommand`. See
[commands.md](commands.md#command-catalog).
+1
View File
@@ -70,6 +70,7 @@ music channel when a context is attached.
| `PATCH /music/{guildId}/queue/reorder` | Move a track (`fromPosition` → `toPosition`). |
| `POST /music/{guildId}/skip`, `/stop`, `/pause`, `/resume`, `/shuffle`, `/leave` | Playback controls. |
| `GET` / `PUT /music/{guildId}/volume` | Read / set volume (0–100). |
| `GET` / `PUT /music/{guildId}/autoplay` | Read / set autoplay (`{"enabled": true}`). In-memory, resets when the player is destroyed. |
| `PUT /music/{guildId}/seek` | Seek to `positionMs`. |
| `GET /music/{guildId}/vote-skip` | Vote-skip status. |
| `POST /music/{guildId}/vote-skip/{userId}` | Cast a vote on behalf of a member. |
+1
View File
@@ -177,6 +177,7 @@
<version>5.3.2</version>
</dependency>
<dependency>
<groupId>io.javalin</groupId>
<artifactId>javalin</artifactId>
+33 -58
View File
@@ -6,7 +6,6 @@ package me.bobert.netneutrality;
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.libraries.jda.JDAVoiceUpdateListener;
import io.sentry.Sentry;
import me.bobert.netneutrality.bootstrap.ApplicationContext;
import me.bobert.netneutrality.bootstrap.Lifecycle;
@@ -14,6 +13,9 @@ import me.bobert.netneutrality.bootstrap.BotState;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.CommandManager;
import me.bobert.netneutrality.db.SessionManager;
import me.bobert.netneutrality.events.EventPublisher;
import me.bobert.netneutrality.events.NoopEventPublisher;
import me.bobert.netneutrality.events.WebhookDispatcher;
import me.bobert.netneutrality.core.tasks.TaskManagerImpl;
import me.bobert.netneutrality.core.scheduled.ScheduledTaskManager;
import me.bobert.netneutrality.core.thread.ThreadManager;
@@ -21,7 +23,15 @@ import me.bobert.netneutrality.db.service.GuildServiceProviderImpl;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.core.command.CommandRegistry;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.hooks.EventListenerHandler;
import me.bobert.netneutrality.hooks.EventsHandler;
import me.bobert.netneutrality.hooks.GuildLifecycleHandler;
import me.bobert.netneutrality.hooks.InteractionHandlers;
import me.bobert.netneutrality.hooks.MessageReceivedHandler;
import me.bobert.netneutrality.hooks.ReadyHandler;
import me.bobert.netneutrality.hooks.VoiceEventPublisher;
import java.util.ArrayList;
import java.util.List;
import me.bobert.netneutrality.http.server.WebApi;
import me.bobert.netneutrality.music.MusicServiceImpl;
import me.bobert.netneutrality.core.thread.ExecutorProvider;
@@ -36,11 +46,8 @@ import me.bobert.netneutrality.db.repository.GuildSettingRepository;
import me.bobert.netneutrality.db.repository.TaskRepository;
import me.bobert.netneutrality.utils.Settings;
import me.bobert.netneutrality.http.WebModule;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import me.bobert.netneutrality.shard.ShardService;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.exceptions.InvalidTokenException;
import net.dv8tion.jda.api.requests.GatewayIntent;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -50,8 +57,7 @@ public class App {
private static final Logger logger = LogManager.getLogger(App.class);
private static App instance;
private final Settings settings;
private JDA jda;
private MusicService music;
private EventPublisher events = NoopEventPublisher.INSTANCE;
private final ApplicationContext context = new ApplicationContext();
static void main() {
@@ -146,22 +152,33 @@ public class App {
UserFunction.bind(guildServices);
DisabledCommandPrecondition.bind(guildServices);
// Music sources (priority 40): initialise the shared player manager; disconnect on shutdown.
music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors));
// Webhook event dispatch (priority 50); stays a no-op publisher when no URL is configured.
String webhookUrl = settings.get("NETNEUTRALITY_WEBHOOK_URL");
if (webhookUrl != null && !webhookUrl.isBlank()) {
events = context.register(new WebhookDispatcher(webhookUrl, settings.get("NETNEUTRALITY_WEBHOOK_SECRET")));
}
// Music (priority 62, above the shards so it stops first): disconnect players while JDA is still up.
MusicService music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors, events));
BotState.bind(guildServices, music);
// JDA gateway; disconnected on shutdown.
context.register(lifecycle(60, this::initJDA, this::disconnect));
// JDA shards (priority 60); the events handler is attached to every shard before it connects.
List<EventListenerHandler<?>> handlers = new ArrayList<>();
handlers.add(new MessageReceivedHandler(settings, guildServices));
handlers.addAll(InteractionHandlers.create(settings));
handlers.add(new ReadyHandler(commands));
handlers.addAll(new GuildLifecycleHandler(guildServices).handlers());
handlers.add(new VoiceEventPublisher(events));
ShardService shards = context.register(new ShardService(
settings, music, new EventsHandler(handlers, commands, tasks)));
// Register slash commands, attach the event handler and set the presence once JDA is ready.
// Slash commands are registered from EventsHandler on ReadyEvent; set the presence here.
context.register(lifecycle(65, () -> {
commands.registerSlashCommands(jda);
jda.addEventListener(new EventsHandler(settings, guildServices, commands, tasks));
jda.getPresence().setActivity(Activity.watching("you"));
shards.setActivity(Activity.watching("you"));
}, () -> { }));
// Web API (priority 70) — Lifecycle with graceful stop.
context.register(new WebApi(settings, guildServices, music, this::getJDA, this::shutdown));
context.register(new WebApi(settings, guildServices, music, shards, this::shutdown));
}
private static Lifecycle lifecycle(int priority, ThrowingRunnable start, Runnable stop) {
@@ -192,52 +209,10 @@ public class App {
return instance;
}
public void initJDA() {
String token = settings.get("NETNEUTRALITY_BOT_TOKEN");
if (token == null) {
SessionManager sessions = context.get(SessionManager.class);
if (sessions != null) {
sessions.stop();
}
throw new RuntimeException("No bot token provided, exiting.");
}
try {
jda = JDABuilder
.createDefault(token)
.enableIntents(
GatewayIntent.GUILD_MEMBERS,
GatewayIntent.GUILD_MESSAGE_REACTIONS,
GatewayIntent.GUILD_VOICE_STATES,
GatewayIntent.MESSAGE_CONTENT)
// Voice state/server updates are handed to Lavalink instead of JDA's audio stack;
// DAVE encryption is handled by the node.
.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(music.getClient()))
.setActivity(Activity.watching("me load..."))
.build();
jda.awaitReady();
} catch (InvalidTokenException | IllegalArgumentException | InterruptedException e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
LogHandler.log(e);
throw new IllegalStateException("Unable to connect to Discord", e);
}
}
public void shutdown() {
logger.info("[Net Neutrality] Shutting down...");
context.stop();
logger.info("[Net Neutrality] Shutdown complete");
}
public JDA getJDA() {
return jda;
}
private void disconnect() {
if (jda != null) {
jda.shutdown();
}
}
}
@@ -0,0 +1,76 @@
package me.bobert.netneutrality.command.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.MusicCommand;
import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.music.TrackScheduler;
import me.bobert.netneutrality.utils.MusicUtils;
import me.bobert.netneutrality.utils.UserFunction;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
public class AutoplayCommand extends MusicCommand {
private final MusicService musicService;
public AutoplayCommand() {
this(null, null);
}
public AutoplayCommand(MusicService musicService) {
this(musicService, null);
}
public AutoplayCommand(MusicService musicService, GuildServiceProvider guildServices) {
super(guildServices);
this.musicService = musicService;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public String getCommandName() {
return "autoplay";
}
@Override
public String getDescription() {
return "Toggles autoplay, which keeps the music going with similar tracks when the queue ends";
}
@Override
public void registerCommandOptions(CommandOptionsBuilder builder) {
builder.addOptions(new OptionData(OptionType.BOOLEAN, "enabled", "Turn autoplay on or off; omit to toggle", false));
}
@Override
public CommandExecutionResult execute(CommandContext context) {
MusicManager manager = musicService.get(context.getGuild());
Boolean requested = context.getOption("enabled", CommandOption::getAsBoolean);
if (MusicUtils.checkIfConnected(context, manager)) {
if (UserFunction.isAdmin(context) || MusicUtils.isUserDj(context, manager)) {
TrackScheduler scheduler = manager.getScheduler();
boolean enabled = requested != null ? requested : !scheduler.isAutoplayEnabled();
scheduler.setAutoplayEnabled(enabled);
return CommandExecutionResult.success("Autoplay is now " + (enabled ? "enabled" : "disabled"));
}
}
return CommandExecutionResult.none();
}
}
@@ -12,7 +12,7 @@ import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions;
public interface Command {
enum Type {
SLASH, USER, MESSAGE
CHAT, USER, MESSAGE
}
boolean needAdmin();
@@ -5,7 +5,6 @@ package me.bobert.netneutrality.core.command;
* https://discord.gg/qUa6zNFZsd
*/
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
@@ -13,27 +12,49 @@ import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
import net.dv8tion.jda.internal.interactions.CommandDataImpl;
import java.util.List;
import java.util.Set;
import java.util.StringJoiner;
public class CommandContainer {
public static final CommandContainer NULL_COMMAND = new CommandContainer(null);
private static final CommandUsageFormatter USAGE_FORMATTER = new CommandUsageFormatter();
private final Command command;
private CommandDataImpl commandData;
private List<OptionData> options;
private List<SubcommandData> subcommands;
private List<SubcommandGroupData> subcommandGroups;
private final CommandDataImpl commandData;
private final List<OptionData> options;
private final List<SubcommandData> subcommands;
private final List<SubcommandGroupData> subcommandGroups;
public CommandContainer(Command command) {
this.command = command;
this(command, build(command));
}
if (command != null) {
CommandOptionsBuilder commandOptionsBuilder = new CommandOptionsBuilder(command.getType(), command);
command.registerCommandOptions(commandOptionsBuilder);
initContainer(commandOptionsBuilder);
private CommandContainer(Command command, CommandDataImpl commandData) {
this.command = command;
this.commandData = commandData;
if (commandData != null) {
this.options = commandData.getOptions();
this.subcommands = commandData.getSubcommands();
this.subcommandGroups = commandData.getSubcommandGroups();
} else {
this.options = List.of();
this.subcommands = List.of();
this.subcommandGroups = List.of();
}
}
public static CommandContainer of(Command command) {
return new CommandContainer(command);
}
private static CommandDataImpl build(Command command) {
if (command == null) {
return null;
}
CommandOptionsBuilder builder = new CommandOptionsBuilder(command.getType(), command);
command.registerCommandOptions(builder);
return builder.build();
}
public Command getCommand() {
return command;
}
@@ -54,129 +75,11 @@ public class CommandContainer {
return subcommandGroups;
}
private void initContainer(CommandOptionsBuilder builder) {
this.commandData = builder.build();
this.options = commandData.getOptions();
this.subcommands = commandData.getSubcommands();
this.subcommandGroups = commandData.getSubcommandGroups();
}
public boolean isEmpty() {
return options.isEmpty() && subcommands.isEmpty() && subcommandGroups.isEmpty();
}
public String printUsage() {
StringBuilder usage = new StringBuilder();
if (!options.isEmpty()) {
usage.append("- Usage: /").append(command.getCommandName());
for (OptionData option : options) {
usage.append(" <").append(option.getName()).append(">");
}
usage.append(printOptions(options, null, false));
}
if (!subcommands.isEmpty()) {
for (SubcommandData subcommand : subcommands) {
usage.append("- Usage: /").append(command.getCommandName()).append(" ").append(subcommand.getName());
for (OptionData option : subcommand.getOptions()) {
usage.append(" <").append(option.getName()).append(">");
}
usage.append(" (").append(subcommand.getDescription()).append(")");
usage.append(printOptions(subcommand.getOptions(), null, false));
usage.append("\n");
}
}
if (!subcommandGroups.isEmpty()) {
for (SubcommandGroupData subcommandGroup : subcommandGroups) {
usage.append("- Subcommand: /").append(command.getCommandName())
.append(" ").append(subcommandGroup.getName())
.append(" (").append(subcommandGroup.getDescription()).append(")")
.append("\n");
for (SubcommandData subcommand : subcommandGroup.getSubcommands()) {
usage.append(" - Usage: /").append(command.getCommandName())
.append(" ").append(subcommandGroup.getName())
.append(" ").append(subcommand.getName());
for (OptionData option : subcommand.getOptions()) {
usage.append(" <").append(option.getName()).append(">");
}
usage.append(printOptions(subcommand.getOptions(), subcommand.getDescription(), true));
usage.append("\n");
}
}
}
if (!isEmpty()) {
usage.append("\n\n-# *: required argument");
}
return usage.toString();
}
private String printOptions(List<OptionData> options, String description, boolean group) {
StringBuilder usage = new StringBuilder();
if (description != null) {
usage.append(" (").append(description).append(")");
}
for (OptionData option : options) {
usage.append("\n");
if (group) {
usage.append(" ");
}
usage.append(" - ");
if (option.isRequired()) {
usage.append("*");
}
usage.append(option.getName()).append(": ").append(option.getDescription());
if (option.getMinValue() != null) {
usage.append(" [Min value: ").append(option.getMinValue().longValue()).append("]");
}
if (option.getMaxValue() != null) {
usage.append(" [Max value: ").append(option.getMaxValue().longValue()).append("]");
}
if (option.getMinLength() != null) {
usage.append(" [Min length: ").append(option.getMinLength().longValue()).append("]");
}
if (option.getMaxLength() != null) {
usage.append(" [Max length: ").append(option.getMaxLength().longValue()).append("]");
}
List<net.dv8tion.jda.api.interactions.commands.Command.Choice> choices = option.getChoices();
if (!choices.isEmpty()) {
StringJoiner joiner = new StringJoiner(", ");
for (net.dv8tion.jda.api.interactions.commands.Command.Choice choice : choices) {
joiner.add(choice.getAsString());
}
usage.append(" [Available choices: ").append(joiner).append("]");
}
Set<ChannelType> requiredChannelTypes = option.getChannelTypes();
if (!requiredChannelTypes.isEmpty()) {
StringJoiner joiner = new StringJoiner(", ");
for (ChannelType type : requiredChannelTypes) {
joiner.add(type.name());
}
usage.append(" [Required channel types: ").append(joiner).append("]");
}
}
return usage.toString();
return USAGE_FORMATTER.format(this);
}
}
@@ -57,7 +57,7 @@ public class CommandManager implements CommandRegistry {
public void init() {
logger.info("Loading commands..");
initCommands();
ReflectionHelper.loadCommands(Command.class, context, this::onCommandLoaded);
logger.info("Loaded {} commands!", commands.size());
}
@@ -84,30 +84,26 @@ public class CommandManager implements CommandRegistry {
return commands;
}
public void initCommands() {
Consumer<Command> onLoaded = command -> {
if (!command.isEnabled()) {
logger.info("Skipping command {} as it is disabled", command.getClass().getName());
return;
}
void onCommandLoaded(Command command) {
if (!command.isEnabled()) {
logger.info("Skipping command {} as it is disabled", command.getClass().getName());
return;
}
if (command.getCommandName() == null) {
logger.error("Failed to load command {}: Command name is null", command.getClass().getName());
return;
}
if (command.getCommandName() == null) {
logger.error("Failed to load command {}: Command name is null", command.getClass().getName());
return;
}
if (command.getDescription() == null && command.getType() == Command.Type.SLASH) {
logger.error("Failed to load command {}: Command description is null", command.getClass().getName());
return;
}
if (command.getDescription() == null && command.getType() == Command.Type.CHAT) {
logger.error("Failed to load command {}: Command description is null", command.getClass().getName());
return;
}
CommandContainer container = new CommandContainer(command);
if (!commands.containsKey(command.getCommandName())) {
commands.put(command.getCommandName(), container);
}
};
ReflectionHelper.loadCommands(Command.class, context, onLoaded);
CommandContainer container = new CommandContainer(command);
if (!commands.containsKey(command.getCommandName())) {
commands.put(command.getCommandName(), container);
}
}
@Override
@@ -31,7 +31,7 @@ public class CommandOptionsBuilder implements SlashCommandData {
public CommandOptionsBuilder(Command.Type type, Command command) {
if (type == Command.Type.SLASH) {
if (type == Command.Type.CHAT) {
commandData = (CommandDataImpl) Commands.slash(command.getCommandName(), command.getDescription());
}
@@ -83,7 +83,7 @@ public class CommandProcessor {
logger.info("Executing command {} on guild {}", command.getCommandName(), context.getGuild() == null ? "PrivateMessage" : context.getGuild().getId());
configureSentry(command);
if (command.getType() == Command.Type.SLASH && !usageValidator.validate(context, container)) {
if (command.getType() == Command.Type.CHAT && !usageValidator.validate(context, container)) {
return;
}
@@ -0,0 +1,151 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
import java.util.List;
import java.util.Set;
import java.util.StringJoiner;
final class CommandUsageFormatter {
private static final String REQUIRED_LEGEND = "\n\n-# *: required argument";
String format(CommandContainer container) {
if (container.isEmpty()) {
return "";
}
String commandName = container.getCommand().getCommandName();
StringBuilder usage = new StringBuilder();
appendOptions(usage, commandName, container.getOptions());
appendSubcommands(usage, commandName, container.getSubcommands());
appendGroups(usage, commandName, container.getSubcommandGroups());
usage.append(REQUIRED_LEGEND);
return usage.toString();
}
private void appendOptions(StringBuilder usage, String commandName, List<OptionData> options) {
if (options.isEmpty()) {
return;
}
usage.append("- Usage: /").append(commandName);
appendArgs(usage, options);
usage.append(describeOptions(options, null, false));
}
private void appendSubcommands(StringBuilder usage, String commandName, List<SubcommandData> subcommands) {
for (SubcommandData subcommand : subcommands) {
usage.append("- Usage: /").append(commandName).append(" ").append(subcommand.getName());
appendArgs(usage, subcommand.getOptions());
usage.append(" (").append(subcommand.getDescription()).append(")");
usage.append(describeOptions(subcommand.getOptions(), null, false));
usage.append("\n");
}
}
private void appendGroups(StringBuilder usage, String commandName, List<SubcommandGroupData> groups) {
for (SubcommandGroupData group : groups) {
usage.append("- Subcommand: /").append(commandName)
.append(" ").append(group.getName())
.append(" (").append(group.getDescription()).append(")")
.append("\n");
for (SubcommandData subcommand : group.getSubcommands()) {
usage.append(" - Usage: /").append(commandName)
.append(" ").append(group.getName())
.append(" ").append(subcommand.getName());
appendArgs(usage, subcommand.getOptions());
usage.append(describeOptions(subcommand.getOptions(), subcommand.getDescription(), true));
usage.append("\n");
}
}
}
private void appendArgs(StringBuilder usage, List<OptionData> options) {
for (OptionData option : options) {
usage.append(" <").append(option.getName()).append(">");
}
}
private String describeOptions(List<OptionData> options, String description, boolean group) {
StringBuilder usage = new StringBuilder();
if (description != null) {
usage.append(" (").append(description).append(")");
}
for (OptionData option : options) {
usage.append("\n");
if (group) {
usage.append(" ");
}
usage.append(" - ");
if (option.isRequired()) {
usage.append("*");
}
usage.append(option.getName()).append(": ").append(option.getDescription());
appendConstraints(usage, option);
appendChoices(usage, option);
appendChannelTypes(usage, option);
}
return usage.toString();
}
private void appendConstraints(StringBuilder usage, OptionData option) {
if (option.getMinValue() != null) {
usage.append(" [Min value: ").append(option.getMinValue().longValue()).append("]");
}
if (option.getMaxValue() != null) {
usage.append(" [Max value: ").append(option.getMaxValue().longValue()).append("]");
}
if (option.getMinLength() != null) {
usage.append(" [Min length: ").append(option.getMinLength().longValue()).append("]");
}
if (option.getMaxLength() != null) {
usage.append(" [Max length: ").append(option.getMaxLength().longValue()).append("]");
}
}
private void appendChoices(StringBuilder usage, OptionData option) {
List<net.dv8tion.jda.api.interactions.commands.Command.Choice> choices = option.getChoices();
if (choices.isEmpty()) {
return;
}
StringJoiner joiner = new StringJoiner(", ");
for (net.dv8tion.jda.api.interactions.commands.Command.Choice choice : choices) {
joiner.add(choice.getAsString());
}
usage.append(" [Available choices: ").append(joiner).append("]");
}
private void appendChannelTypes(StringBuilder usage, OptionData option) {
Set<ChannelType> types = option.getChannelTypes();
if (types.isEmpty()) {
return;
}
StringJoiner joiner = new StringJoiner(", ");
for (ChannelType type : types) {
joiner.add(type.name());
}
usage.append(" [Required channel types: ").append(joiner).append("]");
}
}
@@ -34,7 +34,7 @@ public abstract class AbstractCommand implements Command {
@Override
public Type getType() {
return Type.SLASH;
return Type.CHAT;
}
@Override
@@ -13,7 +13,7 @@ public class PermissionPrecondition implements CommandPrecondition {
@Override
public boolean check(CommandContext context, Command command) {
if (command.getType() != Command.Type.SLASH) {
if (command.getType() != Command.Type.CHAT) {
return true;
}
@@ -0,0 +1,15 @@
package me.bobert.netneutrality.events;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* Sink for {@link MusicEvent}s. Implementations must never block the calling thread.
*/
public interface EventPublisher {
void publish(MusicEvent event);
}
@@ -0,0 +1,43 @@
package me.bobert.netneutrality.events;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* A music-state change pushed to the frontend webhook.
*
* @param type the kind of change
* @param guildId the guild the change belongs to
* @param timestamp epoch millis when the event was created
* @param seq per-guild, monotonically increasing sequence number (assigned by the dispatcher)
* @param payload event specific data, serialised as JSON
*/
public record MusicEvent(Type type, String guildId, long timestamp, long seq, Object payload) {
public enum Type {
TRACK_START,
TRACK_END,
QUEUE_UPDATED,
PLAYER_PAUSED,
PLAYER_RESUMED,
VOLUME_CHANGED,
PLAYER_SEEKED,
VOTE_SKIP_UPDATED,
VOICE_CONNECTED,
VOICE_DISCONNECTED,
QUEUE_SHUFFLED,
TRACK_ERROR,
TRACK_STUCK,
PLAYER_POSITION
}
/**
* Creates an unsequenced event stamped with the current time.
*/
public static MusicEvent of(Type type, String guildId, Object payload) {
return new MusicEvent(type, guildId, System.currentTimeMillis(), 0L, payload);
}
}
@@ -0,0 +1,20 @@
package me.bobert.netneutrality.events;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* Publisher that discards every event; used when webhooks are disabled.
*/
public final class NoopEventPublisher implements EventPublisher {
public static final NoopEventPublisher INSTANCE = new NoopEventPublisher();
@Override
public void publish(MusicEvent event) {
// intentionally empty
}
}
@@ -0,0 +1,166 @@
package me.bobert.netneutrality.events;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.fasterxml.jackson.databind.ObjectMapper;
import me.bobert.netneutrality.bootstrap.Lifecycle;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.HexFormat;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
/**
* Delivers {@link MusicEvent}s to a single static webhook URL. Events are queued in a bounded
* buffer (oldest dropped on overflow) and sent in order by one worker thread, signed with
* HMAC-SHA256 in the {@code X-Signature} header. Failed deliveries are retried with exponential
* backoff and then dropped.
*/
public class WebhookDispatcher implements EventPublisher, Lifecycle {
private static final Logger logger = LogManager.getLogger(WebhookDispatcher.class);
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
private static final String HMAC = "HmacSHA256";
private final String url;
private final String secret;
private final OkHttpClient client;
private final ObjectMapper mapper = new ObjectMapper();
private final int maxAttempts;
private final long baseBackoffMillis;
private final LinkedBlockingDeque<MusicEvent> queue;
private final Map<String, AtomicLong> sequences = new ConcurrentHashMap<>();
private volatile boolean running;
private Thread worker;
public WebhookDispatcher(String url, String secret) {
this(url, secret, new OkHttpClient.Builder()
.callTimeout(Duration.ofSeconds(10))
.build(), 1024, 3, 500);
}
public WebhookDispatcher(String url, String secret, OkHttpClient client, int capacity,
int retries, long baseBackoffMillis) {
this.url = url;
this.secret = secret == null ? "" : secret;
this.client = client;
this.queue = new LinkedBlockingDeque<>(capacity);
this.maxAttempts = retries + 1;
this.baseBackoffMillis = baseBackoffMillis;
}
@Override
public int priority() {
return 50;
}
@Override
public void start() {
running = true;
worker = new Thread(this::run, "webhook-dispatcher");
worker.setDaemon(true);
worker.start();
logger.info("Webhook dispatcher enabled, posting to {}", url);
}
@Override
public void stop() {
running = false;
if (worker != null) {
worker.interrupt();
}
}
@Override
public void publish(MusicEvent event) {
if (event == null) {
return;
}
long seq = sequences.computeIfAbsent(event.guildId(), _ -> new AtomicLong()).incrementAndGet();
MusicEvent sequenced = new MusicEvent(event.type(), event.guildId(), event.timestamp(), seq, event.payload());
while (!queue.offerLast(sequenced)) {
MusicEvent dropped = queue.pollFirst();
if (dropped != null) {
logger.warn("Webhook queue full, dropped {} for guild {} (seq {})",
dropped.type(), dropped.guildId(), dropped.seq());
}
}
}
private void run() {
while (running) {
try {
MusicEvent event = queue.pollFirst(1, TimeUnit.SECONDS);
if (event != null) {
deliver(event);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} catch (Exception e) {
logger.error("Unexpected webhook dispatch error", e);
}
}
}
private void deliver(MusicEvent event) throws InterruptedException {
byte[] body;
try {
body = mapper.writeValueAsBytes(event);
} catch (Exception e) {
logger.error("Failed to serialise {} event, dropping", event.type(), e);
return;
}
Request request = new Request.Builder()
.url(url)
.header("X-Signature", "sha256=" + sign(body))
.post(RequestBody.create(body, JSON))
.build();
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try (Response response = client.newCall(request).execute()) {
if (response.isSuccessful()) {
return;
}
logger.warn("Webhook returned {} for {} (attempt {}/{})",
response.code(), event.type(), attempt, maxAttempts);
} catch (Exception e) {
logger.warn("Webhook delivery failed for {} (attempt {}/{}): {}",
event.type(), attempt, maxAttempts, e.getMessage());
}
if (attempt < maxAttempts) {
Thread.sleep(baseBackoffMillis << (attempt - 1));
}
}
logger.error("Dropping {} event for guild {} (seq {}) after {} attempts",
event.type(), event.guildId(), event.seq(), maxAttempts);
}
String sign(byte[] body) {
try {
Mac mac = Mac.getInstance(HMAC);
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC));
return HexFormat.of().formatHex(mac.doFinal(body));
} catch (Exception e) {
throw new IllegalStateException("Unable to sign webhook payload", e);
}
}
}
@@ -0,0 +1,22 @@
package me.bobert.netneutrality.hooks;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.context.Context;
import net.dv8tion.jda.api.events.Event;
/**
* A handler that turns an event into a {@link Context}, which the dispatcher routes to the
* command or task pipeline. May return {@code null} to ignore the event.
*/
public interface ContextEventHandler<T extends Event> extends EventListenerHandler<T> {
Context toContext(T event);
@Override
default void handle(T event) {
}
}
@@ -0,0 +1,18 @@
package me.bobert.netneutrality.hooks;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import net.dv8tion.jda.api.events.Event;
/**
* A side-effect handler for one JDA event type. Several handlers may listen to the same event.
*/
public interface EventListenerHandler<T extends Event> {
Class<T> eventType();
void handle(T event);
}
@@ -6,159 +6,90 @@ package me.bobert.netneutrality.hooks;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.context.command.MessageCommandContext;
import me.bobert.netneutrality.context.command.MessageMenuContext;
import me.bobert.netneutrality.context.command.SlashCommandContext;
import me.bobert.netneutrality.context.command.UserMenuContext;
import me.bobert.netneutrality.context.tasks.ButtonTaskContext;
import me.bobert.netneutrality.context.tasks.EmojiTaskContext;
import me.bobert.netneutrality.context.tasks.MessageTaskContext;
import me.bobert.netneutrality.context.tasks.ModalTaskContext;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.CommandProcessor;
import me.bobert.netneutrality.core.command.CommandRegistry;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.Context;
import me.bobert.netneutrality.core.context.TaskContext;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.core.command.CommandRegistry;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.events.Event;
import net.dv8tion.jda.api.events.GenericEvent;
import net.dv8tion.jda.api.events.guild.GuildJoinEvent;
import net.dv8tion.jda.api.events.guild.GuildLeaveEvent;
import net.dv8tion.jda.api.events.guild.update.GuildUpdateIconEvent;
import net.dv8tion.jda.api.events.guild.update.GuildUpdateNameEvent;
import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.events.interaction.command.UserContextInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent;
import net.dv8tion.jda.api.hooks.EventListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* Thin dispatcher: calls every registered {@link EventListenerHandler} for an event (walking its
* class hierarchy and interfaces), isolates failures per handler and routes any produced
* {@link Context} to the command or task pipeline.
*/
public class EventsHandler implements EventListener {
private static final Logger logger = LoggerFactory.getLogger(EventsHandler.class);
private final Map<Class<? extends Event>, Function<? extends Event, Context>> listeners = new ConcurrentHashMap<>();
private final SettingsProvider settings;
private final GuildServiceProvider guildServices;
private final Map<Class<?>, List<EventListenerHandler<?>>> handlers = new ConcurrentHashMap<>();
private final CommandRegistry commands;
private final TaskManager taskManager;
public EventsHandler(
SettingsProvider settings,
GuildServiceProvider guildServices,
List<EventListenerHandler<?>> handlers,
CommandRegistry commands,
TaskManager taskManager
) {
this.settings = Objects.requireNonNull(settings, "settings");
this.guildServices = Objects.requireNonNull(guildServices, "guildServices");
this.commands = Objects.requireNonNull(commands, "commands");
this.taskManager = Objects.requireNonNull(taskManager, "taskManager");
registerListeners();
Objects.requireNonNull(handlers, "handlers").forEach(this::register);
}
private void registerListeners() {
addListener(MessageReceivedEvent.class, event -> {
if (event.getAuthor().isBot()) {
return null;
}
if (event.isWebhookMessage()) {
return null;
}
Message message = event.getMessage();
String rawMessage = message.getContentDisplay().trim();
String prefix = settings.get("NETNEUTRALITY_BOT_PREFIX", "$");
if (event.isFromGuild()) {
String guildPrefix = guildServices.get(event.getGuild().getId()).getGuildSettingService().getOrDefault(GSettings.COMMAND_PREFIX);
if (!guildPrefix.isEmpty()) {
prefix = guildPrefix;
}
}
if (rawMessage.startsWith(prefix)) {
return new MessageCommandContext(event, settings);
}
return new MessageTaskContext(event);
});
addListener(UserContextInteractionEvent.class, event -> new UserMenuContext(event, settings));
addListener(MessageContextInteractionEvent.class, event -> new MessageMenuContext(event, settings));
addListener(SlashCommandInteractionEvent.class, event -> new SlashCommandContext(event, settings));
addListener(ModalInteractionEvent.class, ModalTaskContext::new);
addListener(ButtonInteractionEvent.class, ButtonTaskContext::new);
addListener(MessageReactionAddEvent.class, EmojiTaskContext::new);
addGenericListener(GuildJoinEvent.class, event -> {
GuildService service = guildServices.get(event.getGuild().getId());
service.updateMetadata(event.getGuild().getName(), event.getGuild().getIconId());
if (service.isDebugEnabled()) {
LogHandler.sendMessageToDebugChannel(event.getGuild(), "Error logging enabled on this channel");
}
});
addGenericListener(GuildLeaveEvent.class, event -> onGuildLeave(event.getGuild().getId()));
addGenericListener(GuildUpdateNameEvent.class, event ->
guildServices.get(event.getGuild().getId()).updateMetadata(event.getNewName(), event.getGuild().getIconId()));
addGenericListener(GuildUpdateIconEvent.class, event ->
guildServices.get(event.getGuild().getId()).updateMetadata(event.getGuild().getName(), event.getNewIconId()));
public void register(EventListenerHandler<?> handler) {
Class<?> type = Objects.requireNonNull(handler, "handler").eventType();
logger.info("Registering handler {} for {}", handler.getClass().getSimpleName(), type.getSimpleName());
handlers.computeIfAbsent(type, k -> new CopyOnWriteArrayList<>()).add(handler);
}
/**
* Deletes the guild's data and drops its cached service. The service is evicted even when the
* delete fails, so a stale cache entry never outlives the guild membership.
*
* @param guildId the id of the guild the bot left
* Register a listener for an event associated with a {@link Context}.
*/
void onGuildLeave(String guildId) {
try {
guildServices.get(guildId).deleteGuild();
} catch (RuntimeException e) {
logger.error("Failed to delete data of guild {}", guildId, e);
} finally {
guildServices.evict(guildId);
}
}
/**
* Register a listener for an event assocated with a {@link Context}.
*
* @param eventClass the class type of the event to listen for
* @param function the function to handle the event, which takes an event of the specified type and returns a {@link Context}
* @param <T> the type of the event that extends the {@link Event} class
*/
@SuppressWarnings("unchecked")
public <T extends Event> void addListener(Class<T> eventClass, Function<? super T, Context> function) {
logger.info("Registering listener for {}", eventClass.getSimpleName());
listeners.put(eventClass, (Function<? extends Event, Context>) function);
register(new ContextEventHandler<T>() {
@Override
public Class<T> eventType() {
return eventClass;
}
@Override
public Context toContext(T event) {
return function.apply(event);
}
});
}
/**
* Adds a generic listener for a specified event type. The consumer will be invoked
* whenever an event of the specified type is detected.
*
* @param eventClass the class type of the event to listen for
* @param consumer the consumer to handle the event, which takes an event of the specified type and performs an action
* @param <T> the type of the event that extends the {@link Event} class
* Adds a side-effect listener for a specified event type.
*/
public <T extends Event> void addGenericListener(Class<T> eventClass, Consumer<T> consumer) {
addListener(eventClass, t -> {
consumer.accept(t);
return null;
register(new EventListenerHandler<T>() {
@Override
public Class<T> eventType() {
return eventClass;
}
@Override
public void handle(T event) {
consumer.accept(event);
}
});
}
@@ -169,34 +100,48 @@ public class EventsHandler implements EventListener {
@SuppressWarnings("unchecked")
private void handleEvent(Event event) {
Class<?> eventClass = event.getClass();
while (eventClass != null) {
if (listeners.containsKey(eventClass)) {
Function<Event, Context> function = (Function<Event, Context>) listeners.get(eventClass);
Context context = function.apply(event);
if (context instanceof CommandContext commandContext) {
handleCommand(commandContext);
}
if (context instanceof TaskContext taskContext) {
handleTask(taskContext);
}
break;
for (Class<?> type : hierarchy(event.getClass())) {
List<EventListenerHandler<?>> matching = handlers.get(type);
if (matching == null) {
continue;
}
for (EventListenerHandler<?> raw : matching) {
try {
EventListenerHandler<Event> handler = (EventListenerHandler<Event>) raw;
handler.handle(event);
if (handler instanceof ContextEventHandler<Event> contextHandler) {
route(contextHandler.toContext(event));
}
} catch (Exception e) {
logger.error("Handler {} failed for {}", raw.getClass().getName(), event.getClass().getSimpleName(), e);
}
}
eventClass = eventClass.getSuperclass();
}
}
private void handleCommand(CommandContext context) {
CommandProcessor processor = new CommandProcessor(context, commands, taskManager);
processor.dispatch();
private void route(Context context) {
if (context instanceof CommandContext commandContext) {
new CommandProcessor(commandContext, commands, taskManager).dispatch();
}
if (context instanceof TaskContext taskContext) {
taskManager.handle(taskContext);
}
}
private void handleTask(TaskContext context) {
taskManager.handle(context);
private static Set<Class<?>> hierarchy(Class<?> start) {
Set<Class<?>> result = new LinkedHashSet<>();
Deque<Class<?>> queue = new ArrayDeque<>();
queue.add(start);
while (!queue.isEmpty()) {
Class<?> type = queue.poll();
if (!result.add(type)) {
continue;
}
if (type.getSuperclass() != null) {
queue.add(type.getSuperclass());
}
queue.addAll(List.of(type.getInterfaces()));
}
return result;
}
}
@@ -0,0 +1,113 @@
package me.bobert.netneutrality.hooks;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import net.dv8tion.jda.api.events.guild.GuildJoinEvent;
import net.dv8tion.jda.api.events.guild.GuildLeaveEvent;
import net.dv8tion.jda.api.events.guild.update.GuildUpdateIconEvent;
import net.dv8tion.jda.api.events.guild.update.GuildUpdateNameEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.Objects;
/**
* Guild join, leave and name/icon update handling.
*/
public class GuildLifecycleHandler {
private static final Logger logger = LoggerFactory.getLogger(GuildLifecycleHandler.class);
private final GuildServiceProvider guildServices;
public GuildLifecycleHandler(GuildServiceProvider guildServices) {
this.guildServices = Objects.requireNonNull(guildServices, "guildServices");
}
public List<EventListenerHandler<?>> handlers() {
return List.of(
new Join(), new Leave(), new NameUpdate(), new IconUpdate());
}
void onGuildJoin(GuildJoinEvent event) {
GuildService service = guildServices.get(event.getGuild().getId());
service.updateMetadata(event.getGuild().getName(), event.getGuild().getIconId());
if (service.isDebugEnabled()) {
LogHandler.sendMessageToDebugChannel(event.getGuild(), "Error logging enabled on this channel");
}
}
/**
* Deletes the guild's data and drops its cached service. The service is evicted even when the
* delete fails, so a stale cache entry never outlives the guild membership.
*
* @param guildId the id of the guild the bot left
*/
void onGuildLeave(String guildId) {
try {
guildServices.get(guildId).deleteGuild();
} catch (RuntimeException e) {
logger.error("Failed to delete data of guild {}", guildId, e);
} finally {
guildServices.evict(guildId);
}
}
private class Join implements EventListenerHandler<GuildJoinEvent> {
@Override
public Class<GuildJoinEvent> eventType() {
return GuildJoinEvent.class;
}
@Override
public void handle(GuildJoinEvent event) {
onGuildJoin(event);
}
}
private class Leave implements EventListenerHandler<GuildLeaveEvent> {
@Override
public Class<GuildLeaveEvent> eventType() {
return GuildLeaveEvent.class;
}
@Override
public void handle(GuildLeaveEvent event) {
onGuildLeave(event.getGuild().getId());
}
}
private class NameUpdate implements EventListenerHandler<GuildUpdateNameEvent> {
@Override
public Class<GuildUpdateNameEvent> eventType() {
return GuildUpdateNameEvent.class;
}
@Override
public void handle(GuildUpdateNameEvent event) {
guildServices.get(event.getGuild().getId())
.updateMetadata(event.getNewName(), event.getGuild().getIconId());
}
}
private class IconUpdate implements EventListenerHandler<GuildUpdateIconEvent> {
@Override
public Class<GuildUpdateIconEvent> eventType() {
return GuildUpdateIconEvent.class;
}
@Override
public void handle(GuildUpdateIconEvent event) {
guildServices.get(event.getGuild().getId())
.updateMetadata(event.getGuild().getName(), event.getNewIconId());
}
}
}
@@ -0,0 +1,60 @@
package me.bobert.netneutrality.hooks;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.context.command.MessageMenuContext;
import me.bobert.netneutrality.context.command.SlashCommandContext;
import me.bobert.netneutrality.context.command.UserMenuContext;
import me.bobert.netneutrality.context.tasks.ButtonTaskContext;
import me.bobert.netneutrality.context.tasks.EmojiTaskContext;
import me.bobert.netneutrality.context.tasks.ModalTaskContext;
import me.bobert.netneutrality.core.context.Context;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.events.Event;
import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.events.interaction.command.UserContextInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
/**
* Slash, menu, modal, button and reaction events, each mapped to its context.
*/
public final class InteractionHandlers {
private InteractionHandlers() {
}
public static List<EventListenerHandler<?>> create(SettingsProvider settings) {
Objects.requireNonNull(settings, "settings");
return List.of(
of(UserContextInteractionEvent.class, e -> new UserMenuContext(e, settings)),
of(MessageContextInteractionEvent.class, e -> new MessageMenuContext(e, settings)),
of(SlashCommandInteractionEvent.class, e -> new SlashCommandContext(e, settings)),
of(ModalInteractionEvent.class, ModalTaskContext::new),
of(ButtonInteractionEvent.class, ButtonTaskContext::new),
of(MessageReactionAddEvent.class, EmojiTaskContext::new));
}
private static <T extends Event> ContextEventHandler<T> of(Class<T> type, Function<T, Context> mapper) {
return new ContextEventHandler<>() {
@Override
public Class<T> eventType() {
return type;
}
@Override
public Context toContext(T event) {
return mapper.apply(event);
}
};
}
}
@@ -0,0 +1,57 @@
package me.bobert.netneutrality.hooks;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.context.command.MessageCommandContext;
import me.bobert.netneutrality.context.tasks.MessageTaskContext;
import me.bobert.netneutrality.core.context.Context;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import java.util.Objects;
/**
* Resolves the prefix and decides whether a message is a command or a plain task message.
*/
public class MessageReceivedHandler implements ContextEventHandler<MessageReceivedEvent> {
private final SettingsProvider settings;
private final GuildServiceProvider guildServices;
public MessageReceivedHandler(SettingsProvider settings, GuildServiceProvider guildServices) {
this.settings = Objects.requireNonNull(settings, "settings");
this.guildServices = Objects.requireNonNull(guildServices, "guildServices");
}
@Override
public Class<MessageReceivedEvent> eventType() {
return MessageReceivedEvent.class;
}
@Override
public Context toContext(MessageReceivedEvent event) {
if (event.getAuthor().isBot() || event.isWebhookMessage()) {
return null;
}
String rawMessage = event.getMessage().getContentDisplay().trim();
String prefix = settings.get("NETNEUTRALITY_BOT_PREFIX", "$");
if (event.isFromGuild()) {
String guildPrefix = guildServices.get(event.getGuild().getId()).getGuildSettingService()
.getOrDefault(GSettings.COMMAND_PREFIX);
if (!guildPrefix.isEmpty()) {
prefix = guildPrefix;
}
}
if (rawMessage.startsWith(prefix)) {
return new MessageCommandContext(event, settings);
}
return new MessageTaskContext(event);
}
}
@@ -0,0 +1,36 @@
package me.bobert.netneutrality.hooks;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.CommandRegistry;
import net.dv8tion.jda.api.events.session.ReadyEvent;
import java.util.Objects;
/**
* Registers the global slash commands once the first shard is ready.
*/
public class ReadyHandler implements EventListenerHandler<ReadyEvent> {
private final CommandRegistry commands;
public ReadyHandler(CommandRegistry commands) {
this.commands = Objects.requireNonNull(commands, "commands");
}
@Override
public Class<ReadyEvent> eventType() {
return ReadyEvent.class;
}
@Override
public void handle(ReadyEvent event) {
// Global commands are shared by all shards, so only register them once.
if (event.getJDA().getShardInfo().getShardId() == 0) {
commands.registerSlashCommands(event.getJDA());
}
}
}
@@ -0,0 +1,74 @@
package me.bobert.netneutrality.hooks;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.events.EventPublisher;
import me.bobert.netneutrality.events.MusicEvent;
import net.dv8tion.jda.api.entities.GuildVoiceState;
import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion;
import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent;
import java.util.Map;
import java.util.Objects;
/**
* Publishes connect/disconnect events for the bot itself and for members entering or leaving
* the voice channel the bot is currently in. The payload carries {@code userId}, {@code bot}
* (whether the member is a bot) and {@code self} (whether it is this bot).
*/
public class VoiceEventPublisher implements EventListenerHandler<GuildVoiceUpdateEvent> {
private final EventPublisher events;
public VoiceEventPublisher(EventPublisher events) {
this.events = Objects.requireNonNull(events, "events");
}
@Override
public Class<GuildVoiceUpdateEvent> eventType() {
return GuildVoiceUpdateEvent.class;
}
@Override
public void handle(GuildVoiceUpdateEvent event) {
onVoiceUpdate(event);
}
void onVoiceUpdate(GuildVoiceUpdateEvent event) {
boolean self = event.getEntity().getId().equals(event.getJDA().getSelfUser().getId());
AudioChannelUnion left = event.getChannelLeft();
AudioChannelUnion joined = event.getChannelJoined();
String botChannelId = null;
if (!self) {
GuildVoiceState botState = event.getGuild().getSelfMember().getVoiceState();
if (botState == null || botState.getChannel() == null) {
return;
}
botChannelId = botState.getChannel().getId();
}
String guildId = event.getGuild().getId();
if (left != null && (self || left.getId().equals(botChannelId))) {
events.publish(MusicEvent.of(MusicEvent.Type.VOICE_DISCONNECTED, guildId,
voicePayload(event, left.getId(), self)));
}
if (joined != null && (self || joined.getId().equals(botChannelId))) {
events.publish(MusicEvent.of(MusicEvent.Type.VOICE_CONNECTED, guildId,
voicePayload(event, joined.getId(), self)));
}
}
private static Map<String, Object> voicePayload(GuildVoiceUpdateEvent event, String channelId, boolean self) {
return Map.of(
"channelId", channelId,
"userId", event.getEntity().getId(),
"username", event.getEntity().getUser().getName(),
"nickname", event.getEntity().getEffectiveName(),
"bot", event.getEntity().getUser().isBot(),
"self", self);
}
}
@@ -14,12 +14,11 @@ import io.javalin.openapi.OpenApi;
import io.javalin.openapi.OpenApiContent;
import io.javalin.openapi.OpenApiResponse;
import me.bobert.netneutrality.http.server.dto.StatusResponse;
import net.dv8tion.jda.api.JDA;
import me.bobert.netneutrality.shard.ShardProvider;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Map;
import java.util.function.Supplier;
/**
* Registers the {@code /actions/*} endpoints on a {@link RoutesConfig} instance.
@@ -34,10 +33,10 @@ public final class ActionRoutes {
public static void register(
RoutesConfig routes,
Supplier<JDA> jdaSupplier,
ShardProvider shards,
Runnable shutdownHook
) {
routes.get("/actions/status", ctx -> status(jdaSupplier, ctx));
routes.get("/actions/status", ctx -> status(shards, ctx));
Handler shutdown = ctx -> shutdown(ctx, shutdownHook);
routes.post("/actions/shutdown", shutdown);
@@ -50,11 +49,12 @@ public final class ActionRoutes {
tags = {"Actions"},
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = StatusResponse.class))
)
private static void status(Supplier<JDA> jdaSupplier, Context ctx) {
JDA jda = GuildRoutes.jda(jdaSupplier);
private static void status(ShardProvider shards, Context ctx) {
ShardProvider provider = GuildRoutes.shards(shards);
ctx.json(Map.of(
"health", String.valueOf(jda.getStatus()),
"ping", String.valueOf(jda.getGatewayPing())
"health", String.valueOf(provider.getWorstStatus()),
"ping", String.valueOf((long) provider.getAveragePing()),
"shard_count", provider.getShardCount()
));
}
@@ -35,7 +35,7 @@ import me.bobert.netneutrality.http.server.dto.RoleSummaryResponse;
import me.bobert.netneutrality.http.server.dto.VoiceChannelSummaryResponse;
import me.bobert.netneutrality.utils.MusicAccess;
import me.bobert.netneutrality.utils.UserFunction;
import net.dv8tion.jda.api.JDA;
import me.bobert.netneutrality.shard.ShardProvider;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role;
@@ -47,7 +47,6 @@ import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Registers the read-only {@code /guilds/*} endpoints, as well as the debug/logging/log-channel
@@ -58,22 +57,22 @@ public final class GuildRoutes {
private GuildRoutes() {
}
public static void register(RoutesConfig routes, Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices) {
routes.get("/guilds", ctx -> guilds(jdaSupplier, guildServices, ctx));
routes.get("/guilds/owner", ctx -> owner(jdaSupplier, ctx));
routes.get("/guilds/{guildId}/owner", ctx -> ownerOf(jdaSupplier, ctx));
routes.get("/guilds/{guildId}/channels", ctx -> channels(jdaSupplier, ctx));
routes.get("/guilds/{guildId}/roles", ctx -> roles(jdaSupplier, ctx));
routes.get("/guilds/{guildId}/members/{userId}/access", ctx -> memberAccess(jdaSupplier, guildServices, ctx));
routes.get("/guilds/{guildId}/members/{userId}", ctx -> member(jdaSupplier, ctx));
routes.get("/guilds/{guildId}/members/{userId}/roles", ctx -> memberRoles(jdaSupplier, ctx));
routes.get("/guilds/{guildId}/members/{userId}/admin", ctx -> memberAdmin(jdaSupplier, ctx));
routes.get("/guilds/{guildId}/voice-channels", ctx -> voiceChannels(jdaSupplier, ctx));
routes.get("/guilds/{guildId}/voice-channels/{channelId}", ctx -> voiceChannel(jdaSupplier, ctx));
routes.get("/guilds/{guildId}/config", ctx -> config(jdaSupplier, guildServices, ctx));
routes.put("/guilds/{guildId}/debug", ctx -> updateDebug(jdaSupplier, guildServices, ctx));
routes.put("/guilds/{guildId}/logging", ctx -> updateLogging(jdaSupplier, guildServices, ctx));
routes.put("/guilds/{guildId}/log-channel", ctx -> updateLogChannel(jdaSupplier, guildServices, ctx));
public static void register(RoutesConfig routes, ShardProvider shards, GuildServiceProvider guildServices) {
routes.get("/guilds", ctx -> guilds(shards, guildServices, ctx));
routes.get("/guilds/owner", ctx -> owner(shards, ctx));
routes.get("/guilds/{guildId}/owner", ctx -> ownerOf(shards, ctx));
routes.get("/guilds/{guildId}/channels", ctx -> channels(shards, ctx));
routes.get("/guilds/{guildId}/roles", ctx -> roles(shards, ctx));
routes.get("/guilds/{guildId}/members/{userId}/access", ctx -> memberAccess(shards, guildServices, ctx));
routes.get("/guilds/{guildId}/members/{userId}", ctx -> member(shards, ctx));
routes.get("/guilds/{guildId}/members/{userId}/roles", ctx -> memberRoles(shards, ctx));
routes.get("/guilds/{guildId}/members/{userId}/admin", ctx -> memberAdmin(shards, ctx));
routes.get("/guilds/{guildId}/voice-channels", ctx -> voiceChannels(shards, ctx));
routes.get("/guilds/{guildId}/voice-channels/{channelId}", ctx -> voiceChannel(shards, ctx));
routes.get("/guilds/{guildId}/config", ctx -> config(shards, guildServices, ctx));
routes.put("/guilds/{guildId}/debug", ctx -> updateDebug(shards, guildServices, ctx));
routes.put("/guilds/{guildId}/logging", ctx -> updateLogging(shards, guildServices, ctx));
routes.put("/guilds/{guildId}/log-channel", ctx -> updateLogChannel(shards, guildServices, ctx));
}
@OpenApi(
@@ -87,15 +86,15 @@ public final class GuildRoutes {
@OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void guilds(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
private static void guilds(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
String userId = ctx.queryParam("user_id");
if (userId != null) {
userGuilds(jda(jdaSupplier), guildServices, userId, ctx);
userGuilds(shards(shards), guildServices, userId, ctx);
return;
}
ctx.json(
jda(jdaSupplier).getGuilds().stream()
shards(shards).getGuilds().stream()
.map(GuildRoutes::guildSummary)
.toList()
);
@@ -105,12 +104,12 @@ public final class GuildRoutes {
* Lists the guilds shared by the bot and the given user, with the user's access flags. Members
* missing from the cache are resolved through REST; a failed lookup means "not a member".
*/
private static void userGuilds(JDA jda, GuildServiceProvider guildServices, String userId, Context ctx) {
private static void userGuilds(ShardProvider shards, GuildServiceProvider guildServices, String userId, Context ctx) {
if (!userId.matches("\\d{1,20}")) {
throw new BadRequestResponse("user_id must be a numeric Discord id");
}
List<CompletableFuture<Optional<Map<String, Object>>>> lookups = jda.getGuilds().stream()
List<CompletableFuture<Optional<Map<String, Object>>>> lookups = shards.getGuilds().stream()
.map(guild -> resolveMember(guild, userId)
.thenApply(member -> Optional.ofNullable(member)
.map(m -> userGuildEntry(guild, m, guildServices.get(guild.getId()))))
@@ -176,9 +175,9 @@ public final class GuildRoutes {
tags = {"Guilds"},
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = GuildOwnerEntryResponse[].class))
)
private static void owner(Supplier<JDA> jdaSupplier, Context ctx) {
private static void owner(ShardProvider shards, Context ctx) {
ctx.json(
jda(jdaSupplier).getGuilds().stream()
shards(shards).getGuilds().stream()
.map(guild -> ordered("guild_id", guild.getId(), "owner_id", guild.getOwnerId()))
.toList()
);
@@ -195,8 +194,8 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void ownerOf(Supplier<JDA> jdaSupplier, Context ctx) {
Guild guild = guild(jdaSupplier, ctx);
private static void ownerOf(ShardProvider shards, Context ctx) {
Guild guild = guild(shards, ctx);
CacheOrFetch.resolve(
ctx,
@@ -217,9 +216,9 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void channels(Supplier<JDA> jdaSupplier, Context ctx) {
private static void channels(ShardProvider shards, Context ctx) {
ctx.json(
guild(jdaSupplier, ctx).getTextChannels().stream()
guild(shards, ctx).getTextChannels().stream()
.map(channel -> ordered("channel_id", channel.getId(), "channel_name", channel.getName()))
.toList()
);
@@ -236,9 +235,9 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void roles(Supplier<JDA> jdaSupplier, Context ctx) {
private static void roles(ShardProvider shards, Context ctx) {
ctx.json(
guild(jdaSupplier, ctx).getRoles().stream()
guild(shards, ctx).getRoles().stream()
.filter(role -> !role.isManaged() && !role.isPublicRole())
.map(role -> ordered("role_id", role.getId(), "role_name", role.getName()))
.toList()
@@ -259,8 +258,8 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void member(Supplier<JDA> jdaSupplier, Context ctx) {
Guild guild = guild(jdaSupplier, ctx);
private static void member(ShardProvider shards, Context ctx) {
Guild guild = guild(shards, ctx);
String userId = ctx.pathParam("userId");
CacheOrFetch.resolve(
@@ -285,8 +284,8 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void memberRoles(Supplier<JDA> jdaSupplier, Context ctx) {
Guild guild = guild(jdaSupplier, ctx);
private static void memberRoles(ShardProvider shards, Context ctx) {
Guild guild = guild(shards, ctx);
String userId = ctx.pathParam("userId");
CacheOrFetch.resolve(
@@ -311,8 +310,8 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void memberAdmin(Supplier<JDA> jdaSupplier, Context ctx) {
Guild guild = guild(jdaSupplier, ctx);
private static void memberAdmin(ShardProvider shards, Context ctx) {
Guild guild = guild(shards, ctx);
String userId = ctx.pathParam("userId");
CacheOrFetch.resolve(
@@ -343,8 +342,8 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void memberAccess(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
Guild guild = guild(jdaSupplier, ctx);
private static void memberAccess(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
Guild guild = guild(shards, ctx);
String userId = ctx.pathParam("userId");
Function<Member, Map<String, Object>> toBody = member -> {
@@ -413,9 +412,9 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void voiceChannels(Supplier<JDA> jdaSupplier, Context ctx) {
private static void voiceChannels(ShardProvider shards, Context ctx) {
ctx.json(
guild(jdaSupplier, ctx).getVoiceChannels().stream()
guild(shards, ctx).getVoiceChannels().stream()
.map(GuildRoutes::voiceChannelSummary)
.toList()
);
@@ -435,8 +434,8 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void voiceChannel(Supplier<JDA> jdaSupplier, Context ctx) {
ctx.json(voiceChannelSummary(voiceChannel(guild(jdaSupplier, ctx), ctx)));
private static void voiceChannel(ShardProvider shards, Context ctx) {
ctx.json(voiceChannelSummary(voiceChannel(guild(shards, ctx), ctx)));
}
/**
@@ -472,7 +471,7 @@ public final class GuildRoutes {
Map<String, String> summary = new LinkedHashMap<>();
summary.put("user_id", member.getId());
summary.put("username", member.getUser().getName());
summary.put("nickname", member.getNickname());
summary.put("nickname", member.getEffectiveName());
return summary;
}
@@ -497,8 +496,8 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void config(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
Guild guild = guild(jdaSupplier, ctx);
private static void config(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
Guild guild = guild(shards, ctx);
ctx.json(guildConfig(guild, guildServices));
}
@@ -515,8 +514,8 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void updateDebug(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
Guild guild = guild(jdaSupplier, ctx);
private static void updateDebug(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
Guild guild = guild(shards, ctx);
boolean value = booleanValue(ctx);
GuildService guildService = guildServices.get(guild.getId());
@@ -538,8 +537,8 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void updateLogging(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
Guild guild = guild(jdaSupplier, ctx);
private static void updateLogging(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
Guild guild = guild(shards, ctx);
boolean value = booleanValue(ctx);
GuildService guildService = guildServices.get(guild.getId());
@@ -561,8 +560,8 @@ public final class GuildRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void updateLogChannel(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
Guild guild = guild(jdaSupplier, ctx);
private static void updateLogChannel(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
Guild guild = guild(shards, ctx);
String channelId = ctx.bodyAsClass(Map.class).get("value") instanceof String s ? s : null;
if (channelId != null && !channelId.isBlank() && guild.getTextChannelById(channelId) == null) {
@@ -600,21 +599,20 @@ public final class GuildRoutes {
}
/**
* Resolves the JDA client or fails with 503 while it is not ready yet.
* Resolves the shard provider or fails with 503 while the shards are not ready yet.
*/
static JDA jda(Supplier<JDA> jdaSupplier) {
JDA jda = jdaSupplier.get();
if (jda == null) {
static ShardProvider shards(ShardProvider shards) {
if (shards == null || !shards.isReady()) {
throw new ServiceUnavailableResponse("Discord client is not ready");
}
return jda;
return shards;
}
/**
* Resolves the {@code guildId} path parameter or fails with 404.
*/
static Guild guild(Supplier<JDA> jdaSupplier, Context ctx) {
Guild guild = jda(jdaSupplier).getGuildById(ctx.pathParam("guildId"));
static Guild guild(ShardProvider shards, Context ctx) {
Guild guild = shards(shards).getGuildById(ctx.pathParam("guildId"));
if (guild == null) {
throw new NotFoundResponse("Unknown guild");
}
@@ -17,6 +17,8 @@ import io.javalin.openapi.OpenApiContent;
import io.javalin.openapi.OpenApiParam;
import io.javalin.openapi.OpenApiRequestBody;
import io.javalin.openapi.OpenApiResponse;
import me.bobert.netneutrality.http.server.dto.AutoplayRequest;
import me.bobert.netneutrality.http.server.dto.AutoplayResponse;
import me.bobert.netneutrality.http.server.dto.CommandResultResponse;
import me.bobert.netneutrality.http.server.dto.DeleteQueueResponse;
import me.bobert.netneutrality.http.server.dto.DjStatusResponse;
@@ -41,11 +43,12 @@ import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.music.PendingSearchResults;
import me.bobert.netneutrality.music.PlayOutcome;
import me.bobert.netneutrality.music.PlaylistInfo;
import me.bobert.netneutrality.music.QueueActionResult;
import me.bobert.netneutrality.music.SourceFinder;
import me.bobert.netneutrality.music.TrackInfo;
import me.bobert.netneutrality.utils.MusicUtils;
import net.dv8tion.jda.api.JDA;
import me.bobert.netneutrality.shard.ShardProvider;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
@@ -55,7 +58,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
/**
* Registers the {@code /music/*} endpoints on a {@link RoutesConfig} instance.
@@ -69,26 +71,28 @@ public final class MusicRoutes {
private MusicRoutes() {
}
public static void register(RoutesConfig routes, Supplier<JDA> jdaSupplier, MusicService musicService) {
public static void register(RoutesConfig routes, ShardProvider shards, MusicService musicService) {
routes.get("/music/sources", MusicRoutes::sources);
routes.get("/music/{guildId}/status", ctx -> status(jdaSupplier, musicService, ctx));
routes.get("/music/{guildId}/queue", ctx -> queue(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/skip", ctx -> skip(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/stop", ctx -> stop(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/pause", ctx -> pause(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/resume", ctx -> resumePlayback(jdaSupplier, musicService, ctx));
routes.get("/music/{guildId}/volume", ctx -> getVolume(jdaSupplier, musicService, ctx));
routes.put("/music/{guildId}/volume", ctx -> volume(jdaSupplier, musicService, ctx));
routes.put("/music/{guildId}/seek", ctx -> seek(jdaSupplier, musicService, ctx));
routes.get("/music/{guildId}/vote-skip", ctx -> voteSkipStatus(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/vote-skip/{userId}", ctx -> voteSkip(jdaSupplier, musicService, ctx));
routes.get("/music/{guildId}/dj/{userId}", ctx -> dj(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/queue", ctx -> play(jdaSupplier, musicService, ctx));
routes.delete("/music/{guildId}/queue", ctx -> deleteQueue(jdaSupplier, musicService, ctx));
routes.patch("/music/{guildId}/queue/reorder", ctx -> reorder(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/search", ctx -> search(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/leave", ctx -> leave(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/shuffle", ctx -> shuffle(jdaSupplier, musicService, ctx));
routes.get("/music/{guildId}/status", ctx -> status(shards, musicService, ctx));
routes.get("/music/{guildId}/queue", ctx -> queue(shards, musicService, ctx));
routes.post("/music/{guildId}/skip", ctx -> skip(shards, musicService, ctx));
routes.post("/music/{guildId}/stop", ctx -> stop(shards, musicService, ctx));
routes.post("/music/{guildId}/pause", ctx -> pause(shards, musicService, ctx));
routes.post("/music/{guildId}/resume", ctx -> resumePlayback(shards, musicService, ctx));
routes.get("/music/{guildId}/volume", ctx -> getVolume(shards, musicService, ctx));
routes.put("/music/{guildId}/volume", ctx -> volume(shards, musicService, ctx));
routes.get("/music/{guildId}/autoplay", ctx -> getAutoplay(shards, musicService, ctx));
routes.put("/music/{guildId}/autoplay", ctx -> autoplay(shards, musicService, ctx));
routes.put("/music/{guildId}/seek", ctx -> seek(shards, musicService, ctx));
routes.get("/music/{guildId}/vote-skip", ctx -> voteSkipStatus(shards, musicService, ctx));
routes.post("/music/{guildId}/vote-skip/{userId}", ctx -> voteSkip(shards, musicService, ctx));
routes.get("/music/{guildId}/dj/{userId}", ctx -> dj(shards, musicService, ctx));
routes.post("/music/{guildId}/queue", ctx -> play(shards, musicService, ctx));
routes.delete("/music/{guildId}/queue", ctx -> deleteQueue(shards, musicService, ctx));
routes.patch("/music/{guildId}/queue/reorder", ctx -> reorder(shards, musicService, ctx));
routes.post("/music/{guildId}/search", ctx -> search(shards, musicService, ctx));
routes.post("/music/{guildId}/leave", ctx -> leave(shards, musicService, ctx));
routes.post("/music/{guildId}/shuffle", ctx -> shuffle(shards, musicService, ctx));
}
@OpenApi(
@@ -118,8 +122,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void status(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
private static void status(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(shards, musicService, ctx);
AudioChannel channel = manager.getConnectedChannel();
Map<String, Object> body = new LinkedHashMap<>();
@@ -146,8 +150,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void queue(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
private static void queue(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(shards, musicService, ctx);
ctx.json(
manager.getQueueManager().getQueue().stream()
@@ -167,8 +171,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void skip(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
private static void skip(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(shards, musicService, ctx);
QueueActionResult result = manager.getQueueManager().skip();
announce(manager, result.getMessage());
ctx.json(resultBody(result));
@@ -185,8 +189,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void stop(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
private static void stop(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(shards, musicService, ctx);
manager.stopTrack();
announce(manager, "Stopping current track");
ctx.json(Map.of("status", "ok", "message", "Stopping current track"));
@@ -203,8 +207,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void pause(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
private static void pause(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(shards, musicService, ctx);
manager.pauseTrack();
announce(manager, "Pausing current track");
ctx.json(Map.of("status", "ok", "message", "Pausing current track"));
@@ -221,8 +225,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void resumePlayback(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
private static void resumePlayback(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(shards, musicService, ctx);
manager.unpauseTrack();
ctx.json(Map.of("status", "ok", "message", "Resuming current track"));
}
@@ -238,8 +242,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void getVolume(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
private static void getVolume(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(shards, musicService, ctx);
ctx.json(Map.of("status", "ok", "volume", manager.getVolume()));
}
@@ -256,8 +260,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void volume(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
private static void volume(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(shards, musicService, ctx);
int newVolume = volume(ctx);
manager.setVolume(newVolume);
@@ -284,6 +288,46 @@ public final class MusicRoutes {
return volume;
}
@OpenApi(
path = "/music/{guildId}/autoplay",
methods = HttpMethod.GET,
summary = "Get whether autoplay is enabled for a guild",
tags = {"Music"},
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = AutoplayResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void getAutoplay(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(shards, musicService, ctx);
ctx.json(new AutoplayResponse(manager.getScheduler().isAutoplayEnabled()));
}
@OpenApi(
path = "/music/{guildId}/autoplay",
methods = HttpMethod.PUT,
summary = "Enable or disable autoplay for a guild",
tags = {"Music"},
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = AutoplayRequest.class)),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = AutoplayResponse.class)),
@OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void autoplay(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(shards, musicService, ctx);
Object enabled = ctx.bodyAsClass(Map.class).get("enabled");
if (!(enabled instanceof Boolean value)) {
throw new BadRequestResponse("enabled must be a boolean");
}
manager.getScheduler().setAutoplayEnabled(value);
ctx.json(new AutoplayResponse(value));
}
@OpenApi(
path = "/music/{guildId}/seek",
methods = HttpMethod.PUT,
@@ -297,8 +341,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void seek(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
private static void seek(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(shards, musicService, ctx);
long positionMs = seekPosition(ctx, manager);
manager.seek(positionMs);
@@ -341,8 +385,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void voteSkipStatus(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
private static void voteSkipStatus(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(shards, musicService, ctx);
AudioChannel channel = manager.getConnectedChannel();
TrackInfo head = manager.getQueueManager().peekHeadOfQueue();
int threshold = manager.getVoteSkipThreshold();
@@ -369,8 +413,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void voteSkip(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
private static void voteSkip(ShardProvider shards, MusicService musicService, Context ctx) {
Guild guild = GuildRoutes.guild(shards, ctx);
MusicManager manager = musicService.get(guild);
String userId = ctx.pathParam("userId");
@@ -400,8 +444,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void dj(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
private static void dj(ShardProvider shards, MusicService musicService, Context ctx) {
Guild guild = GuildRoutes.guild(shards, ctx);
MusicManager manager = musicService.get(guild);
String userId = ctx.pathParam("userId");
@@ -426,8 +470,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void play(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
private static void play(ShardProvider shards, MusicService musicService, Context ctx) {
Guild guild = GuildRoutes.guild(shards, ctx);
MusicManager manager = musicService.get(guild);
QueueRequest body = ctx.bodyAsClass(QueueRequest.class);
String userId = requireNonBlank(body.userId(), "userId");
@@ -449,8 +493,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void search(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
private static void search(ShardProvider shards, MusicService musicService, Context ctx) {
Guild guild = GuildRoutes.guild(shards, ctx);
MusicManager manager = musicService.get(guild);
SearchRequest body = ctx.bodyAsClass(SearchRequest.class);
String query = requireNonBlank(body.query(), "query");
@@ -480,7 +524,7 @@ public final class MusicRoutes {
/**
* Queues a previously returned search candidate for a guild member, invoked from {@link
* #play(Supplier, MusicService, Context)} when the request body carries a {@code searchId}.
* #play(ShardProvider, MusicService, Context)} when the request body carries a {@code searchId}.
* Exposed via the {@code /music/{guildId}/queue} POST endpoint.
*/
private static void playSelect(Guild guild, MusicManager manager, QueueRequest body, String userId, Context ctx) {
@@ -491,10 +535,12 @@ public final class MusicRoutes {
throw new BadRequestResponse("index is required when searchId is provided unless queueAll is true");
}
List<Track> candidates = PendingSearchResults.consume(searchId, guild.getId());
if (candidates == null) {
PendingSearchResults.Consumed consumed = PendingSearchResults.consumeWithPlaylist(searchId, guild.getId());
if (consumed == null) {
throw new NotFoundResponse("Unknown or expired searchId");
}
List<Track> candidates = consumed.candidates();
PlaylistInfo playlist = consumed.playlist();
if (!queueAll && (index < 0 || index >= candidates.size())) {
throw new BadRequestResponse("index is out of range");
@@ -507,7 +553,7 @@ public final class MusicRoutes {
if (queueAll) {
ctx.future(() -> resolveMember(guild, userId)
.thenApply(member -> {
candidates.forEach(candidate -> manager.getQueueManager().queue(candidate, member));
manager.getQueueManager().queue(candidates, member, playlist);
announce(manager, String.format("Queued %d track%s requested by %s",
candidates.size(), candidates.size() > 1 ? "s" : "", member.getEffectiveName()));
ctx.json(queuedAllBody(candidates, manager.getQueueSize()));
@@ -541,8 +587,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void deleteQueue(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
private static void deleteQueue(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(shards, musicService, ctx);
String position = ctx.queryParam("position");
QueueActionResult result;
@@ -578,8 +624,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void leave(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
private static void leave(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(shards, musicService, ctx);
manager.disconnect();
announce(manager, "Leaving voice channel");
ctx.json(new LeaveResponse("ok", "Leaving voice channel"));
@@ -596,8 +642,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void shuffle(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
private static void shuffle(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(shards, musicService, ctx);
QueueActionResult result = manager.getQueueManager().shuffle();
announce(manager, result.getMessage());
ctx.json(resultBodyWithQueue(result, manager));
@@ -616,8 +662,8 @@ public final class MusicRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void reorder(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
private static void reorder(ShardProvider shards, MusicService musicService, Context ctx) {
MusicManager manager = manager(shards, musicService, ctx);
ReorderRequest body = ctx.bodyAsClass(ReorderRequest.class);
QueueActionResult result = manager.getQueueManager().reorder(body.fromPosition(), body.toPosition());
@@ -717,8 +763,8 @@ public final class MusicRoutes {
/**
* Resolves the {@link MusicManager} for the guild path parameter, creating it on first use.
*/
private static MusicManager manager(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
return musicService.get(GuildRoutes.guild(jdaSupplier, ctx));
private static MusicManager manager(ShardProvider shards, MusicService musicService, Context ctx) {
return musicService.get(GuildRoutes.guild(shards, ctx));
}
/**
@@ -781,6 +827,16 @@ public final class MusicRoutes {
Map<String, Object> summary = trackSummary(info.getTrack());
summary.put("requester_id", info.getRequester().getId());
summary.put("skip_count", info.getSkipCount());
PlaylistInfo playlist = info.getPlaylist();
if (playlist == null) {
summary.put("playlist", null);
} else {
Map<String, Object> playlistMap = new LinkedHashMap<>();
playlistMap.put("name", playlist.name());
playlistMap.put("identifier", playlist.identifier());
playlistMap.put("track_count", playlist.trackCount());
summary.put("playlist", playlistMap);
}
return summary;
}
}
@@ -25,13 +25,12 @@ import me.bobert.netneutrality.http.server.dto.SettingsCurrentResponse;
import me.bobert.netneutrality.http.server.dto.SettingsReloadResponse;
import me.bobert.netneutrality.http.server.dto.SettingsUpdateRequest;
import me.bobert.netneutrality.http.server.dto.SettingsUpdateResponse;
import net.dv8tion.jda.api.JDA;
import me.bobert.netneutrality.shard.ShardProvider;
import net.dv8tion.jda.api.entities.Guild;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
/**
* Registers the {@code /settings/*} endpoints on a {@link RoutesConfig} instance.
@@ -41,14 +40,14 @@ public final class SettingsRoutes {
private SettingsRoutes() {
}
public static void register(RoutesConfig routes, Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices) {
public static void register(RoutesConfig routes, ShardProvider shards, GuildServiceProvider guildServices) {
routes.get("/settings/catalog", SettingsRoutes::settingsCatalog);
routes.get("/settings/current/{guildId}", ctx -> settingsCurrent(jdaSupplier, guildServices, ctx));
routes.get("/settings/current/{guildId}", ctx -> settingsCurrent(shards, guildServices, ctx));
routes.put("/settings/current/{guildId}/{name}", ctx -> settingsUpdate(jdaSupplier, guildServices, ctx));
routes.put("/settings/current/{guildId}/{name}", ctx -> settingsUpdate(shards, guildServices, ctx));
routes.put("/settings/reload/{guildId}", ctx -> settingsReload(jdaSupplier, guildServices, ctx));
routes.put("/settings/reload/{guildId}", ctx -> settingsReload(shards, guildServices, ctx));
}
@OpenApi(
@@ -87,8 +86,8 @@ public final class SettingsRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void settingsCurrent(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
private static void settingsCurrent(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
Guild guild = GuildRoutes.guild(shards, ctx);
Map<String, String> settings = guildServices.get(guild.getId()).getGuildSettingService().snapshot();
ctx.json(Map.of("status", "ok", "settings", settings));
@@ -110,8 +109,8 @@ public final class SettingsRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void settingsUpdate(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
private static void settingsUpdate(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
Guild guild = GuildRoutes.guild(shards, ctx);
GSettings setting = setting(ctx.pathParam("name"));
String value = ctx.bodyAsClass(Map.class).get("value") instanceof String s ? s : null;
@@ -195,8 +194,8 @@ public final class SettingsRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void settingsReload(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
private static void settingsReload(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
Guild guild = GuildRoutes.guild(shards, ctx);
guildServices.get(guild.getId()).getGuildSettingService().reloadSettingsCache();
ctx.json(Map.of("status", true));
@@ -18,11 +18,10 @@ import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.http.server.dto.BotStatsResponse;
import me.bobert.netneutrality.http.server.dto.ErrorResponse;
import me.bobert.netneutrality.http.server.dto.GuildStatsResponse;
import net.dv8tion.jda.api.JDA;
import me.bobert.netneutrality.shard.ShardProvider;
import net.dv8tion.jda.api.entities.Guild;
import java.util.Map;
import java.util.function.Supplier;
/**
* Registers the {@code /stats/*} endpoints on a {@link RoutesConfig} instance.
@@ -32,9 +31,9 @@ public final class StatsRoutes {
private StatsRoutes() {
}
public static void register(RoutesConfig routes, Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices) {
routes.get("/stats/bot", ctx -> bot(jdaSupplier, ctx));
routes.get("/stats/guilds/{guildId}", ctx -> guild(jdaSupplier, guildServices, ctx));
public static void register(RoutesConfig routes, ShardProvider shards, GuildServiceProvider guildServices) {
routes.get("/stats/bot", ctx -> bot(shards, ctx));
routes.get("/stats/guilds/{guildId}", ctx -> guild(shards, guildServices, ctx));
}
@OpenApi(
@@ -44,13 +43,14 @@ public final class StatsRoutes {
tags = {"Stats"},
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = BotStatsResponse.class))
)
private static void bot(Supplier<JDA> jdaSupplier, Context ctx) {
JDA jda = GuildRoutes.jda(jdaSupplier);
private static void bot(ShardProvider shards, Context ctx) {
ShardProvider provider = GuildRoutes.shards(shards);
ctx.json(Map.of(
"guild_count", jda.getGuilds().size(),
"gateway_ping", jda.getGatewayPing(),
"status", String.valueOf(jda.getStatus()),
"guild_count", provider.getGuilds().size(),
"gateway_ping", (long) provider.getAveragePing(),
"status", String.valueOf(provider.getWorstStatus()),
"shard_count", provider.getShardCount(),
"uptime_seconds", uptimeSeconds()
));
}
@@ -66,8 +66,8 @@ public final class StatsRoutes {
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void guild(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
private static void guild(ShardProvider shards, GuildServiceProvider guildServices, Context ctx) {
Guild guild = GuildRoutes.guild(shards, ctx);
GuildService guildService = guildServices.get(guild.getId());
ctx.json(Map.of(
@@ -14,12 +14,11 @@ import me.bobert.netneutrality.bootstrap.Lifecycle;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.JDA;
import me.bobert.netneutrality.shard.ShardProvider;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Map;
import java.util.function.Supplier;
/**
* Embedded HTTP API exposed as a {@link Lifecycle} component (priority 70, after JDA).
@@ -32,7 +31,7 @@ public class WebApi implements Lifecycle {
private final SettingsProvider settings;
private final GuildServiceProvider guildServices;
private final MusicService musicService;
private final Supplier<JDA> jdaSupplier;
private final ShardProvider shards;
private final Runnable shutdownHook;
private Javalin app;
@@ -40,13 +39,13 @@ public class WebApi implements Lifecycle {
SettingsProvider settings,
GuildServiceProvider guildServices,
MusicService musicService,
Supplier<JDA> jdaSupplier,
ShardProvider shards,
Runnable shutdownHook
) {
this.settings = settings;
this.guildServices = guildServices;
this.musicService = musicService;
this.jdaSupplier = jdaSupplier;
this.shards = shards;
this.shutdownHook = shutdownHook;
}
@@ -77,11 +76,11 @@ public class WebApi implements Lifecycle {
config.routes.before(new ApiKeyFilter(settings));
config.routes.get("/", ctx -> ctx.html(LIVENESS_HTML));
GuildRoutes.register(config.routes, jdaSupplier, guildServices);
ActionRoutes.register(config.routes, jdaSupplier, shutdownHook);
SettingsRoutes.register(config.routes, jdaSupplier, guildServices);
StatsRoutes.register(config.routes, jdaSupplier, guildServices);
MusicRoutes.register(config.routes, jdaSupplier, musicService);
GuildRoutes.register(config.routes, shards, guildServices);
ActionRoutes.register(config.routes, shards, shutdownHook);
SettingsRoutes.register(config.routes, shards, guildServices);
StatsRoutes.register(config.routes, shards, guildServices);
MusicRoutes.register(config.routes, shards, musicService);
config.routes.exception(HttpResponseException.class, (e, ctx) ->
ctx.status(e.getStatus()).json(Map.of("status", "error", "message", e.getMessage()))
@@ -0,0 +1,15 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /music/{guildId}/autoplay} request body.
*/
public record AutoplayRequest(
Boolean enabled
) {
}
@@ -0,0 +1,15 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /music/{guildId}/autoplay} response.
*/
public record AutoplayResponse(
boolean enabled
) {
}
@@ -0,0 +1,84 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.client.AbstractAudioLoadResultHandler;
import dev.arbjerg.lavalink.client.player.LoadFailed;
import dev.arbjerg.lavalink.client.player.PlaylistLoaded;
import dev.arbjerg.lavalink.client.player.SearchResult;
import dev.arbjerg.lavalink.client.player.Track;
import dev.arbjerg.lavalink.client.player.TrackLoaded;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.List;
import java.util.Objects;
/**
* Queues the tracks of an autoplay mix in the background. Unlike {@link TrackLoadingHandler}
* it has no user to reply to: failures and empty results are only logged.
*/
public class AutoplayLoadHandler extends AbstractAudioLoadResultHandler {
private static final Logger logger = LogManager.getLogger(AutoplayLoadHandler.class);
private final TrackScheduler scheduler;
private final Track seed;
public AutoplayLoadHandler(TrackScheduler scheduler, Track seed) {
this.scheduler = scheduler;
this.seed = seed;
}
private String guildId() {
return scheduler.getGuild().getId();
}
@Override
public void onPlaylistLoaded(PlaylistLoaded result) {
try {
String seedId = seed.getInfo().getIdentifier();
List<Track> tracks = result.getTracks().stream()
.filter(track -> !Objects.equals(track.getInfo().getIdentifier(), seedId))
.toList();
if (tracks.isEmpty()) {
logger.debug("Guild {}: autoplay mix is empty", guildId());
return;
}
TrackInfo seedInfo = scheduler.getQueueManager().peekHeadOfQueue();
PlaylistInfo playlist = new PlaylistInfo(null, "Autoplay: " + seed.getInfo().getTitle(), tracks.size());
scheduler.getQueueManager().queueAutoplay(
tracks, seedInfo == null ? null : seedInfo.getRequester(), playlist);
} finally {
scheduler.endAutoplayLoad();
}
}
@Override
public void ontrackLoaded(TrackLoaded result) {
logger.debug("Guild {}: autoplay resolved a single track, ignoring", guildId());
scheduler.endAutoplayLoad();
}
@Override
public void onSearchResultLoaded(SearchResult result) {
logger.debug("Guild {}: autoplay resolved search results, ignoring", guildId());
scheduler.endAutoplayLoad();
}
@Override
public void noMatches() {
logger.debug("Guild {}: autoplay found no matches", guildId());
scheduler.endAutoplayLoad();
}
@Override
public void loadFailed(LoadFailed result) {
logger.debug("Guild {}: autoplay load failed: {}", guildId(), result.getException().getMessage());
scheduler.endAutoplayLoad();
}
}
@@ -0,0 +1,30 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.client.player.Track;
import java.util.Optional;
/**
* Source-specific strategy that turns the last track of the queue into a Lavalink identifier
* whose tracks are queued next (e.g. a radio mix). Register new sources in
* {@link AutoplayService}.
*/
public interface AutoplayProvider {
/**
* @param seed the track autoplay is seeded from
* @return whether this provider can build recommendations for the track's source
*/
boolean supports(Track seed);
/**
* @param seed the track autoplay is seeded from
* @return the identifier to load through Lavalink, or empty when none can be built
*/
Optional<String> radioIdentifier(Track seed);
}
@@ -0,0 +1,73 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.client.Link;
import dev.arbjerg.lavalink.client.player.Track;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.List;
import java.util.Optional;
/**
* Picks the {@link AutoplayProvider} matching a seed track and loads its recommendations
* in the background. At most one load per scheduler is in flight at a time.
*/
public class AutoplayService {
private static final Logger logger = LogManager.getLogger(AutoplayService.class);
private final List<AutoplayProvider> providers;
public AutoplayService() {
this(List.of(new YoutubeAutoplayProvider()));
}
public AutoplayService(List<AutoplayProvider> providers) {
this.providers = List.copyOf(providers);
}
/**
* Starts loading recommendations for the seed track; does nothing when a load is already
* running, no provider supports the track or the node is unavailable.
*/
public void loadFor(TrackScheduler scheduler, Track seed) {
Optional<String> identifier = providers.stream()
.filter(provider -> provider.supports(seed))
.findFirst()
.flatMap(provider -> provider.radioIdentifier(seed));
String guildId = scheduler.getGuild().getId();
if (identifier.isEmpty()) {
logger.debug("Guild {}: no autoplay provider for track {}", guildId, seed.getInfo().getIdentifier());
return;
}
if (!scheduler.tryBeginAutoplayLoad()) {
return;
}
try {
Link link = scheduler.getMusicManager().getLink();
if (link == null) {
logger.debug("Guild {}: autoplay skipped, node unavailable", guildId);
scheduler.endAutoplayLoad();
return;
}
link.loadItem(identifier.get()).subscribe(
new AutoplayLoadHandler(scheduler, seed),
error -> {
logger.debug("Guild {}: autoplay load failed: {}", guildId, error.getMessage());
scheduler.endAutoplayLoad();
}
);
} catch (RuntimeException e) {
logger.debug("Guild {}: autoplay load could not start: {}", guildId, e.getMessage());
scheduler.endAutoplayLoad();
}
}
}
@@ -24,6 +24,10 @@ import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import me.bobert.netneutrality.events.EventPublisher;
import me.bobert.netneutrality.events.MusicEvent;
import me.bobert.netneutrality.events.NoopEventPublisher;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
@@ -49,8 +53,15 @@ public class MusicManager implements MusicManagerCallback {
private final GuildService guildService;
private final TaskManager taskManager;
private final VoteSkipService voteSkipService;
private final EventPublisher events;
MusicManager(Guild guild, LavalinkClient client, GuildService guildService, TaskManager taskManager) {
this(guild, client, guildService, taskManager, NoopEventPublisher.INSTANCE);
}
MusicManager(Guild guild, LavalinkClient client, GuildService guildService, TaskManager taskManager,
EventPublisher events) {
this.events = events;
this.guild = guild;
this.client = client;
this.scheduler = new TrackScheduler(this);
@@ -88,6 +99,10 @@ public class MusicManager implements MusicManagerCallback {
return voteSkipService;
}
public EventPublisher getEvents() {
return events;
}
public Guild getGuild() {
return guild;
}
@@ -324,6 +339,7 @@ public class MusicManager implements MusicManagerCallback {
*/
public synchronized void pauseTrack() {
updatePlayer("pause track", builder -> builder.setPaused(true));
events.publish(MusicEvent.of(MusicEvent.Type.PLAYER_PAUSED, guild.getId(), null));
}
/**
@@ -331,6 +347,7 @@ public class MusicManager implements MusicManagerCallback {
*/
public synchronized void unpauseTrack() {
updatePlayer("unpause track", builder -> builder.setPaused(false));
events.publish(MusicEvent.of(MusicEvent.Type.PLAYER_RESUMED, guild.getId(), null));
}
/**
@@ -353,7 +370,15 @@ public class MusicManager implements MusicManagerCallback {
*/
public synchronized QueueActionResult voteSkip(Member member) {
int threshold = parseVoteSkipThreshold(guildService);
return voteSkipService.voteSkip(member, guild.getId(), getQueueManager(), getConnectedChannel(), isCurrentlyPlaying(), threshold);
TrackInfo before = getQueueManager().peekHeadOfQueue();
QueueActionResult result = voteSkipService.voteSkip(member, guild.getId(), getQueueManager(), getConnectedChannel(), isCurrentlyPlaying(), threshold);
if (before != null && before == getQueueManager().peekHeadOfQueue()) {
events.publish(MusicEvent.of(MusicEvent.Type.VOTE_SKIP_UPDATED, guild.getId(), java.util.Map.of(
"userId", member.getId(),
"votes", before.getSkipCount(),
"threshold", threshold)));
}
return result;
}
/**
@@ -373,6 +398,7 @@ public class MusicManager implements MusicManagerCallback {
public void setVolume(int volume) {
updatePlayer("set volume", builder -> builder.setVolume(volume));
events.publish(MusicEvent.of(MusicEvent.Type.VOLUME_CHANGED, guild.getId(), java.util.Map.of("volume", volume)));
}
/**
@@ -394,6 +420,7 @@ public class MusicManager implements MusicManagerCallback {
*/
public void seek(long positionMs) {
updatePlayer("seek to " + positionMs, builder -> builder.setPosition(positionMs));
events.publish(MusicEvent.of(MusicEvent.Type.PLAYER_SEEKED, guild.getId(), java.util.Map.of("position", positionMs)));
}
public int getQueueSize() {
@@ -10,6 +10,7 @@ import dev.arbjerg.lavalink.client.Helpers;
import dev.arbjerg.lavalink.client.LavalinkClient;
import dev.arbjerg.lavalink.client.LavalinkNode;
import dev.arbjerg.lavalink.client.NodeOptions;
import dev.arbjerg.lavalink.client.event.PlayerUpdateEvent;
import dev.arbjerg.lavalink.client.event.ReadyEvent;
import dev.arbjerg.lavalink.client.event.TrackEndEvent;
import dev.arbjerg.lavalink.client.event.TrackExceptionEvent;
@@ -34,6 +35,8 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import me.bobert.netneutrality.events.EventPublisher;
import me.bobert.netneutrality.events.NoopEventPublisher;
/**
* Instance-based {@link MusicService} owning the shared {@link LavalinkClient} and the
@@ -54,6 +57,7 @@ public class MusicServiceImpl implements MusicService {
private final GuildServiceProvider guildServices;
private final TaskManager taskManager;
private final ExecutorProvider executors;
private final EventPublisher events;
private final Map<String, MusicManager> managers = new ConcurrentHashMap<>();
private final List<Disposable> subscriptions = new ArrayList<>();
private final LavalinkClient client;
@@ -61,6 +65,12 @@ public class MusicServiceImpl implements MusicService {
public MusicServiceImpl(SettingsProvider settings, GuildServiceProvider guildServices, TaskManager taskManager,
ExecutorProvider executors) {
this(settings, guildServices, taskManager, executors, NoopEventPublisher.INSTANCE);
}
public MusicServiceImpl(SettingsProvider settings, GuildServiceProvider guildServices, TaskManager taskManager,
ExecutorProvider executors, EventPublisher events) {
this.events = events;
this.settings = settings;
this.guildServices = guildServices;
this.taskManager = taskManager;
@@ -70,7 +80,9 @@ public class MusicServiceImpl implements MusicService {
@Override
public int priority() {
return 40;
// Must be above ShardService (60): shutdown runs in reverse order, and disconnecting players
// needs JDA's direct audio controller, so music has to stop before the shards do.
return 62;
}
@Override
@@ -105,7 +117,7 @@ public class MusicServiceImpl implements MusicService {
public MusicManager get(Guild guild) {
return managers.computeIfAbsent(
guild.getId(),
id -> new MusicManager(guild, client, guildServices.get(id), taskManager)
id -> new MusicManager(guild, client, guildServices.get(id), taskManager, events)
);
}
@@ -200,6 +212,11 @@ public class MusicServiceImpl implements MusicService {
subscriptions.add(client.on(TrackStuckEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackStuck(event.getTrack(), event.getThresholdMs()))));
subscriptions.add(client.on(PlayerUpdateEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onPlayerUpdate(
event.getState().getTime(), event.getState().getPosition(),
event.getState().getConnected(), event.getState().getPing()))));
subscriptions.add(client.on(WebSocketClosedEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onWebSocketClosed(event.getCode(), event.getReason(), event.getByRemote()))));
}
@@ -46,10 +46,17 @@ public final class PendingSearchResults {
* @return the generated search id
*/
public static String put(String guildId, List<Track> candidates) {
return put(guildId, candidates, null);
}
/**
* Stores the given candidates together with the playlist they came from (may be null).
*/
public static String put(String guildId, List<Track> candidates, PlaylistInfo playlist) {
sweep();
String searchId = UUID.randomUUID().toString();
entries.put(searchId, new Entry(guildId, candidates, Instant.now().plus(TTL)));
entries.put(searchId, new Entry(guildId, candidates, playlist, Instant.now().plus(TTL)));
return searchId;
}
@@ -62,6 +69,14 @@ public final class PendingSearchResults {
* belongs to a different guild
*/
public static List<Track> consume(String searchId, String guildId) {
Consumed consumed = consumeWithPlaylist(searchId, guildId);
return consumed == null ? null : consumed.candidates();
}
/**
* Like {@link #consume(String, String)} but also returns the originating playlist, if any.
*/
public static Consumed consumeWithPlaylist(String searchId, String guildId) {
sweep();
Entry entry = entries.remove(searchId);
@@ -69,7 +84,10 @@ public final class PendingSearchResults {
return null;
}
return entry.candidates();
return new Consumed(entry.candidates(), entry.playlist());
}
public record Consumed(List<Track> candidates, PlaylistInfo playlist) {
}
private static void sweep() {
@@ -77,6 +95,6 @@ public final class PendingSearchResults {
entries.entrySet().removeIf(entry -> now.isAfter(entry.getValue().expiresAt()));
}
private record Entry(String guildId, List<Track> candidates, Instant expiresAt) {
private record Entry(String guildId, List<Track> candidates, PlaylistInfo playlist, Instant expiresAt) {
}
}
@@ -0,0 +1,16 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* Describes the playlist a queued track originated from.
*
* @param identifier the playlist identifier, or null when unknown
* @param name the playlist display name
* @param trackCount number of tracks in the playlist
*/
public record PlaylistInfo(String identifier, String name, int trackCount) {
}
@@ -6,6 +6,7 @@ package me.bobert.netneutrality.music;
*/
import dev.arbjerg.lavalink.client.player.Track;
import me.bobert.netneutrality.events.MusicEvent;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import org.apache.logging.log4j.LogManager;
@@ -14,7 +15,9 @@ import org.apache.logging.log4j.Logger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.function.Predicate;
@@ -61,10 +64,55 @@ public class QueueManager {
public void removeHeadOfQueue() {
synchronized (queue) {
queue.poll();
TrackInfo head = queue.poll();
notifyQueueUpdated("removed", head == null ? List.of() : List.of(item(0, head)));
}
}
private static Map<String, Object> item(int index, TrackInfo info) {
Map<String, Object> item = new HashMap<>();
item.put("index", index);
item.put("identifier", info.getTrack().getInfo().getIdentifier());
item.put("title", info.getTrack().getInfo().getTitle());
item.put("author", info.getTrack().getInfo().getAuthor());
item.put("duration_ms", info.getTrack().getInfo().getLength());
item.put("uri", info.getTrack().getInfo().getUri());
item.put("artwork_url", info.getTrack().getInfo().getArtworkUrl());
item.put("requester_id", info.getRequester() == null ? null : info.getRequester().getId());
item.put("playlist", playlistMap(info.getPlaylist()));
return item;
}
private static Map<String, Object> playlistMap(PlaylistInfo playlist) {
if (playlist == null) {
return null;
}
Map<String, Object> map = new HashMap<>();
map.put("name", playlist.name());
map.put("identifier", playlist.identifier());
map.put("track_count", playlist.trackCount());
return map;
}
private void notifyQueueUpdated(String change, List<Map<String, Object>> items) {
notifyQueueUpdated(change, items, null);
}
private void notifyQueueUpdated(String change, List<Map<String, Object>> items, PlaylistInfo playlist) {
MusicManager musicManager = scheduler.getMusicManager();
if (musicManager == null || musicManager.getEvents() == null) {
return;
}
Map<String, Object> payload = new HashMap<>();
payload.put("size", queue.size());
payload.put("change", change);
payload.put("items", items);
if (playlist != null) {
payload.put("playlist", playlistMap(playlist));
}
musicManager.getEvents().publish(MusicEvent.of(MusicEvent.Type.QUEUE_UPDATED, guild.getId(), payload));
}
public boolean isEmpty() {
synchronized (queue) {
return queue.isEmpty();
@@ -79,6 +127,7 @@ public class QueueManager {
}
queue.clear();
notifyQueueUpdated("cleared", List.of());
scheduler.getCallback().onQueueEmpty();
return QueueActionResult.success(String.format("Removed %d track%s from the queue", queueSize, (queueSize > 1 ? "s" : "")));
}
@@ -96,7 +145,16 @@ public class QueueManager {
}
TrackInfo first = queue.peek();
List<Map<String, Object>> removed = new ArrayList<>();
int position = 0;
for (TrackInfo trackInfo : queue) {
if (member.equals(trackInfo.getRequester())) {
removed.add(item(position, trackInfo));
}
position++;
}
queue.removeIf(trackInfo -> member.equals(trackInfo.getRequester()));
notifyQueueUpdated("removed", removed);
if (first != null && member.equals(first.getRequester())) {
skip(1);
@@ -116,7 +174,83 @@ public class QueueManager {
}
public void queue(List<Track> tracks, Member member) {
tracks.forEach(track -> queue(track, member));
queue(tracks, member, null);
}
public void queue(List<Track> tracks, Member member, PlaylistInfo playlist) {
if (playlist == null) {
tracks.forEach(track -> queue(track, member));
return;
}
synchronized (queue) {
boolean idle = scheduler.getCurrentTrack() == null;
List<TrackInfo> added = new ArrayList<>();
for (Track track : tracks) {
TrackInfo info = new TrackInfo(track, member, playlist);
if (queue.contains(info)) {
continue;
}
queue.add(info);
added.add(info);
}
if (added.isEmpty()) {
return;
}
PlaylistInfo actual = new PlaylistInfo(playlist.identifier(), playlist.name(), added.size());
List<TrackInfo> all = new ArrayList<>(queue);
List<Map<String, Object>> items = new ArrayList<>();
for (TrackInfo info : added) {
items.add(item(all.indexOf(info), info));
}
notifyQueueUpdated("playlist_added", items, actual);
if (idle) {
TrackInfo head = queue.peek();
if (head != null) {
startTrack(head.getTrack(), true);
}
}
}
}
/**
* Appends autoplay tracks behind the playing one. The result of a background load is only
* applied when autoplay is still enabled and the playing track is still the last one;
* otherwise it is discarded. Never starts playback.
*/
public void queueAutoplay(List<Track> tracks, Member member, PlaylistInfo playlist) {
synchronized (queue) {
if (!scheduler.isAutoplayEnabled() || queue.size() > 1 || scheduler.getCurrentTrack() == null) {
logger.debug("Guild {}: discarding autoplay result", guild.getId());
return;
}
List<TrackInfo> added = new ArrayList<>();
for (Track track : tracks) {
TrackInfo info = new TrackInfo(track, member, playlist);
if (queue.contains(info)) {
continue;
}
queue.add(info);
added.add(info);
}
if (added.isEmpty()) {
return;
}
PlaylistInfo actual = new PlaylistInfo(playlist.identifier(), playlist.name(), added.size());
List<TrackInfo> all = new ArrayList<>(queue);
List<Map<String, Object>> items = new ArrayList<>();
for (TrackInfo info : added) {
items.add(item(all.indexOf(info), info));
}
notifyQueueUpdated("playlist_added", items, actual);
}
}
public void queue(Track track, Member member) {
@@ -134,6 +268,7 @@ public class QueueManager {
} else {
queue.add(info);
}
notifyQueueUpdated("added", List.of(item(queue.peekFirst() == info ? 0 : queue.size() - 1, info)));
if (idle) {
TrackInfo head = queue.peek();
@@ -181,7 +316,8 @@ public class QueueManager {
public void startNextTrack() {
synchronized (queue) {
queue.poll();
TrackInfo polled = queue.poll();
notifyQueueUpdated("removed", polled == null ? List.of() : List.of(item(0, polled)));
TrackInfo next = queue.peek();
if (next != null) {
@@ -243,6 +379,12 @@ public class QueueManager {
queue.clear();
queue.add(current);
queue.addAll(newQueue);
notifyQueueUpdated("shuffled", List.of());
MusicManager musicManager = scheduler.getMusicManager();
if (musicManager != null && musicManager.getEvents() != null) {
musicManager.getEvents().publish(MusicEvent.of(MusicEvent.Type.QUEUE_SHUFFLED, guild.getId(),
Map.of("size", queue.size())));
}
return QueueActionResult.success("The queue is now shuffled.");
}
@@ -262,10 +404,11 @@ public class QueueManager {
if (index > 1) {
List<TrackInfo> newQueue = new ArrayList<>(queue);
newQueue.remove(index - 1);
TrackInfo removedInfo = newQueue.remove(index - 1);
queue.clear();
queue.addAll(newQueue);
notifyQueueUpdated("removed", List.of(item(index - 1, removedInfo)));
return QueueActionResult.success(String.format("Removed track #%d from the queue", index));
}
@@ -309,6 +452,10 @@ public class QueueManager {
queue.clear();
queue.addAll(newQueue);
Map<String, Object> movedItem = item(toIndex - 1, moved);
movedItem.put("fromIndex", fromIndex - 1);
movedItem.put("toIndex", toIndex - 1);
notifyQueueUpdated("moved", List.of(movedItem));
return QueueActionResult.success(String.format("Moved track #%d to position #%d", fromIndex, toIndex));
}
@@ -33,7 +33,6 @@ import java.util.concurrent.CompletableFuture;
*/
public class SearchResultHandler extends AbstractAudioLoadResultHandler {
private static final Logger logger = LogManager.getLogger(SearchResultHandler.class);
private static final int SEARCH_RESULTS_LIMIT = 5;
private final String identifier;
private final Guild guild;
@@ -65,7 +64,8 @@ public class SearchResultHandler extends AbstractAudioLoadResultHandler {
boolean truncated = tracks.size() > maxPlaylistSize;
List<Track> candidates = truncated ? tracks.subList(0, maxPlaylistSize) : tracks;
String searchId = PendingSearchResults.put(guild.getId(), candidates);
String searchId = PendingSearchResults.put(guild.getId(), candidates,
new PlaylistInfo(identifier, result.getInfo().getName(), candidates.size()));
future.complete(new PlayOutcome.PendingPlaylist(searchId, identifier, result.getInfo().getName(), candidates, truncated));
}
@@ -79,7 +79,7 @@ public class SearchResultHandler extends AbstractAudioLoadResultHandler {
return;
}
List<Track> candidates = tracks.stream().limit(SEARCH_RESULTS_LIMIT).toList();
List<Track> candidates = tracks.stream().toList();
String searchId = PendingSearchResults.put(guild.getId(), candidates);
future.complete(new PlayOutcome.PendingSearch(searchId, candidates));
}
@@ -21,12 +21,23 @@ public class TrackInfo {
private final Set<String> skips;
private static final Logger logger = LogManager.getLogger(TrackInfo.class);
private final PlaylistInfo playlist;
public TrackInfo(Track track, Member member) {
this(track, member, null);
}
public TrackInfo(Track track, Member member, PlaylistInfo playlist) {
this.track = track;
this.member = member;
this.playlist = playlist;
this.skips = new HashSet<>();
}
public PlaylistInfo getPlaylist() {
return playlist;
}
public Track getTrack() {
return track;
}
@@ -67,7 +67,7 @@ public class TrackLoadingHandler extends AbstractAudioLoadResultHandler {
public void onPlaylistLoaded(PlaylistLoaded result) {
logger.debug("Guild {}: playlist loaded: {}", guild.getId(), identifier);
new LoadPlaylistTask(result.getInfo().getName(), result.getTracks(), false, type, manager).insert(context);
new LoadPlaylistTask(identifier, result.getInfo().getName(), result.getTracks(), false, type, manager).insert(context);
}
/**
@@ -84,7 +84,7 @@ public class TrackLoadingHandler extends AbstractAudioLoadResultHandler {
return;
}
new LoadPlaylistTask("Search results", tracks, true, type, manager).insert(context);
new LoadPlaylistTask(identifier, "Search results", tracks, true, type, manager).insert(context);
}
@Override
@@ -11,12 +11,15 @@ import dev.arbjerg.lavalink.protocol.v4.Message.EmittedEvent.TrackEndEvent;
import me.bobert.netneutrality.context.MusicChannelContext;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.context.Context;
import me.bobert.netneutrality.events.MusicEvent;
import me.bobert.netneutrality.exceptions.ContextException;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Sink for the track lifecycle events emitted by the Lavalink node.
*
@@ -32,8 +35,19 @@ public class TrackScheduler {
private volatile Context context;
private volatile Track currentTrack;
private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler;
private static final long POSITION_THROTTLE_MS = 5000;
private volatile long lastPositionSent = Long.MIN_VALUE;
private static final long AUTOPLAY_REMAINING_MS = 30_000;
private final AutoplayService autoplayService;
private volatile boolean autoplayEnabled;
private final AtomicBoolean autoplayLoading = new AtomicBoolean();
public TrackScheduler(MusicManager manager) {
this(manager, new AutoplayService());
}
public TrackScheduler(MusicManager manager, AutoplayService autoplayService) {
this.autoplayService = autoplayService;
this.manager = manager;
this.callback = manager;
this.guild = manager.getGuild();
@@ -73,6 +87,37 @@ public class TrackScheduler {
this.currentTrack = track;
}
public boolean isAutoplayEnabled() {
return autoplayEnabled;
}
/**
* Enables or disables autoplay. Recommendations are only loaded once the last track of the
* queue is close to its end, so users still get a chance to queue their own tracks.
*/
public void setAutoplayEnabled(boolean enabled) {
this.autoplayEnabled = enabled;
}
boolean tryBeginAutoplayLoad() {
return autoplayLoading.compareAndSet(false, true);
}
void endAutoplayLoad() {
autoplayLoading.set(false);
}
private void maybeAutoplay(long position) {
Track track = currentTrack;
if (!autoplayEnabled || track == null || queueManager.getQueueSize() > 1) {
return;
}
long length = track.getInfo().getLength();
if (length > 0 && length - position <= AUTOPLAY_REMAINING_MS) {
autoplayService.loadFor(this, track);
}
}
public Member getDj() {
Context ctx = context;
if (ctx != null) {
@@ -102,6 +147,7 @@ public class TrackScheduler {
logger.debug("Guild {}: starting track: {}", guild.getId(), track.getInfo().getIdentifier());
currentTrack = track;
stuckTrackRecoveryHandler.reset();
publish(MusicEvent.Type.TRACK_START, track, null);
}
public void onTrackEnd(Track track, TrackEndEvent.AudioTrackEndReason endReason) {
@@ -110,6 +156,7 @@ public class TrackScheduler {
if (endReason != TrackEndEvent.AudioTrackEndReason.REPLACED) {
currentTrack = null;
}
publish(MusicEvent.Type.TRACK_END, track, endReason.name());
if (endReason == TrackEndEvent.AudioTrackEndReason.LOAD_FAILED) {
logger.debug("Guild {}: failed loading track: {}", guild.getId(), track.getInfo().getIdentifier());
@@ -134,9 +181,39 @@ public class TrackScheduler {
// clone of this back to your queue
}
private static java.util.Map<String, Object> trackPayload(Track track) {
java.util.Map<String, Object> payload = new java.util.HashMap<>();
payload.put("identifier", track.getInfo().getIdentifier());
payload.put("title", track.getInfo().getTitle());
payload.put("author", track.getInfo().getAuthor());
payload.put("length", track.getInfo().getLength());
payload.put("uri", track.getInfo().getUri());
payload.put("artworkUrl", track.getInfo().getArtworkUrl());
return payload;
}
private void publish(MusicEvent.Type type, Track track, String reason) {
if (manager.getEvents() == null) {
return;
}
java.util.Map<String, Object> payload = trackPayload(track);
if (reason != null) {
payload.put("reason", reason);
}
manager.getEvents().publish(MusicEvent.of(type, guild.getId(), payload));
}
public void onTrackException(Track track, TrackException exception) {
logger.error("Guild {}: exception while playing track: {}", guild.getId(), track.getInfo().getIdentifier());
if (manager.getEvents() != null) {
java.util.Map<String, Object> payload = new java.util.HashMap<>();
payload.put("track", trackPayload(track));
payload.put("message", exception.getMessage());
payload.put("severity", exception.getSeverity() != null ? exception.getSeverity().name() : null);
manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.TRACK_ERROR, guild.getId(), payload));
}
LogHandler.log(new ContextException(
String.format("Error while playing track: %s", track.getInfo().getTitle()),
getContext(),
@@ -146,9 +223,37 @@ public class TrackScheduler {
public void onTrackStuck(Track track, long thresholdMs) {
logger.error("Guild {}: currently playing track is stuck: {} (threshold {}ms)", guild.getId(), track.getInfo().getIdentifier(), thresholdMs);
if (manager.getEvents() != null) {
java.util.Map<String, Object> payload = new java.util.HashMap<>();
payload.put("track", trackPayload(track));
payload.put("thresholdMs", thresholdMs);
manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.TRACK_STUCK, guild.getId(), payload));
}
stuckTrackRecoveryHandler.handle(track, context);
}
public void onPlayerUpdate(long timestamp, long position, boolean connected, long ping) {
onPlayerUpdate(timestamp, position, connected, ping, System.currentTimeMillis());
}
void onPlayerUpdate(long timestamp, long position, boolean connected, long ping, long now) {
maybeAutoplay(position);
if (manager.getEvents() == null || currentTrack == null) {
return;
}
if (lastPositionSent != Long.MIN_VALUE && now - lastPositionSent < POSITION_THROTTLE_MS) {
return;
}
lastPositionSent = now;
java.util.Map<String, Object> payload = new java.util.HashMap<>();
payload.put("position", position);
payload.put("timestamp", timestamp);
payload.put("connected", connected);
payload.put("ping", ping);
manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.PLAYER_POSITION, guild.getId(), payload));
}
public void onWebSocketClosed(int code, String reason, boolean byRemote) {
logger.warn("Guild {}: voice websocket closed with code {} ({}), by remote: {}", guild.getId(), code, reason, byRemote);
currentTrack = null;
@@ -0,0 +1,45 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.client.player.Track;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Autoplay through the YouTube radio mix of the seed track.
*/
public class YoutubeAutoplayProvider implements AutoplayProvider {
private static final Pattern VIDEO_ID = Pattern.compile("(?:[?&]v=|youtu\\.be/)([-_a-zA-Z0-9]{11})");
@Override
public boolean supports(Track seed) {
return videoId(seed).isPresent();
}
@Override
public Optional<String> radioIdentifier(Track seed) {
return videoId(seed).map(id -> "https://www.youtube.com/watch?v=" + id + "&list=RD" + id);
}
private static Optional<String> videoId(Track seed) {
if ("youtube".equalsIgnoreCase(seed.getInfo().getSourceName())) {
String identifier = seed.getInfo().getIdentifier();
return identifier == null || identifier.isBlank() ? Optional.empty() : Optional.of(identifier);
}
String uri = seed.getInfo().getUri();
if (uri != null && SourceFinder.getSource(uri) == SourceFinder.AudioSourceType.Youtube) {
Matcher matcher = VIDEO_ID.matcher(uri);
if (matcher.find()) {
return Optional.of(matcher.group(1));
}
}
return Optional.empty();
}
}
@@ -0,0 +1,52 @@
package me.bobert.netneutrality.shard;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import java.util.List;
/**
* Read-only, shard-aggregating view of the Discord gateway connections.
*/
public interface ShardProvider {
/**
* @return {@code true} once every shard has finished starting up and until shutdown
*/
boolean isReady();
/**
* Looks a guild up on whichever shard owns it.
*
* @return the guild, or {@code null} if no shard knows it
*/
Guild getGuildById(String id);
/**
* @return the guilds of all shards
*/
List<Guild> getGuilds();
int getShardCount();
/**
* @return the mean gateway ping across shards, or {@code -1} if unknown
*/
double getAveragePing();
/**
* @return {@code CONNECTED} when every shard is connected, otherwise the most degraded status
*/
JDA.Status getWorstStatus();
/**
* @return an arbitrary shard (for global operations such as command registration), or {@code null} if not ready
*/
JDA getAnyShard();
}
@@ -0,0 +1,206 @@
package me.bobert.netneutrality.shard;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.libraries.jda.JDAVoiceUpdateListener;
import me.bobert.netneutrality.bootstrap.Lifecycle;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.exceptions.InvalidTokenException;
import net.dv8tion.jda.api.hooks.EventListener;
import net.dv8tion.jda.api.requests.GatewayIntent;
import net.dv8tion.jda.api.sharding.DefaultShardManagerBuilder;
import net.dv8tion.jda.api.sharding.ShardManager;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
/**
* Owns the JDA {@link ShardManager}. The shard count is Discord's recommendation unless
* {@code NETNEUTRALITY_SHARD_TOTAL} overrides it.
*/
public final class ShardService implements Lifecycle, ShardProvider {
public static final String SHARD_TOTAL_KEY = "NETNEUTRALITY_SHARD_TOTAL";
private static final Logger logger = LogManager.getLogger(ShardService.class);
private final SettingsProvider settings;
private final MusicService music;
private final EventListener eventsHandler;
private volatile ShardManager shardManager;
private volatile boolean ready;
public ShardService(SettingsProvider settings, MusicService music, EventListener eventsHandler) {
this.settings = Objects.requireNonNull(settings, "settings");
this.music = Objects.requireNonNull(music, "music");
this.eventsHandler = Objects.requireNonNull(eventsHandler, "eventsHandler");
}
/**
* Wraps an already-built manager; used by tests.
*/
ShardService(ShardManager shardManager) {
this.settings = null;
this.music = null;
this.eventsHandler = null;
this.shardManager = shardManager;
this.ready = true;
}
@Override
public void start() throws Exception {
String token = settings.get("NETNEUTRALITY_BOT_TOKEN");
if (token == null) {
throw new IllegalStateException("No bot token provided, exiting.");
}
try {
DefaultShardManagerBuilder builder = DefaultShardManagerBuilder
.createDefault(token)
.enableIntents(
GatewayIntent.GUILD_MEMBERS,
GatewayIntent.GUILD_MESSAGE_REACTIONS,
GatewayIntent.GUILD_VOICE_STATES,
GatewayIntent.MESSAGE_CONTENT)
// Voice state/server updates are handed to Lavalink instead of JDA's audio stack;
// DAVE encryption is handled by the node.
.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(music.getClient()))
// Attached before connecting so no shard misses early events.
.addEventListeners(eventsHandler)
.setActivity(Activity.watching("me load..."));
int total = parseShardTotal(settings.get(SHARD_TOTAL_KEY));
if (total > 0) {
builder.setShardsTotal(total);
}
shardManager = builder.build();
awaitAllShards(shardManager);
ready = true;
logger.info("{} shard(s) ready", shardManager.getShardsTotal());
} catch (InvalidTokenException | IllegalArgumentException e) {
LogHandler.log(e);
throw new IllegalStateException("Unable to connect to Discord", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LogHandler.log(e);
throw new IllegalStateException("Unable to connect to Discord", e);
}
}
@Override
public void stop() {
ready = false;
ShardManager manager = shardManager;
if (manager != null) {
manager.shutdown();
}
}
@Override
public int priority() {
return 60;
}
/**
* @return the configured shard count, or {@code -1} (automatic) when missing or invalid
*/
static int parseShardTotal(String raw) {
if (raw == null || raw.isBlank()) {
return -1;
}
try {
int value = Integer.parseInt(raw.trim());
if (value > 0) {
return value;
}
} catch (NumberFormatException ignored) {
// falls through to the warning below
}
logger.warn("Invalid {} '{}', using Discord's recommended shard count", SHARD_TOTAL_KEY, raw);
return -1;
}
private static void awaitAllShards(ShardManager manager) throws InterruptedException {
int total = manager.getShardsTotal();
for (int id = 0; id < total; id++) {
JDA shard = manager.getShardById(id);
// Shards are started asynchronously (one identify per 5s), so wait for each to appear.
while (shard == null) {
Thread.sleep(100);
shard = manager.getShardById(id);
}
shard.awaitReady();
}
}
public void setActivity(Activity activity) {
ShardManager manager = shardManager;
if (manager != null) {
manager.setActivity(activity);
}
}
@Override
public boolean isReady() {
return ready && shardManager != null;
}
@Override
public Guild getGuildById(String id) {
ShardManager manager = shardManager;
return manager == null ? null : manager.getGuildById(id);
}
@Override
public List<Guild> getGuilds() {
ShardManager manager = shardManager;
return manager == null ? List.of() : manager.getGuilds();
}
@Override
public int getShardCount() {
ShardManager manager = shardManager;
return manager == null ? 0 : manager.getShardsTotal();
}
@Override
public double getAveragePing() {
ShardManager manager = shardManager;
return manager == null ? -1 : manager.getAverageGatewayPing();
}
@Override
public JDA.Status getWorstStatus() {
ShardManager manager = shardManager;
if (manager == null) {
return JDA.Status.INITIALIZING;
}
return manager.getShards().stream()
.map(JDA::getStatus)
.filter(status -> status != JDA.Status.CONNECTED)
.max(Comparator.naturalOrder())
.orElse(JDA.Status.CONNECTED);
}
@Override
public JDA getAnyShard() {
ShardManager manager = shardManager;
if (manager == null || !ready) {
return null;
}
JDA first = manager.getShardById(0);
return first != null ? first : manager.getShards().stream().findFirst().orElse(null);
}
}
@@ -13,6 +13,7 @@ import me.bobert.netneutrality.core.tasks.trait.Deletable;
import me.bobert.netneutrality.core.tasks.trait.WithChildren;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.PlaylistInfo;
import me.bobert.netneutrality.music.SourceFinder;
import me.bobert.netneutrality.core.tasks.generic.AcceptButton;
import me.bobert.netneutrality.core.tasks.generic.CancelButton;
@@ -27,15 +28,17 @@ import java.util.List;
public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deletable {
private final List<Task> childrens = new ArrayList<>();
private final String playlistIdentifier;
private final String playlistName;
private final List<Track> playlistTracks;
private final boolean searchResult;
private final SourceFinder.AudioSourceType sourceType;
private final MusicManager musicManager;
public LoadPlaylistTask(String playlistName, List<Track> playlistTracks, boolean searchResult,
public LoadPlaylistTask(String playlistIdentifier, String playlistName, List<Track> playlistTracks, boolean searchResult,
SourceFinder.AudioSourceType sourceType, MusicManager musicManager) {
super(musicManager.getTaskManager(), null, null);
this.playlistIdentifier = playlistIdentifier;
this.playlistName = playlistName;
this.playlistTracks = playlistTracks;
this.searchResult = searchResult;
@@ -93,7 +96,8 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
// Load playlist normally
if (MusicUtils.connect(context, musicManager)) {
musicManager.getQueueManager().queue(tracks, context.getGuildUser());
musicManager.getQueueManager().queue(tracks, context.getGuildUser(),
new PlaylistInfo(playlistIdentifier, playlistName, tracks.size()));
context.edit(MessageFunction.buildAddingPlaylistMessage(tracks, playlistName, context.getGuildUser(), sourceType));
}
@@ -104,7 +108,7 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
Member member = context.getGuildUser();
if (MusicUtils.connect(context, musicManager)) {
musicManager.getQueueManager().queue(tracks, member);
musicManager.getQueueManager().queue(tracks, member, new PlaylistInfo(playlistIdentifier, playlistName, tracks.size()));
context.reply(MessageFunction.buildAddingPlaylistMessage(tracks, playlistName, member, SourceFinder.AudioSourceType.Youtube));
return Lifecycle.REMOVE_SELF_AND_CHILDREN;
@@ -0,0 +1,254 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CommandContainerTest {
@Mock
private Command command;
@BeforeEach
void setup() {
when(command.getCommandName()).thenReturn("test");
when(command.getDescription()).thenReturn("A test command");
when(command.getType()).thenReturn(Command.Type.CHAT);
}
private CommandContainer container(Consumer<CommandOptionsBuilder> registration) {
doAnswer(invocation -> {
registration.accept(invocation.getArgument(0));
return null;
}).when(command).registerCommandOptions(org.mockito.ArgumentMatchers.any());
return new CommandContainer(command);
}
// ===== NULL_COMMAND and construction =====
@Test
void nullCommandShouldHaveNoCommand() {
assertNull(CommandContainer.NULL_COMMAND.getCommand());
}
@Test
void nullCommandShouldBeEmptyAndPrintNothing() {
assertTrue(CommandContainer.NULL_COMMAND.isEmpty());
assertEquals("", CommandContainer.NULL_COMMAND.printUsage());
}
@Test
void ofShouldBuildTheSameAsTheConstructor() {
doAnswer(invocation -> {
((CommandOptionsBuilder) invocation.getArgument(0))
.addOptions(new OptionData(OptionType.STRING, "text", "Text"));
return null;
}).when(command).registerCommandOptions(org.mockito.ArgumentMatchers.any());
CommandContainer container = CommandContainer.of(command);
assertSame(command, container.getCommand());
assertEquals(1, container.getOptions().size());
}
@Test
void shouldExposeTheCommand() {
assertSame(command, container(_ -> { }).getCommand());
}
@Test
void shouldExposeTheBuiltSlashData() {
CommandContainer container = container(_ -> { });
assertNotNull(container.getSlashCommandData());
assertEquals("test", container.getSlashCommandData().getName());
}
@Test
void shouldExposeTheRegisteredOptionsSubcommandsAndGroups() {
CommandContainer container = container(builder -> {
builder.addOptions(new OptionData(OptionType.STRING, "text", "Text"));
});
assertEquals(1, container.getOptions().size());
assertTrue(container.getSubcommands().isEmpty());
assertTrue(container.getSubcommandGroups().isEmpty());
}
// ===== isEmpty =====
@Test
void shouldBeEmptyWithoutAnyOptions() {
assertTrue(container(_ -> { }).isEmpty());
}
@Test
void shouldNotBeEmptyWithOptions() {
assertFalse(container(builder -> builder.addOptions(new OptionData(OptionType.STRING, "text", "Text"))).isEmpty());
}
@Test
void shouldNotBeEmptyWithSubcommands() {
assertFalse(container(builder -> builder.addSubcommands(new SubcommandData("add", "Adds"))).isEmpty());
}
@Test
void shouldNotBeEmptyWithSubcommandGroups() {
CommandContainer container = container(builder -> builder.addSubcommandGroups(
new SubcommandGroupData("grp", "Group").addSubcommands(new SubcommandData("add", "Adds"))));
assertFalse(container.isEmpty());
}
// ===== printUsage =====
@Test
void shouldPrintNothingWithoutOptions() {
assertEquals("", container(_ -> { }).printUsage());
}
@Test
void shouldPrintPlainOptions() {
CommandContainer container = container(builder -> builder.addOptions(
new OptionData(OptionType.INTEGER, "amount", "Amount", true),
new OptionData(OptionType.STRING, "reason", "Reason")));
assertEquals("- Usage: /test <amount> <reason>\n"
+ " - *amount: Amount\n"
+ " - reason: Reason\n\n"
+ "-# *: required argument", container.printUsage());
}
@Test
void shouldPrintMinAndMaxValues() {
CommandContainer container = container(builder -> builder.addOptions(
new OptionData(OptionType.INTEGER, "amount", "Amount").setMinValue(1).setMaxValue(10)));
String usage = container.printUsage();
assertTrue(usage.contains("amount: Amount [Min value: 1] [Max value: 10]"), usage);
}
@Test
void shouldPrintMinAndMaxLengths() {
CommandContainer container = container(builder -> builder.addOptions(
new OptionData(OptionType.STRING, "text", "Text").setMinLength(2).setMaxLength(20)));
String usage = container.printUsage();
assertTrue(usage.contains("text: Text [Min length: 2] [Max length: 20]"), usage);
}
@Test
void shouldPrintChoices() {
CommandContainer container = container(builder -> builder.addOptions(
new OptionData(OptionType.STRING, "mode", "Mode").addChoice("One", "one").addChoice("Two", "two")));
String usage = container.printUsage();
assertTrue(usage.contains("[Available choices: one, two]"), usage);
}
@Test
void shouldPrintRequiredChannelTypes() {
CommandContainer container = container(builder -> builder.addOptions(
new OptionData(OptionType.CHANNEL, "channel", "Channel").setChannelTypes(ChannelType.TEXT)));
String usage = container.printUsage();
assertTrue(usage.contains("[Required channel types: TEXT]"), usage);
}
@Test
void shouldPrintSubcommands() {
CommandContainer container = container(builder -> builder.addSubcommands(
new SubcommandData("add", "Adds").addOptions(new OptionData(OptionType.STRING, "name", "Name", true))));
String usage = container.printUsage();
assertTrue(usage.contains("- Usage: /test add <name> (Adds)\n - *name: Name"), usage);
assertTrue(usage.endsWith("-# *: required argument"), usage);
}
@Test
void shouldPrintSubcommandGroups() {
CommandContainer container = container(builder -> builder.addSubcommandGroups(
new SubcommandGroupData("grp", "Group").addSubcommands(
new SubcommandData("add", "Adds").addOptions(new OptionData(OptionType.STRING, "name", "Name", true)))));
String usage = container.printUsage();
assertTrue(usage.contains("- Subcommand: /test grp (Group)\n"), usage);
assertTrue(usage.contains(" - Usage: /test grp add <name> (Adds)\n - *name: Name"), usage);
assertTrue(usage.endsWith("-# *: required argument"), usage);
}
@Test
void shouldPrintExactSubcommandUsage() {
CommandContainer container = container(builder -> builder.addSubcommands(
new SubcommandData("add", "Adds").addOptions(new OptionData(OptionType.STRING, "name", "Name", true))));
assertEquals("- Usage: /test add <name> (Adds)\n - *name: Name\n\n\n-# *: required argument",
container.printUsage());
}
@Test
void shouldPrintExactSubcommandGroupUsage() {
CommandContainer container = container(builder -> builder.addSubcommandGroups(
new SubcommandGroupData("grp", "Group").addSubcommands(
new SubcommandData("add", "Adds").addOptions(new OptionData(OptionType.STRING, "name", "Name", true)))));
assertEquals("- Subcommand: /test grp (Group)\n"
+ " - Usage: /test grp add <name> (Adds)\n"
+ " - *name: Name\n\n\n-# *: required argument", container.printUsage());
}
@Test
void shouldPrintExactAnnotations() {
CommandContainer container = container(builder -> builder.addOptions(
new OptionData(OptionType.STRING, "mode", "Mode").setMinLength(1).setMaxLength(5)
.addChoice("One", "one").addChoice("Two", "two"),
new OptionData(OptionType.INTEGER, "n", "N").setMinValue(1).setMaxValue(9),
new OptionData(OptionType.CHANNEL, "ch", "Ch").setChannelTypes(ChannelType.TEXT)));
assertEquals("- Usage: /test <mode> <n> <ch>\n"
+ " - mode: Mode [Min length: 1] [Max length: 5] [Available choices: one, two]\n"
+ " - n: N [Min value: 1] [Max value: 9]\n"
+ " - ch: Ch [Required channel types: TEXT]\n\n"
+ "-# *: required argument", container.printUsage());
}
@Test
void shouldNotPrintTheRequiredLegendForUsagelessCommands() {
assertFalse(container(_ -> { }).printUsage().contains("required argument"));
}
}
@@ -0,0 +1,138 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.core.command.precondition.DisabledCommandPrecondition;
import me.bobert.netneutrality.utils.UserFunction;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Dispatches through a real {@link CommandProcessor} with the default preconditions, usage validator and
* result handler, so only the Discord entities, the registry and the command itself are mocked.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CommandDispatchIntegrationTest {
private static final String COMMAND_NAME = "test";
@Mock
private CommandContext context;
@Mock
private CommandRegistry commands;
@Mock
private TaskManager taskManager;
@Mock
private Command command;
@Mock
private Guild guild;
@Mock
private Member member;
@Mock
private User user;
@Mock
private MessageChannelUnion channel;
private CommandProcessor processor;
@BeforeEach
void setup() {
UserFunction.bind((GuildServiceProvider) null);
DisabledCommandPrecondition.bind(null);
when(command.getCommandName()).thenReturn(COMMAND_NAME);
when(command.getDescription()).thenReturn("A test command");
when(command.getType()).thenReturn(Command.Type.CHAT);
when(command.isEnabled()).thenReturn(true);
when(command.canBeExecutedFrom(channel)).thenReturn(true);
when(command.canBeExecutedBy(member)).thenReturn(true);
when(command.execute(context)).thenReturn(CommandExecutionResult.success("Executed"));
CommandContainer container = new CommandContainer(command);
when(commands.hasCommand(COMMAND_NAME)).thenReturn(true);
when(commands.getCommandContainer(COMMAND_NAME)).thenReturn(container);
when(context.parseCommandName()).thenReturn(COMMAND_NAME);
when(context.getFullCommand()).thenReturn(COMMAND_NAME);
when(context.getId()).thenReturn("context-id");
when(context.getGuild()).thenReturn(guild);
when(context.isFromGuild()).thenReturn(true);
when(context.getGuildUser()).thenReturn(member);
when(context.getDiscordUser()).thenReturn(user);
when(context.getChannel()).thenReturn(channel);
when(context.isValid()).thenReturn(true);
when(guild.getId()).thenReturn("111");
when(member.getId()).thenReturn("222");
when(user.getId()).thenReturn("222");
when(channel.getId()).thenReturn("333");
processor = new CommandProcessor(context, commands, taskManager);
}
@AfterEach
void tearDown() {
UserFunction.bind((GuildServiceProvider) null);
DisabledCommandPrecondition.bind(null);
}
@Test
void shouldBlockANonAdminOnAnAdminCommand() {
when(command.needAdmin()).thenReturn(true);
processor.dispatch();
verify(context).replyEphemeral("You are not powerful enough, scrub");
verify(command, never()).execute(any());
verify(context, never()).reply("Executed");
}
@Test
void shouldRunAnAllowedCommandToCompletion() {
processor.dispatch();
verify(context).setCommand(command);
verify(command).execute(context);
verify(context).reply("Executed");
}
@Test
void shouldBlockAGuildOnlyCommandInPrivateMessages() {
when(command.isGuildOnly()).thenReturn(true);
when(context.isFromGuild()).thenReturn(false);
processor.dispatch();
verify(context).replyEphemeral("This command is only available in a guild");
verify(context).deleteMessage();
verify(command, never()).execute(any());
}
}
@@ -0,0 +1,246 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.bootstrap.ApplicationContext;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.interactions.InteractionContextType;
import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions;
import net.dv8tion.jda.api.interactions.commands.build.CommandData;
import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData;
import net.dv8tion.jda.api.requests.restaction.CommandListUpdateAction;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.RETURNS_SELF;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CommandManagerTest {
@Mock
private ApplicationContext applicationContext;
@Mock
private JDA jda;
private CommandListUpdateAction updateAction;
private CommandManager manager;
@BeforeEach
void setup() {
manager = new CommandManager(applicationContext);
updateAction = mock(CommandListUpdateAction.class, RETURNS_SELF);
when(jda.updateCommands()).thenReturn(updateAction);
}
private Command command(String name, String description) {
Command command = mock(Command.class);
when(command.getCommandName()).thenReturn(name);
when(command.getDescription()).thenReturn(description);
when(command.getType()).thenReturn(Command.Type.CHAT);
when(command.isEnabled()).thenReturn(true);
when(command.isSlashCommand()).thenReturn(true);
when(command.isGuildOnly()).thenReturn(false);
when(command.getSlashCommandPermission()).thenReturn(DefaultMemberPermissions.ENABLED);
return command;
}
@SuppressWarnings("unchecked")
private List<CommandData> registeredCommands() {
manager.registerSlashCommands(jda);
ArgumentCaptor<Collection<CommandData>> captor = ArgumentCaptor.forClass(Collection.class);
verify(updateAction).addCommands(captor.capture());
verify(updateAction).queue();
return List.copyOf(captor.getValue());
}
// ===== Construction =====
@Test
void shouldRequireTheApplicationContext() {
assertThrows(NullPointerException.class, () -> new CommandManager(null));
}
@Test
void shouldStartAfterTheOtherRegistries() {
assertEquals(30, manager.priority());
}
// ===== Loading =====
@Test
void shouldRegisterAnEnabledCommand() {
Command ping = command("ping", "Pings");
manager.onCommandLoaded(ping);
assertTrue(manager.hasCommand("ping"));
assertSame(ping, manager.getCommandContainer("ping").getCommand());
}
@Test
void shouldSkipDisabledCommands() {
Command ping = command("ping", "Pings");
when(ping.isEnabled()).thenReturn(false);
manager.onCommandLoaded(ping);
assertFalse(manager.hasCommand("ping"));
}
@Test
void shouldSkipCommandsWithoutAName() {
manager.onCommandLoaded(command(null, "Pings"));
assertTrue(manager.getCommands().isEmpty());
}
@Test
void shouldSkipChatCommandsWithoutADescription() {
manager.onCommandLoaded(command("ping", null));
assertFalse(manager.hasCommand("ping"));
}
@Test
void shouldKeepMessageCommandsWithoutADescription() {
Command menu = command("menu", null);
when(menu.getType()).thenReturn(Command.Type.MESSAGE);
manager.onCommandLoaded(menu);
assertTrue(manager.hasCommand("menu"));
}
@Test
void shouldKeepTheFirstCommandWhenNamesAreDuplicated() {
Command first = command("ping", "First");
Command second = command("ping", "Second");
manager.onCommandLoaded(first);
manager.onCommandLoaded(second);
assertEquals(1, manager.getCommands().size());
assertSame(first, manager.getCommandContainer("ping").getCommand());
}
// ===== Lookups =====
@Test
void shouldNotHaveAnUnknownCommand() {
assertFalse(manager.hasCommand("unknown"));
}
@Test
void shouldReturnTheNullCommandForAnUnknownName() {
assertSame(CommandContainer.NULL_COMMAND, manager.getCommandContainer("unknown"));
}
// ===== Slash command registration =====
@Test
void shouldRegisterEnabledSlashCommands() {
manager.onCommandLoaded(command("ping", "Pings"));
List<CommandData> registered = registeredCommands();
assertEquals(1, registered.size());
assertEquals("ping", registered.getFirst().getName());
}
@Test
void shouldRegisterNothingWithoutCommands() {
assertTrue(registeredCommands().isEmpty());
}
@Test
void shouldNotRegisterCommandsThatAreNotSlashCommands() {
Command ping = command("ping", "Pings");
manager.onCommandLoaded(ping);
when(ping.isSlashCommand()).thenReturn(false);
assertTrue(registeredCommands().isEmpty());
}
@Test
void shouldNotRegisterCommandsDisabledAfterLoading() {
Command ping = command("ping", "Pings");
manager.onCommandLoaded(ping);
when(ping.isEnabled()).thenReturn(false);
assertTrue(registeredCommands().isEmpty());
}
@Test
void shouldRestrictGuildOnlyCommandsToGuilds() {
Command ping = command("ping", "Pings");
when(ping.isGuildOnly()).thenReturn(true);
manager.onCommandLoaded(ping);
SlashCommandData data = (SlashCommandData) registeredCommands().getFirst();
assertEquals(Set.of(InteractionContextType.GUILD), data.getContexts());
}
@Test
void shouldAllowOtherCommandsInGuildsAndBotDms() {
manager.onCommandLoaded(command("ping", "Pings"));
SlashCommandData data = (SlashCommandData) registeredCommands().getFirst();
assertEquals(Set.of(InteractionContextType.GUILD, InteractionContextType.BOT_DM), data.getContexts());
}
@Test
void shouldApplyTheDefaultPermissionOfTheCommand() {
Command ping = command("ping", "Pings");
when(ping.getSlashCommandPermission()).thenReturn(DefaultMemberPermissions.DISABLED);
manager.onCommandLoaded(ping);
SlashCommandData data = (SlashCommandData) registeredCommands().getFirst();
assertEquals(DefaultMemberPermissions.DISABLED, data.getDefaultPermissions());
}
@Test
void shouldKeepRegisteringWhenOneCommandFails() {
Command broken = command("broken", "Broken");
CommandContainer brokenContainer = mock(CommandContainer.class);
when(brokenContainer.getCommand()).thenReturn(broken);
when(brokenContainer.getSlashCommandData()).thenThrow(new IllegalStateException("boom"));
manager.getCommands().put("broken", brokenContainer);
manager.onCommandLoaded(command("ping", "Pings"));
List<CommandData> registered = registeredCommands();
assertEquals(1, registered.size());
assertEquals("ping", registered.getFirst().getName());
verify(brokenContainer).getSlashCommandData();
}
}
@@ -0,0 +1,105 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CommandOptionsBuilderTest {
@Mock
private Command command;
@BeforeEach
void setup() {
when(command.getCommandName()).thenReturn("test");
when(command.getDescription()).thenReturn("A test command");
}
@Test
void shouldBuildSlashDataForChatCommands() {
CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.CHAT, command);
assertNotNull(builder.build());
assertEquals(net.dv8tion.jda.api.interactions.commands.Command.Type.SLASH, builder.getType());
assertEquals("test", builder.getName());
assertEquals("A test command", builder.getDescription());
}
@Test
void shouldBuildUserContextMenuDataForUserCommands() {
CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.USER, command);
assertEquals(net.dv8tion.jda.api.interactions.commands.Command.Type.USER, builder.build().getType());
assertEquals("test", builder.build().getName());
}
@Test
void shouldBuildMessageContextMenuDataForMessageCommands() {
CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.MESSAGE, command);
assertEquals(net.dv8tion.jda.api.interactions.commands.Command.Type.MESSAGE, builder.build().getType());
assertEquals("test", builder.build().getName());
}
@Test
void shouldStartWithoutOptions() {
CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.CHAT, command);
assertTrue(builder.getOptions().isEmpty());
assertTrue(builder.getSubcommands().isEmpty());
assertTrue(builder.getSubcommandGroups().isEmpty());
}
@Test
void shouldDelegateAddedOptions() {
CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.CHAT, command);
builder.addOptions(new OptionData(OptionType.STRING, "text", "Some text", true));
assertEquals(1, builder.build().getOptions().size());
assertEquals("text", builder.getOptions().getFirst().getName());
}
@Test
void shouldDelegateAddedSubcommandsAndGroups() {
CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.CHAT, command);
builder.addSubcommands(new SubcommandData("add", "Adds"));
builder.addSubcommandGroups(new SubcommandGroupData("grp", "Group").addSubcommands(new SubcommandData("sub", "Sub")));
assertEquals("add", builder.getSubcommands().getFirst().getName());
assertEquals("grp", builder.getSubcommandGroups().getFirst().getName());
}
@Test
void shouldDelegateSettersToTheBuiltData() {
CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.CHAT, command);
builder.setNSFW(true);
builder.setDescription("Changed");
assertTrue(builder.build().isNSFW());
assertEquals("Changed", builder.build().getDescription());
}
}
@@ -109,7 +109,7 @@ class CommandProcessorTest {
when(commands.getCommandContainer(COMMAND_NAME)).thenReturn(container);
when(container.getCommand()).thenReturn(command);
when(command.getCommandName()).thenReturn(COMMAND_NAME);
when(command.getType()).thenReturn(Command.Type.SLASH);
when(command.getType()).thenReturn(Command.Type.CHAT);
when(command.execute(context)).thenReturn(result);
when(usageValidator.validate(context, container)).thenReturn(true);
}
@@ -0,0 +1,113 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.Task;
import me.bobert.netneutrality.core.tasks.TaskManager;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CommandResultHandlerTest {
@Mock
private CommandContext context;
@Mock
private TaskManager taskManager;
@Mock
private Task task;
private final CommandResultHandler handler = new CommandResultHandler();
@Test
void shouldDoNothingForNoAction() {
handler.handle(context, CommandExecutionResult.none(), taskManager);
verify(context, never()).reply(anyString());
verifyNoInteractions(taskManager);
}
@Test
void shouldInsertTheTaskForATaskResult() {
handler.handle(context, CommandExecutionResult.task(task), taskManager);
verify(taskManager).insert(context, task);
}
@Test
void shouldNotReplyForATaskResultWithoutMessage() {
handler.handle(context, CommandExecutionResult.task(task), taskManager);
verify(context, never()).reply(anyString());
}
@Test
void shouldReplyForASuccess() {
handler.handle(context, CommandExecutionResult.success("Done"), taskManager);
verify(context).reply("Done");
verify(taskManager, never()).insert(any(), any());
}
@Test
void shouldReplyForAnInfo() {
handler.handle(context, CommandExecutionResult.info("FYI"), taskManager);
verify(context).reply("FYI");
}
@Test
void shouldReplyForAWarning() {
handler.handle(context, CommandExecutionResult.warn("Careful"), taskManager);
verify(context).reply("Careful");
}
@Test
void shouldReplyForAnError() {
handler.handle(context, CommandExecutionResult.error("Broken", new IllegalStateException("boom")), taskManager);
verify(context).reply("Broken");
}
@Test
void shouldReplyForAnErrorWithoutException() {
handler.handle(context, CommandExecutionResult.error("Broken"), taskManager);
verify(context).reply("Broken");
}
@Test
void shouldNotReplyWhenTheMessageIsNull() {
handler.handle(context, CommandExecutionResult.success((String) null), taskManager);
verify(context, never()).reply(anyString());
}
@Test
void shouldFailForANullResult() {
when(context.getId()).thenReturn("context-id");
assertThrows(RuntimeException.class, () -> handler.handle(context, null, taskManager));
}
}
@@ -0,0 +1,47 @@
package me.bobert.netneutrality.core.command;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class CommandUsageFormatterTest {
private final CommandUsageFormatter formatter = new CommandUsageFormatter();
private Command command() {
Command command = mock(Command.class);
when(command.getCommandName()).thenReturn("test");
when(command.getDescription()).thenReturn("A test command");
when(command.getType()).thenReturn(Command.Type.CHAT);
return command;
}
@Test
void shouldFormatEmptyContainerAsEmptyString() {
assertEquals("", formatter.format(new CommandContainer(command())));
}
@Test
void shouldFormatOptionsWithRequiredMarkerAndLegend() {
Command command = command();
doAnswer(invocation -> {
CommandOptionsBuilder builder = invocation.getArgument(0);
builder.addOptions(new OptionData(OptionType.STRING, "text", "Text", true));
return null;
}).when(command).registerCommandOptions(org.mockito.ArgumentMatchers.any());
assertEquals("- Usage: /test <text>\n - *text: Text\n\n-# *: required argument",
formatter.format(new CommandContainer(command)));
}
}
@@ -0,0 +1,118 @@
package me.bobert.netneutrality.core.command.precondition;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.Command;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildSettingService;
import net.dv8tion.jda.api.entities.Guild;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class DisabledCommandPreconditionTest {
@Mock
private CommandContext context;
@Mock
private Command command;
@Mock
private Guild guild;
@Mock
private GuildServiceProvider provider;
@Mock
private GuildService guildService;
@Mock
private GuildSettingService settingService;
private final DisabledCommandPrecondition precondition = new DisabledCommandPrecondition();
@BeforeEach
void setup() {
when(context.getGuild()).thenReturn(guild);
when(guild.getId()).thenReturn("111");
when(command.getCommandName()).thenReturn("ping");
when(provider.get("111")).thenReturn(guildService);
when(guildService.getGuildSettingService()).thenReturn(settingService);
when(settingService.getOrDefault(GSettings.DISABLED_COMMANDS)).thenReturn("");
DisabledCommandPrecondition.bind(provider);
}
@AfterEach
void tearDown() {
DisabledCommandPrecondition.bind(null);
}
@Test
void shouldPassWhenNoProviderIsBound() {
DisabledCommandPrecondition.bind(null);
assertTrue(precondition.check(context, command));
}
@Test
void shouldPassOutsideAGuild() {
when(context.getGuild()).thenReturn(null);
assertTrue(precondition.check(context, command));
verify(context, never()).replyEphemeral(anyString());
}
@Test
void shouldPassWhenNoCommandIsDisabled() {
assertTrue(precondition.check(context, command));
verify(context, never()).replyEphemeral(anyString());
}
@Test
void shouldPassWhenAnotherCommandIsDisabled() {
when(settingService.getOrDefault(GSettings.DISABLED_COMMANDS)).thenReturn("play,skip");
assertTrue(precondition.check(context, command));
}
@Test
void shouldBlockADisabledCommand() {
when(settingService.getOrDefault(GSettings.DISABLED_COMMANDS)).thenReturn("play,ping");
assertFalse(precondition.check(context, command));
verify(context).replyEphemeral("This command is disabled on this server");
}
@Test
void shouldIgnoreCaseAndWhitespaceWhenMatching() {
when(settingService.getOrDefault(GSettings.DISABLED_COMMANDS)).thenReturn("play, PING ");
assertFalse(precondition.check(context, command));
}
}
@@ -0,0 +1,68 @@
package me.bobert.netneutrality.core.command.precondition;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.Command;
import me.bobert.netneutrality.core.context.CommandContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class GuildOnlyPreconditionTest {
@Mock
private CommandContext context;
@Mock
private Command command;
private final GuildOnlyPrecondition precondition = new GuildOnlyPrecondition();
@Test
void shouldPassGuildOnlyCommandInAGuild() {
when(command.isGuildOnly()).thenReturn(true);
when(context.isFromGuild()).thenReturn(true);
assertTrue(precondition.check(context, command));
verify(context, never()).replyEphemeral(org.mockito.ArgumentMatchers.anyString());
verify(context, never()).deleteMessage();
}
@Test
void shouldBlockGuildOnlyCommandOutsideAGuild() {
when(command.isGuildOnly()).thenReturn(true);
when(context.isFromGuild()).thenReturn(false);
assertFalse(precondition.check(context, command));
verify(context).replyEphemeral("This command is only available in a guild");
verify(context).deleteMessage();
}
@Test
void shouldPassCommandThatIsNotGuildOnlyOutsideAGuild() {
when(command.isGuildOnly()).thenReturn(false);
when(context.isFromGuild()).thenReturn(false);
assertTrue(precondition.check(context, command));
verify(context, never()).replyEphemeral(org.mockito.ArgumentMatchers.anyString());
verify(context, never()).deleteMessage();
}
}
@@ -0,0 +1,142 @@
package me.bobert.netneutrality.core.command.precondition;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.Command;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.utils.UserFunction;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.util.EnumSet;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class PermissionPreconditionTest {
private static final String DENIED = "You are not powerful enough, scrub";
@Mock
private CommandContext context;
@Mock
private Command command;
@Mock
private Member member;
@Mock
private Role role;
@Mock
private MessageChannelUnion channel;
private final PermissionPrecondition precondition = new PermissionPrecondition();
@BeforeEach
void setup() {
UserFunction.bind(null);
when(command.getType()).thenReturn(Command.Type.CHAT);
when(context.canSkipPermissionCheck()).thenReturn(false);
when(context.getChannel()).thenReturn(channel);
when(context.getGuildUser()).thenReturn(member);
when(command.canBeExecutedFrom(channel)).thenReturn(true);
when(command.needAdmin()).thenReturn(false);
when(command.canBeExecutedBy(member)).thenReturn(true);
when(member.getRoles()).thenReturn(List.of());
}
@AfterEach
void tearDown() {
UserFunction.bind(null);
}
@Test
void shouldSkipTheCheckForMessageCommands() {
when(command.getType()).thenReturn(Command.Type.MESSAGE);
when(command.canBeExecutedFrom(channel)).thenReturn(false);
when(command.canBeExecutedBy(member)).thenReturn(false);
assertTrue(precondition.check(context, command));
verify(context, never()).replyEphemeral(anyString());
}
@Test
void shouldPassWhenThePermissionCheckCanBeSkipped() {
when(context.canSkipPermissionCheck()).thenReturn(true);
when(command.canBeExecutedFrom(channel)).thenReturn(false);
when(command.canBeExecutedBy(member)).thenReturn(false);
assertTrue(precondition.check(context, command));
verify(context, never()).replyEphemeral(anyString());
}
@Test
void shouldBlockWhenTheCommandCannotRunInTheChannel() {
when(command.canBeExecutedFrom(channel)).thenReturn(false);
assertFalse(precondition.check(context, command));
verify(context).replyEphemeral("This command cannot be executed in the current channel");
}
@Test
void shouldBlockAnAdminCommandForANonAdmin() {
when(command.needAdmin()).thenReturn(true);
assertFalse(precondition.check(context, command));
verify(context).replyEphemeral(DENIED);
}
@Test
void shouldPassAnAdminCommandForAnAdmin() {
when(command.needAdmin()).thenReturn(true);
when(role.getPermissions()).thenReturn(EnumSet.of(Permission.ADMINISTRATOR));
when(member.getRoles()).thenReturn(List.of(role));
assertTrue(precondition.check(context, command));
verify(context, never()).replyEphemeral(anyString());
}
@Test
void shouldBlockWhenTheMemberCannotExecuteTheCommand() {
when(command.canBeExecutedBy(member)).thenReturn(false);
assertFalse(precondition.check(context, command));
verify(context).replyEphemeral(DENIED);
}
@Test
void shouldPassWhenEveryCheckSucceeds() {
assertTrue(precondition.check(context, command));
verify(context, never()).replyEphemeral(anyString());
}
}
@@ -0,0 +1,66 @@
package me.bobert.netneutrality.core.command.precondition;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.Command;
import me.bobert.netneutrality.core.context.CommandContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SlashCommandOnlyPreconditionTest {
@Mock
private CommandContext context;
@Mock
private Command command;
private final SlashCommandOnlyPrecondition precondition = new SlashCommandOnlyPrecondition();
@Test
void shouldPassSlashOnlyCommandFromAnInteraction() {
when(command.isSlashCommandOnly()).thenReturn(true);
when(context.isInteraction()).thenReturn(true);
assertTrue(precondition.check(context, command));
verify(context, never()).replyEphemeral(anyString());
}
@Test
void shouldBlockSlashOnlyCommandSentAsAMessage() {
when(command.isSlashCommandOnly()).thenReturn(true);
when(context.isInteraction()).thenReturn(false);
assertFalse(precondition.check(context, command));
verify(context).replyEphemeral("This command is only available as a slash command");
}
@Test
void shouldPassCommandThatIsNotSlashOnlyAsAMessage() {
when(command.isSlashCommandOnly()).thenReturn(false);
when(context.isInteraction()).thenReturn(false);
assertTrue(precondition.check(context, command));
verify(context, never()).replyEphemeral(anyString());
}
}
@@ -0,0 +1,111 @@
package me.bobert.netneutrality.events;
import com.sun.net.httpserver.HttpServer;
import okhttp3.OkHttpClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class WebhookDispatcherTest {
private record Received(String signature, String body) {
}
private HttpServer server;
private WebhookDispatcher dispatcher;
private final BlockingQueue<Received> received = new LinkedBlockingQueue<>();
private final AtomicInteger failuresLeft = new AtomicInteger();
@BeforeEach
void setup() throws Exception {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/hook", exchange -> {
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
received.add(new Received(exchange.getRequestHeaders().getFirst("X-Signature"), body));
int code = failuresLeft.getAndUpdate(n -> Math.max(0, n - 1)) > 0 ? 500 : 200;
exchange.sendResponseHeaders(code, -1);
exchange.close();
});
server.start();
dispatcher = new WebhookDispatcher("http://127.0.0.1:" + server.getAddress().getPort() + "/hook",
"s3cret", new OkHttpClient(), 16, 2, 10);
dispatcher.start();
}
@AfterEach
void teardown() {
dispatcher.stop();
server.stop(0);
}
private Received next() throws InterruptedException {
return received.poll(5, TimeUnit.SECONDS);
}
@Test
void shouldSignAndDeliverEvent() throws Exception {
dispatcher.publish(MusicEvent.of(MusicEvent.Type.TRACK_START, "1", null));
Received r = next();
assertNotNull(r);
assertEquals("sha256=" + dispatcher.sign(r.body().getBytes(StandardCharsets.UTF_8)), r.signature());
assertTrue(r.body().contains("TRACK_START"));
}
@Test
void shouldRetryThenSucceed() throws Exception {
failuresLeft.set(1);
dispatcher.publish(MusicEvent.of(MusicEvent.Type.QUEUE_UPDATED, "1", null));
assertNotNull(next());
assertNotNull(next());
assertNull(received.poll(300, TimeUnit.MILLISECONDS));
}
@Test
void shouldDropAfterRetriesExhausted() throws Exception {
failuresLeft.set(100);
dispatcher.publish(MusicEvent.of(MusicEvent.Type.VOLUME_CHANGED, "1", null));
for (int i = 0; i < 3; i++) {
assertNotNull(next());
}
assertNull(received.poll(300, TimeUnit.MILLISECONDS));
}
@Test
void shouldAssignPerGuildSequenceInOrder() throws Exception {
dispatcher.publish(MusicEvent.of(MusicEvent.Type.TRACK_START, "1", null));
dispatcher.publish(MusicEvent.of(MusicEvent.Type.TRACK_END, "1", null));
dispatcher.publish(MusicEvent.of(MusicEvent.Type.TRACK_START, "2", null));
List<String> expected = List.of("\"seq\":1", "\"seq\":2", "\"seq\":1");
for (String seq : expected) {
Received r = next();
assertNotNull(r);
assertTrue(r.body().contains(seq), r.body());
}
}
@Test
void noopPublisherShouldMakeNoCalls() throws Exception {
NoopEventPublisher.INSTANCE.publish(MusicEvent.of(MusicEvent.Type.TRACK_START, "1", null));
assertNull(received.poll(200, TimeUnit.MILLISECONDS));
}
}
@@ -0,0 +1,34 @@
package me.bobert.netneutrality.hooks;
import me.bobert.netneutrality.core.command.CommandRegistry;
import me.bobert.netneutrality.core.tasks.TaskManager;
import net.dv8tion.jda.api.events.Event;
import net.dv8tion.jda.api.events.session.ReadyEvent;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
class EventsHandlerTest {
private final CommandRegistry commands = mock(CommandRegistry.class);
private final TaskManager tasks = mock(TaskManager.class);
@Test
void multipleHandlersRunAndFailureIsIsolated() {
List<String> calls = new ArrayList<>();
EventsHandler dispatcher = new EventsHandler(List.of(), commands, tasks);
dispatcher.addGenericListener(ReadyEvent.class, e -> {
calls.add("first");
throw new IllegalStateException("boom");
});
dispatcher.addGenericListener(ReadyEvent.class, e -> calls.add("second"));
dispatcher.addGenericListener(Event.class, e -> calls.add("super"));
dispatcher.onEvent(mock(ReadyEvent.class));
assertEquals(List.of("first", "second", "super"), calls);
}
}
@@ -12,6 +12,7 @@ import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildSettingService;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.utils.SettingsProvider;
import me.bobert.netneutrality.shard.ShardProvider;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Guild;
@@ -48,6 +49,7 @@ class WebApiTest {
private WebApi webApi;
private int port;
private GuildSettingService settingService;
private ShardProvider shards;
private final HttpClient http = HttpClient.newHttpClient();
@BeforeEach
@@ -79,13 +81,14 @@ class WebApiTest {
when(failing.submit()).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("unknown member")));
when(guild.retrieveMemberById(any(String.class))).thenReturn(failing);
JDA jda = mock(JDA.class);
when(jda.getGuilds()).thenReturn(List.of(guild));
when(jda.getGuildById(GUILD_ID)).thenReturn(guild);
when(jda.getGuildById(any(String.class))).thenAnswer(inv ->
shards = mock(ShardProvider.class);
when(shards.isReady()).thenReturn(true);
when(shards.getGuilds()).thenReturn(List.of(guild));
when(shards.getGuildById(any(String.class))).thenAnswer(inv ->
GUILD_ID.equals(inv.getArgument(0)) ? guild : null);
when(jda.getStatus()).thenReturn(JDA.Status.CONNECTED);
when(jda.getGatewayPing()).thenReturn(12L);
when(shards.getWorstStatus()).thenReturn(JDA.Status.CONNECTED);
when(shards.getAveragePing()).thenReturn(12.0);
when(shards.getShardCount()).thenReturn(3);
settingService = mock(GuildSettingService.class);
when(settingService.snapshot()).thenReturn(Map.of("prefix", "ü!"));
@@ -107,7 +110,7 @@ class WebApiTest {
MusicService musicService = mock(MusicService.class);
webApi = new WebApi(settings, provider, musicService, () -> jda, () -> { });
webApi = new WebApi(settings, provider, musicService, shards, () -> { });
webApi.start();
}
@@ -157,6 +160,16 @@ class WebApiTest {
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("\"health\":\"CONNECTED\""));
assertTrue(response.body().contains("\"ping\":\"12\""));
assertTrue(response.body().contains("\"shard_count\":3"));
}
@Test
void routesReturnServiceUnavailableWhenShardsAreNotReady() throws Exception {
when(shards.isReady()).thenReturn(false);
assertEquals(503, get("/stats/bot").statusCode());
assertEquals(503, get("/actions/status").statusCode());
assertEquals(503, get("/guilds").statusCode());
}
@Test
@@ -256,6 +269,7 @@ class WebApiTest {
assertTrue(response.body().contains("\"guild_count\":1"));
assertTrue(response.body().contains("\"status\":\"CONNECTED\""));
assertTrue(response.body().contains("\"gateway_ping\":12"));
assertTrue(response.body().contains("\"shard_count\":3"));
}
@Test
@@ -0,0 +1,99 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.client.player.PlaylistLoaded;
import dev.arbjerg.lavalink.client.player.Track;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class AutoplayLoadHandlerTest {
private TrackScheduler scheduler;
private QueueManager queueManager;
private Track seed;
private Member requester;
private AutoplayLoadHandler handler;
@BeforeEach
void setUp() {
Guild guild = mock(Guild.class);
when(guild.getId()).thenReturn("guild1");
scheduler = mock(TrackScheduler.class);
queueManager = mock(QueueManager.class);
when(scheduler.getGuild()).thenReturn(guild);
when(scheduler.getQueueManager()).thenReturn(queueManager);
seed = QueueManagerTest.mockTrack("seed");
requester = mock(Member.class);
when(queueManager.peekHeadOfQueue()).thenReturn(new TrackInfo(seed, requester));
handler = new AutoplayLoadHandler(scheduler, seed);
}
private static PlaylistLoaded playlist(Track... tracks) {
PlaylistLoaded result = mock(PlaylistLoaded.class);
when(result.getTracks()).thenReturn(List.of(tracks));
return result;
}
@SuppressWarnings("unchecked")
@Test
public void queuesMixWithoutSeedAndClearsLoadingFlag() {
Track other = QueueManagerTest.mockTrack("other");
handler.onPlaylistLoaded(playlist(seed, other));
org.mockito.ArgumentCaptor<List<Track>> captor = org.mockito.ArgumentCaptor.forClass(List.class);
org.mockito.ArgumentCaptor<PlaylistInfo> info = org.mockito.ArgumentCaptor.forClass(PlaylistInfo.class);
verify(queueManager).queueAutoplay(captor.capture(), same(requester), info.capture());
assertEquals(1, captor.getValue().size());
assertSame(other, captor.getValue().get(0));
assertEquals("Autoplay: Title seed", info.getValue().name());
verify(scheduler).endAutoplayLoad();
}
@Test
public void emptyMixQueuesNothing() {
handler.onPlaylistLoaded(playlist(seed));
verify(queueManager, never()).queueAutoplay(any(), any(), any());
verify(scheduler).endAutoplayLoad();
}
@Test
public void noMatchesClearsLoadingFlag() {
handler.noMatches();
verify(queueManager, never()).queueAutoplay(any(), any(), any());
verify(scheduler).endAutoplayLoad();
}
@Test
public void loadFailedClearsLoadingFlag() {
dev.arbjerg.lavalink.client.player.LoadFailed failed = mock(dev.arbjerg.lavalink.client.player.LoadFailed.class);
dev.arbjerg.lavalink.client.player.TrackException exception = mock(dev.arbjerg.lavalink.client.player.TrackException.class);
when(failed.getException()).thenReturn(exception);
handler.loadFailed(failed);
verify(scheduler).endAutoplayLoad();
}
}
@@ -0,0 +1,187 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.client.player.Track;
import me.bobert.netneutrality.events.EventPublisher;
import me.bobert.netneutrality.events.MusicEvent;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
public class QueueManagerEventsTest {
private final List<MusicEvent> events = new ArrayList<>();
private TrackScheduler scheduler;
private QueueManager queueManager;
private Member member;
@BeforeEach
void setUp() {
Guild guild = mock(Guild.class);
when(guild.getId()).thenReturn("guild1");
scheduler = mock(TrackScheduler.class);
MusicManager musicManager = mock(MusicManager.class);
EventPublisher publisher = events::add;
when(scheduler.getGuild()).thenReturn(guild);
when(scheduler.getMusicManager()).thenReturn(musicManager);
when(scheduler.getCallback()).thenReturn(mock(MusicManagerCallback.class));
when(musicManager.getEvents()).thenReturn(publisher);
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(QueueManagerTest.mockTrack("t1"));
member = mock(Member.class);
when(member.getId()).thenReturn("u1");
queueManager = new QueueManager(scheduler);
}
private void fill(int count) {
for (int i = 1; i <= count; i++) {
Track track = QueueManagerTest.mockTrack("t" + i);
queueManager.queue(track, member);
}
events.clear();
}
@SuppressWarnings("unchecked")
private Map<String, Object> payload(MusicEvent event) {
return (Map<String, Object>) event.payload();
}
@SuppressWarnings("unchecked")
private List<Map<String, Object>> items(MusicEvent event) {
return (List<Map<String, Object>>) payload(event).get("items");
}
@Test
public void addPublishesAddedWithIndex() {
queueManager.queue(QueueManagerTest.mockTrack("t1"), member);
assertEquals(1, events.size());
MusicEvent event = events.get(0);
assertEquals(MusicEvent.Type.QUEUE_UPDATED, event.type());
assertEquals("guild1", event.guildId());
assertEquals(1, payload(event).get("size"));
assertEquals("added", payload(event).get("change"));
assertEquals(0, items(event).get(0).get("index"));
assertEquals("t1", items(event).get(0).get("identifier"));
assertEquals("u1", items(event).get(0).get("requester_id"));
}
@Test
public void removePublishesRemovedWithOriginalIndex() {
fill(3);
queueManager.removeFromQueue(3);
assertEquals(1, events.size());
assertEquals("removed", payload(events.get(0)).get("change"));
assertEquals(2, payload(events.get(0)).get("size"));
assertEquals(2, items(events.get(0)).get(0).get("index"));
assertEquals("t3", items(events.get(0)).get(0).get("identifier"));
}
@Test
public void movePublishesFromAndTo() {
fill(4);
queueManager.reorder(4, 2);
assertEquals(1, events.size());
assertEquals("moved", payload(events.get(0)).get("change"));
Map<String, Object> item = items(events.get(0)).get(0);
assertEquals(3, item.get("fromIndex"));
assertEquals(1, item.get("toIndex"));
assertEquals("t4", item.get("identifier"));
}
@Test
public void clearPublishesCleared() {
fill(2);
queueManager.clearQueue();
assertEquals(1, events.size());
assertEquals("cleared", payload(events.get(0)).get("change"));
assertEquals(0, payload(events.get(0)).get("size"));
assertTrue(items(events.get(0)).isEmpty());
}
@Test
public void shufflePublishesShuffledAndQueueShuffled() {
fill(4);
queueManager.shuffle();
assertEquals(2, events.size());
assertEquals(MusicEvent.Type.QUEUE_UPDATED, events.get(0).type());
assertEquals("shuffled", payload(events.get(0)).get("change"));
assertEquals(4, payload(events.get(0)).get("size"));
assertEquals(MusicEvent.Type.QUEUE_SHUFFLED, events.get(1).type());
assertEquals("guild1", events.get(1).guildId());
}
@Test
public void playlistPublishesSingleEventWithPlaylist() {
List<Track> tracks = List.of(QueueManagerTest.mockTrack("t1"), QueueManagerTest.mockTrack("t2"),
QueueManagerTest.mockTrack("t3"));
queueManager.queue(tracks, member, new PlaylistInfo("pl1", "My List", 3));
assertEquals(1, events.size());
Map<String, Object> payload = payload(events.get(0));
assertEquals("playlist_added", payload.get("change"));
assertEquals(3, payload.get("size"));
assertEquals(3, items(events.get(0)).size());
@SuppressWarnings("unchecked")
Map<String, Object> playlist = (Map<String, Object>) payload.get("playlist");
assertEquals("My List", playlist.get("name"));
assertEquals("pl1", playlist.get("identifier"));
assertEquals(3, playlist.get("track_count"));
assertNotNull(items(events.get(0)).get(0).get("playlist"));
}
@Test
public void playlistSkipsDuplicates() {
fill(1);
List<Track> tracks = List.of(QueueManagerTest.mockTrack("t1"), QueueManagerTest.mockTrack("t2"));
queueManager.queue(tracks, member, new PlaylistInfo(null, "L", 2));
assertEquals(1, events.size());
assertEquals(1, items(events.get(0)).size());
assertEquals("t2", items(events.get(0)).get(0).get("identifier"));
}
@Test
public void singleTrackHasNullPlaylist() {
queueManager.queue(QueueManagerTest.mockTrack("t1"), member);
assertNull(items(events.get(0)).get(0).get("playlist"));
assertFalse(payload(events.get(0)).containsKey("playlist"));
}
@Test
public void removeHeadPublishesRemovedAtIndexZero() {
fill(2);
queueManager.removeHeadOfQueue();
assertEquals("removed", payload(events.get(0)).get("change"));
assertEquals(0, items(events.get(0)).get(0).get("index"));
assertEquals(1, payload(events.get(0)).get("size"));
}
}
@@ -11,6 +11,7 @@ import net.dv8tion.jda.api.entities.Guild;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.*;
@@ -76,6 +77,149 @@ public class TrackSchedulerTest {
assertNull(scheduler.getCurrentTrack());
}
@Test
@SuppressWarnings("unchecked")
public void onTrackExceptionPublishesTrackError() {
java.util.List<me.bobert.netneutrality.events.MusicEvent> events = new java.util.ArrayList<>();
when(musicManager.getEvents()).thenReturn(events::add);
dev.arbjerg.lavalink.client.player.TrackException exception = mock(dev.arbjerg.lavalink.client.player.TrackException.class);
when(exception.getMessage()).thenReturn("boom");
scheduler.attachContext(mock(me.bobert.netneutrality.context.MusicChannelContext.class));
scheduler.onTrackException(QueueManagerTest.mockTrack("t1"), exception);
assertEquals(1, events.size());
assertEquals(me.bobert.netneutrality.events.MusicEvent.Type.TRACK_ERROR, events.get(0).type());
assertEquals("guild1", events.get(0).guildId());
java.util.Map<String, Object> payload = (java.util.Map<String, Object>) events.get(0).payload();
assertEquals("boom", payload.get("message"));
assertEquals("t1", ((java.util.Map<String, Object>) payload.get("track")).get("identifier"));
}
@Test
@SuppressWarnings("unchecked")
public void onTrackStuckPublishesTrackStuck() {
java.util.List<me.bobert.netneutrality.events.MusicEvent> events = new java.util.ArrayList<>();
when(musicManager.getEvents()).thenReturn(events::add);
scheduler.attachContext(mock(me.bobert.netneutrality.context.MusicChannelContext.class));
scheduler.onTrackStuck(QueueManagerTest.mockTrack("t1"), 10000L);
me.bobert.netneutrality.events.MusicEvent event = events.stream()
.filter(e -> e.type() == me.bobert.netneutrality.events.MusicEvent.Type.TRACK_STUCK)
.findFirst().orElseThrow();
assertEquals("guild1", event.guildId());
java.util.Map<String, Object> payload = (java.util.Map<String, Object>) event.payload();
assertEquals(10000L, payload.get("thresholdMs"));
assertEquals("t1", ((java.util.Map<String, Object>) payload.get("track")).get("identifier"));
}
@Test
@SuppressWarnings("unchecked")
public void onPlayerUpdateIsThrottledPerGuild() {
java.util.List<me.bobert.netneutrality.events.MusicEvent> events = new java.util.ArrayList<>();
when(musicManager.getEvents()).thenReturn(events::add);
scheduler.onTrackStart(QueueManagerTest.mockTrack("t1"));
events.clear();
scheduler.onPlayerUpdate(100L, 1500L, true, 20L, 1000L);
scheduler.onPlayerUpdate(101L, 1600L, true, 20L, 3000L);
scheduler.onPlayerUpdate(102L, 6500L, true, 20L, 6000L);
assertEquals(2, events.size());
assertEquals(me.bobert.netneutrality.events.MusicEvent.Type.PLAYER_POSITION, events.get(0).type());
java.util.Map<String, Object> payload = (java.util.Map<String, Object>) events.get(0).payload();
assertEquals(1500L, payload.get("position"));
assertEquals(100L, payload.get("timestamp"));
assertEquals(true, payload.get("connected"));
assertEquals(20L, payload.get("ping"));
}
@Test
public void onPlayerUpdateSkippedWhenNothingPlaying() {
java.util.List<me.bobert.netneutrality.events.MusicEvent> events = new java.util.ArrayList<>();
when(musicManager.getEvents()).thenReturn(events::add);
scheduler.onPlayerUpdate(100L, 1500L, true, 20L, 1000L);
assertEquals(0, events.size());
}
@Test
public void onTrackStartDoesNotLoadAutoplayImmediately() {
AutoplayService service = mock(AutoplayService.class);
TrackScheduler autoplay = new TrackScheduler(musicManager, service);
Track track = QueueManagerTest.mockTrack("t1");
when(track.getInfo().getLength()).thenReturn(200_000L);
autoplay.setAutoplayEnabled(true);
autoplay.onTrackStart(track);
autoplay.onPlayerUpdate(1L, 1_000L, true, 20L, 1000L);
verify(service, never()).loadFor(any(), any());
}
@Test
public void autoplayLoadsWhenLastTrackNearsItsEnd() {
AutoplayService service = mock(AutoplayService.class);
TrackScheduler autoplay = new TrackScheduler(musicManager, service);
Track track = QueueManagerTest.mockTrack("t1");
when(track.getInfo().getLength()).thenReturn(200_000L);
autoplay.setAutoplayEnabled(true);
autoplay.onTrackStart(track);
autoplay.onPlayerUpdate(1L, 175_000L, true, 20L, 1000L);
verify(service).loadFor(autoplay, track);
}
@Test
public void onTrackStartDoesNotLoadAutoplayWhenDisabled() {
AutoplayService service = mock(AutoplayService.class);
TrackScheduler autoplay = new TrackScheduler(musicManager, service);
autoplay.onTrackStart(QueueManagerTest.mockTrack("t1"));
verify(service, never()).loadFor(any(), any());
}
@Test
public void onTrackStartDoesNotLoadAutoplayWhenMoreTracksQueued() {
AutoplayService service = mock(AutoplayService.class);
TrackScheduler autoplay = new TrackScheduler(musicManager, service);
net.dv8tion.jda.api.entities.Member member = mock(net.dv8tion.jda.api.entities.Member.class);
Track t1 = QueueManagerTest.mockTrack("t1");
autoplay.getQueueManager().queue(t1, member);
autoplay.getQueueManager().queue(QueueManagerTest.mockTrack("t2"), member);
autoplay.setAutoplayEnabled(true);
clearInvocations(service);
autoplay.onTrackStart(t1);
autoplay.onPlayerUpdate(1L, 999_999L, true, 20L, 1000L);
verify(service, never()).loadFor(any(), any());
}
@Test
public void enablingAutoplayDoesNotLoadImmediately() {
AutoplayService service = mock(AutoplayService.class);
TrackScheduler autoplay = new TrackScheduler(musicManager, service);
autoplay.onTrackStart(QueueManagerTest.mockTrack("t1"));
autoplay.setAutoplayEnabled(true);
verify(service, never()).loadFor(any(), any());
}
@Test
public void autoplayLoadGuardAllowsOneLoadAtATime() {
assertEquals(true, scheduler.tryBeginAutoplayLoad());
assertEquals(false, scheduler.tryBeginAutoplayLoad());
scheduler.endAutoplayLoad();
assertEquals(true, scheduler.tryBeginAutoplayLoad());
}
@Test
public void getDjReturnsNullWhenNoContext() {
assertNull(scheduler.getDj());
@@ -0,0 +1,52 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.client.player.Track;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
public class YoutubeAutoplayProviderTest {
private final YoutubeAutoplayProvider provider = new YoutubeAutoplayProvider();
private static Track track(String id, String source, String uri) {
Track track = QueueManagerTest.mockTrack(id);
when(track.getInfo().getSourceName()).thenReturn(source);
when(track.getInfo().getUri()).thenReturn(uri);
return track;
}
@Test
public void youtubeTrackBuildsRadioMixUrl() {
Track track = track("abc", "youtube", "https://www.youtube.com/watch?v=abc");
assertTrue(provider.supports(track));
assertEquals(Optional.of("https://www.youtube.com/watch?v=abc&list=RDabc"), provider.radioIdentifier(track));
}
@Test
public void soundcloudTrackIsNotSupported() {
Track track = track("123", "soundcloud", "https://soundcloud.com/artist/song");
assertFalse(provider.supports(track));
assertEquals(Optional.empty(), provider.radioIdentifier(track));
}
@Test
public void fallsBackToVideoIdFromUri() {
Track track = track("ignored", "http", "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5");
assertEquals(Optional.of("https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=RDdQw4w9WgXcQ"),
provider.radioIdentifier(track));
}
}
@@ -0,0 +1,83 @@
package me.bobert.netneutrality.shard;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.sharding.ShardManager;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ShardServiceTest {
private static JDA shard(JDA.Status status) {
JDA jda = mock(JDA.class);
when(jda.getStatus()).thenReturn(status);
return jda;
}
@Test
void aggregatesAcrossShards() {
ShardManager manager = mock(ShardManager.class);
Guild a = mock(Guild.class);
Guild b = mock(Guild.class);
JDA shard0 = shard(JDA.Status.CONNECTED);
JDA shard1 = shard(JDA.Status.RECONNECT_QUEUED);
when(manager.getGuilds()).thenReturn(List.of(a, b));
when(manager.getGuildById("2")).thenReturn(b);
when(manager.getShardsTotal()).thenReturn(2);
when(manager.getAverageGatewayPing()).thenReturn(42.5);
when(manager.getShards()).thenReturn(List.of(shard0, shard1));
when(manager.getShardById(0)).thenReturn(shard0);
ShardService service = new ShardService(manager);
assertTrue(service.isReady());
assertEquals(2, service.getGuilds().size());
assertSame(b, service.getGuildById("2"));
assertNull(service.getGuildById("missing"));
assertEquals(2, service.getShardCount());
assertEquals(42.5, service.getAveragePing());
assertEquals(JDA.Status.RECONNECT_QUEUED, service.getWorstStatus());
assertSame(shard0, service.getAnyShard());
}
@Test
void allConnectedReportsConnected() {
ShardManager manager = mock(ShardManager.class);
JDA connected = shard(JDA.Status.CONNECTED);
when(manager.getShards()).thenReturn(List.of(connected));
assertEquals(JDA.Status.CONNECTED, new ShardService(manager).getWorstStatus());
}
@Test
void notReadyBeforeStartAndAfterStop() {
ShardManager manager = mock(ShardManager.class);
ShardService service = new ShardService(manager);
service.stop();
assertFalse(service.isReady());
assertNull(service.getAnyShard());
verify(manager).shutdown();
}
@Test
void parsesShardTotal() {
assertEquals(-1, ShardService.parseShardTotal(null));
assertEquals(-1, ShardService.parseShardTotal(""));
assertEquals(-1, ShardService.parseShardTotal("abc"));
assertEquals(-1, ShardService.parseShardTotal("0"));
assertEquals(1, ShardService.parseShardTotal("1"));
assertEquals(4, ShardService.parseShardTotal(" 4 "));
}
}