Compare commits
3
Commits
e989bff9e3
...
d43e032c37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d43e032c37 | ||
|
|
d97c4c6da2 | ||
|
|
764adb6cbc |
No files matched your search
@@ -147,6 +147,7 @@ Concrete commands live under `me.bobert.netneutrality.command.*`, grouped by fea
|
|||||||
| `JoinCommand` / `LeaveCommand` | `music` | Slash | Join / leave the voice channel. |
|
| `JoinCommand` / `LeaveCommand` | `music` | Slash | Join / leave the voice channel. |
|
||||||
| `CurrentlyPlayingCommand` | `music` | Slash | Show the currently playing track. |
|
| `CurrentlyPlayingCommand` | `music` | Slash | Show the currently playing track. |
|
||||||
| `ResumeCommand` / `StopCommand` / `VolumeCommand` | `music` | Slash | Playback controls. |
|
| `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. |
|
| `BitconnectCommand` | `music` | Slash | Novelty music command. |
|
||||||
| `CreateEmojiReaction` / `ModifyEmojiReaction` | `react` | Message menu | Create/modify emoji-role mappings from a message. |
|
| `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). |
|
| `DatabaseCommand`, `PurgeCommand`, `ShutdownCommand`, `JoinClanCommand`, `TestCommand`, `SlashCommandTest`, `TrackStuckTest` | `custom` | Mixed | Owner/debug and personal utilities (the `custom` package is gitignored per-deployment). |
|
||||||
|
|||||||
+1
-1
@@ -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
|
All music commands extend [`MusicCommand`](commands.md#base-implementations-corecommandimpl) (guild
|
||||||
only + music role/channel enforced): `PlayCommand`, `SkipCommand`/`VoteSkipCommand`,
|
only + music role/channel enforced): `PlayCommand`, `SkipCommand`/`VoteSkipCommand`,
|
||||||
`QueueCommand`/`ViewQueueCommand`, `ClearQueueCommand`, `ShuffleCommand`, `JoinCommand`/`LeaveCommand`,
|
`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).
|
[commands.md](commands.md#command-catalog).
|
||||||
@@ -70,6 +70,7 @@ music channel when a context is attached.
|
|||||||
| `PATCH /music/{guildId}/queue/reorder` | Move a track (`fromPosition` → `toPosition`). |
|
| `PATCH /music/{guildId}/queue/reorder` | Move a track (`fromPosition` → `toPosition`). |
|
||||||
| `POST /music/{guildId}/skip`, `/stop`, `/pause`, `/resume`, `/shuffle`, `/leave` | Playback controls. |
|
| `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}/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`. |
|
| `PUT /music/{guildId}/seek` | Seek to `positionMs`. |
|
||||||
| `GET /music/{guildId}/vote-skip` | Vote-skip status. |
|
| `GET /music/{guildId}/vote-skip` | Vote-skip status. |
|
||||||
| `POST /music/{guildId}/vote-skip/{userId}` | Cast a vote on behalf of a member. |
|
| `POST /music/{guildId}/vote-skip/{userId}` | Cast a vote on behalf of a member. |
|
||||||
|
|||||||
@@ -23,7 +23,15 @@ import me.bobert.netneutrality.db.service.GuildServiceProviderImpl;
|
|||||||
import me.bobert.netneutrality.music.MusicService;
|
import me.bobert.netneutrality.music.MusicService;
|
||||||
import me.bobert.netneutrality.core.command.CommandRegistry;
|
import me.bobert.netneutrality.core.command.CommandRegistry;
|
||||||
import me.bobert.netneutrality.core.tasks.TaskManager;
|
import me.bobert.netneutrality.core.tasks.TaskManager;
|
||||||
|
import me.bobert.netneutrality.hooks.EventListenerHandler;
|
||||||
import me.bobert.netneutrality.hooks.EventsHandler;
|
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.http.server.WebApi;
|
||||||
import me.bobert.netneutrality.music.MusicServiceImpl;
|
import me.bobert.netneutrality.music.MusicServiceImpl;
|
||||||
import me.bobert.netneutrality.core.thread.ExecutorProvider;
|
import me.bobert.netneutrality.core.thread.ExecutorProvider;
|
||||||
@@ -150,17 +158,22 @@ public class App {
|
|||||||
events = context.register(new WebhookDispatcher(webhookUrl, settings.get("NETNEUTRALITY_WEBHOOK_SECRET")));
|
events = context.register(new WebhookDispatcher(webhookUrl, settings.get("NETNEUTRALITY_WEBHOOK_SECRET")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Music sources (priority 40): initialise the shared player manager; disconnect on shutdown.
|
// 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));
|
MusicService music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors, events));
|
||||||
BotState.bind(guildServices, music);
|
BotState.bind(guildServices, music);
|
||||||
|
|
||||||
// JDA shards (priority 60); the events handler is attached to every shard before it connects.
|
// 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(
|
ShardService shards = context.register(new ShardService(
|
||||||
settings, music, new EventsHandler(settings, guildServices, commands, tasks, events)));
|
settings, music, new EventsHandler(handlers, commands, tasks)));
|
||||||
|
|
||||||
// Register global slash commands once (shared by all shards) and set the presence.
|
// Slash commands are registered from EventsHandler on ReadyEvent; set the presence here.
|
||||||
context.register(lifecycle(65, () -> {
|
context.register(lifecycle(65, () -> {
|
||||||
commands.registerSlashCommands(shards.getAnyShard());
|
|
||||||
shards.setActivity(Activity.watching("you"));
|
shards.setActivity(Activity.watching("you"));
|
||||||
}, () -> { }));
|
}, () -> { }));
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,7 +93,7 @@ public class WebhookDispatcher implements EventPublisher, Lifecycle {
|
|||||||
if (event == null) {
|
if (event == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
long seq = sequences.computeIfAbsent(event.guildId(), k -> new AtomicLong()).incrementAndGet();
|
long seq = sequences.computeIfAbsent(event.guildId(), _ -> new AtomicLong()).incrementAndGet();
|
||||||
MusicEvent sequenced = new MusicEvent(event.type(), event.guildId(), event.timestamp(), seq, event.payload());
|
MusicEvent sequenced = new MusicEvent(event.type(), event.guildId(), event.timestamp(), seq, event.payload());
|
||||||
while (!queue.offerLast(sequenced)) {
|
while (!queue.offerLast(sequenced)) {
|
||||||
MusicEvent dropped = queue.pollFirst();
|
MusicEvent dropped = queue.pollFirst();
|
||||||
|
|||||||
@@ -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,218 +6,90 @@ package me.bobert.netneutrality.hooks;
|
|||||||
* https://discord.gg/qUa6zNFZsd
|
* 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.CommandProcessor;
|
||||||
|
import me.bobert.netneutrality.core.command.CommandRegistry;
|
||||||
import me.bobert.netneutrality.core.context.CommandContext;
|
import me.bobert.netneutrality.core.context.CommandContext;
|
||||||
import me.bobert.netneutrality.core.context.Context;
|
import me.bobert.netneutrality.core.context.Context;
|
||||||
import me.bobert.netneutrality.core.context.TaskContext;
|
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.core.tasks.TaskManager;
|
||||||
import me.bobert.netneutrality.db.GSettings;
|
|
||||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
|
||||||
import net.dv8tion.jda.api.entities.GuildVoiceState;
|
|
||||||
import net.dv8tion.jda.api.entities.Message;
|
|
||||||
import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion;
|
|
||||||
import net.dv8tion.jda.api.events.Event;
|
import net.dv8tion.jda.api.events.Event;
|
||||||
import net.dv8tion.jda.api.events.GenericEvent;
|
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.voice.GuildVoiceUpdateEvent;
|
|
||||||
import me.bobert.netneutrality.events.EventPublisher;
|
|
||||||
import me.bobert.netneutrality.events.MusicEvent;
|
|
||||||
import me.bobert.netneutrality.events.NoopEventPublisher;
|
|
||||||
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 net.dv8tion.jda.api.hooks.EventListener;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
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.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.function.Function;
|
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 {
|
public class EventsHandler implements EventListener {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(EventsHandler.class);
|
private static final Logger logger = LoggerFactory.getLogger(EventsHandler.class);
|
||||||
|
|
||||||
private final Map<Class<? extends Event>, Function<? extends Event, Context>> listeners = new ConcurrentHashMap<>();
|
private final Map<Class<?>, List<EventListenerHandler<?>>> handlers = new ConcurrentHashMap<>();
|
||||||
private final SettingsProvider settings;
|
|
||||||
private final GuildServiceProvider guildServices;
|
|
||||||
private final CommandRegistry commands;
|
private final CommandRegistry commands;
|
||||||
private final TaskManager taskManager;
|
private final TaskManager taskManager;
|
||||||
private final EventPublisher events;
|
|
||||||
|
|
||||||
public EventsHandler(
|
public EventsHandler(
|
||||||
SettingsProvider settings,
|
List<EventListenerHandler<?>> handlers,
|
||||||
GuildServiceProvider guildServices,
|
|
||||||
CommandRegistry commands,
|
CommandRegistry commands,
|
||||||
TaskManager taskManager
|
TaskManager taskManager
|
||||||
) {
|
) {
|
||||||
this(settings, guildServices, commands, taskManager, NoopEventPublisher.INSTANCE);
|
|
||||||
}
|
|
||||||
|
|
||||||
public EventsHandler(
|
|
||||||
SettingsProvider settings,
|
|
||||||
GuildServiceProvider guildServices,
|
|
||||||
CommandRegistry commands,
|
|
||||||
TaskManager taskManager,
|
|
||||||
EventPublisher events
|
|
||||||
) {
|
|
||||||
this.events = Objects.requireNonNull(events, "events");
|
|
||||||
this.settings = Objects.requireNonNull(settings, "settings");
|
|
||||||
this.guildServices = Objects.requireNonNull(guildServices, "guildServices");
|
|
||||||
this.commands = Objects.requireNonNull(commands, "commands");
|
this.commands = Objects.requireNonNull(commands, "commands");
|
||||||
this.taskManager = Objects.requireNonNull(taskManager, "taskManager");
|
this.taskManager = Objects.requireNonNull(taskManager, "taskManager");
|
||||||
registerListeners();
|
Objects.requireNonNull(handlers, "handlers").forEach(this::register);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void registerListeners() {
|
public void register(EventListenerHandler<?> handler) {
|
||||||
addListener(MessageReceivedEvent.class, event -> {
|
Class<?> type = Objects.requireNonNull(handler, "handler").eventType();
|
||||||
if (event.getAuthor().isBot()) {
|
logger.info("Registering handler {} for {}", handler.getClass().getSimpleName(), type.getSimpleName());
|
||||||
return null;
|
handlers.computeIfAbsent(type, k -> new CopyOnWriteArrayList<>()).add(handler);
|
||||||
}
|
|
||||||
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(GuildVoiceUpdateEvent.class, this::onVoiceUpdate);
|
|
||||||
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()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publishes connect/disconnect events for the bot itself and for members entering or leaving
|
* Register a listener for an event associated with a {@link Context}.
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
public <T extends Event> void addListener(Class<T> eventClass, Function<? super T, Context> function) {
|
||||||
logger.info("Registering listener for {}", eventClass.getSimpleName());
|
register(new ContextEventHandler<T>() {
|
||||||
listeners.put(eventClass, (Function<? extends Event, Context>) function);
|
@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
|
* Adds a side-effect listener for a specified event type.
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
public <T extends Event> void addGenericListener(Class<T> eventClass, Consumer<T> consumer) {
|
public <T extends Event> void addGenericListener(Class<T> eventClass, Consumer<T> consumer) {
|
||||||
addListener(eventClass, t -> {
|
register(new EventListenerHandler<T>() {
|
||||||
consumer.accept(t);
|
@Override
|
||||||
return null;
|
public Class<T> eventType() {
|
||||||
|
return eventClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle(T event) {
|
||||||
|
consumer.accept(event);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,34 +100,48 @@ public class EventsHandler implements EventListener {
|
|||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private void handleEvent(Event event) {
|
private void handleEvent(Event event) {
|
||||||
Class<?> eventClass = event.getClass();
|
for (Class<?> type : hierarchy(event.getClass())) {
|
||||||
|
List<EventListenerHandler<?>> matching = handlers.get(type);
|
||||||
while (eventClass != null) {
|
if (matching == null) {
|
||||||
if (listeners.containsKey(eventClass)) {
|
continue;
|
||||||
Function<Event, Context> function = (Function<Event, Context>) listeners.get(eventClass);
|
}
|
||||||
Context context = function.apply(event);
|
for (EventListenerHandler<?> raw : matching) {
|
||||||
|
try {
|
||||||
if (context instanceof CommandContext commandContext) {
|
EventListenerHandler<Event> handler = (EventListenerHandler<Event>) raw;
|
||||||
handleCommand(commandContext);
|
handler.handle(event);
|
||||||
}
|
if (handler instanceof ContextEventHandler<Event> contextHandler) {
|
||||||
|
route(contextHandler.toContext(event));
|
||||||
if (context instanceof TaskContext taskContext) {
|
}
|
||||||
handleTask(taskContext);
|
} catch (Exception e) {
|
||||||
}
|
logger.error("Handler {} failed for {}", raw.getClass().getName(), event.getClass().getSimpleName(), e);
|
||||||
|
}
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
eventClass = eventClass.getSuperclass();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleCommand(CommandContext context) {
|
private void route(Context context) {
|
||||||
CommandProcessor processor = new CommandProcessor(context, commands, taskManager);
|
if (context instanceof CommandContext commandContext) {
|
||||||
processor.dispatch();
|
new CommandProcessor(commandContext, commands, taskManager).dispatch();
|
||||||
|
}
|
||||||
|
if (context instanceof TaskContext taskContext) {
|
||||||
|
taskManager.handle(taskContext);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleTask(TaskContext context) {
|
private static Set<Class<?>> hierarchy(Class<?> start) {
|
||||||
taskManager.handle(context);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ import io.javalin.openapi.OpenApiContent;
|
|||||||
import io.javalin.openapi.OpenApiParam;
|
import io.javalin.openapi.OpenApiParam;
|
||||||
import io.javalin.openapi.OpenApiRequestBody;
|
import io.javalin.openapi.OpenApiRequestBody;
|
||||||
import io.javalin.openapi.OpenApiResponse;
|
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.CommandResultResponse;
|
||||||
import me.bobert.netneutrality.http.server.dto.DeleteQueueResponse;
|
import me.bobert.netneutrality.http.server.dto.DeleteQueueResponse;
|
||||||
import me.bobert.netneutrality.http.server.dto.DjStatusResponse;
|
import me.bobert.netneutrality.http.server.dto.DjStatusResponse;
|
||||||
@@ -79,6 +81,8 @@ public final class MusicRoutes {
|
|||||||
routes.post("/music/{guildId}/resume", ctx -> resumePlayback(shards, musicService, ctx));
|
routes.post("/music/{guildId}/resume", ctx -> resumePlayback(shards, musicService, ctx));
|
||||||
routes.get("/music/{guildId}/volume", ctx -> getVolume(shards, musicService, ctx));
|
routes.get("/music/{guildId}/volume", ctx -> getVolume(shards, musicService, ctx));
|
||||||
routes.put("/music/{guildId}/volume", ctx -> volume(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.put("/music/{guildId}/seek", ctx -> seek(shards, musicService, ctx));
|
||||||
routes.get("/music/{guildId}/vote-skip", ctx -> voteSkipStatus(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.post("/music/{guildId}/vote-skip/{userId}", ctx -> voteSkip(shards, musicService, ctx));
|
||||||
@@ -284,6 +288,46 @@ public final class MusicRoutes {
|
|||||||
return volume;
|
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(
|
@OpenApi(
|
||||||
path = "/music/{guildId}/seek",
|
path = "/music/{guildId}/seek",
|
||||||
methods = HttpMethod.PUT,
|
methods = HttpMethod.PUT,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,7 +80,9 @@ public class MusicServiceImpl implements MusicService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int priority() {
|
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
|
@Override
|
||||||
|
|||||||
@@ -217,6 +217,42 @@ public class QueueManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
public void queue(Track track, Member member) {
|
||||||
TrackInfo info = new TrackInfo(track, member);
|
TrackInfo info = new TrackInfo(track, member);
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import net.dv8tion.jda.api.entities.Member;
|
|||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sink for the track lifecycle events emitted by the Lavalink node.
|
* Sink for the track lifecycle events emitted by the Lavalink node.
|
||||||
*
|
*
|
||||||
@@ -35,8 +37,16 @@ public class TrackScheduler {
|
|||||||
private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler;
|
private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler;
|
||||||
private static final long POSITION_THROTTLE_MS = 5000;
|
private static final long POSITION_THROTTLE_MS = 5000;
|
||||||
private volatile long lastPositionSent = Long.MIN_VALUE;
|
private volatile long lastPositionSent = Long.MIN_VALUE;
|
||||||
|
private final AutoplayService autoplayService;
|
||||||
|
private volatile boolean autoplayEnabled;
|
||||||
|
private final AtomicBoolean autoplayLoading = new AtomicBoolean();
|
||||||
|
|
||||||
public TrackScheduler(MusicManager manager) {
|
public TrackScheduler(MusicManager manager) {
|
||||||
|
this(manager, new AutoplayService());
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrackScheduler(MusicManager manager, AutoplayService autoplayService) {
|
||||||
|
this.autoplayService = autoplayService;
|
||||||
this.manager = manager;
|
this.manager = manager;
|
||||||
this.callback = manager;
|
this.callback = manager;
|
||||||
this.guild = manager.getGuild();
|
this.guild = manager.getGuild();
|
||||||
@@ -76,6 +86,36 @@ public class TrackScheduler {
|
|||||||
this.currentTrack = track;
|
this.currentTrack = track;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isAutoplayEnabled() {
|
||||||
|
return autoplayEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables or disables autoplay. Enabling it while the last track of the queue is already
|
||||||
|
* playing loads its recommendations immediately.
|
||||||
|
*/
|
||||||
|
public void setAutoplayEnabled(boolean enabled) {
|
||||||
|
this.autoplayEnabled = enabled;
|
||||||
|
if (enabled) {
|
||||||
|
maybeAutoplay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean tryBeginAutoplayLoad() {
|
||||||
|
return autoplayLoading.compareAndSet(false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void endAutoplayLoad() {
|
||||||
|
autoplayLoading.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void maybeAutoplay() {
|
||||||
|
Track track = currentTrack;
|
||||||
|
if (autoplayEnabled && track != null && queueManager.getQueueSize() <= 1) {
|
||||||
|
autoplayService.loadFor(this, track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Member getDj() {
|
public Member getDj() {
|
||||||
Context ctx = context;
|
Context ctx = context;
|
||||||
if (ctx != null) {
|
if (ctx != null) {
|
||||||
@@ -106,6 +146,7 @@ public class TrackScheduler {
|
|||||||
currentTrack = track;
|
currentTrack = track;
|
||||||
stuckTrackRecoveryHandler.reset();
|
stuckTrackRecoveryHandler.reset();
|
||||||
publish(MusicEvent.Type.TRACK_START, track, null);
|
publish(MusicEvent.Type.TRACK_START, track, null);
|
||||||
|
maybeAutoplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void onTrackEnd(Track track, TrackEndEvent.AudioTrackEndReason endReason) {
|
public void onTrackEnd(Track track, TrackEndEvent.AudioTrackEndReason endReason) {
|
||||||
@@ -168,7 +209,7 @@ public class TrackScheduler {
|
|||||||
java.util.Map<String, Object> payload = new java.util.HashMap<>();
|
java.util.Map<String, Object> payload = new java.util.HashMap<>();
|
||||||
payload.put("track", trackPayload(track));
|
payload.put("track", trackPayload(track));
|
||||||
payload.put("message", exception.getMessage());
|
payload.put("message", exception.getMessage());
|
||||||
payload.put("severity", exception.getSeverity() == null ? null : exception.getSeverity().name());
|
payload.put("severity", exception.getSeverity() != null ? exception.getSeverity().name() : null);
|
||||||
manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.TRACK_ERROR, guild.getId(), payload));
|
manager.getEvents().publish(MusicEvent.of(MusicEvent.Type.TRACK_ERROR, guild.getId(), payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,6 +146,64 @@ public class TrackSchedulerTest {
|
|||||||
assertEquals(0, events.size());
|
assertEquals(0, events.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void onTrackStartLoadsAutoplayWhenEnabledAndLastTrack() {
|
||||||
|
AutoplayService service = mock(AutoplayService.class);
|
||||||
|
TrackScheduler autoplay = new TrackScheduler(musicManager, service);
|
||||||
|
Track track = QueueManagerTest.mockTrack("t1");
|
||||||
|
autoplay.setAutoplayEnabled(true);
|
||||||
|
|
||||||
|
autoplay.onTrackStart(track);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
verify(service, never()).loadFor(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void enablingAutoplayWhileLastTrackPlayingTriggersLoad() {
|
||||||
|
AutoplayService service = mock(AutoplayService.class);
|
||||||
|
TrackScheduler autoplay = new TrackScheduler(musicManager, service);
|
||||||
|
Track track = QueueManagerTest.mockTrack("t1");
|
||||||
|
autoplay.onTrackStart(track);
|
||||||
|
|
||||||
|
autoplay.setAutoplayEnabled(true);
|
||||||
|
|
||||||
|
verify(service).loadFor(autoplay, track);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void autoplayLoadGuardAllowsOneLoadAtATime() {
|
||||||
|
assertEquals(true, scheduler.tryBeginAutoplayLoad());
|
||||||
|
assertEquals(false, scheduler.tryBeginAutoplayLoad());
|
||||||
|
scheduler.endAutoplayLoad();
|
||||||
|
assertEquals(true, scheduler.tryBeginAutoplayLoad());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void getDjReturnsNullWhenNoContext() {
|
public void getDjReturnsNullWhenNoContext() {
|
||||||
assertNull(scheduler.getDj());
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user