From 764adb6cbcd17810fea4c8592caf00c8322c58f7 Mon Sep 17 00:00:00 2001 From: anthorob Date: Fri, 30 Oct 2026 22:31:25 -0400 Subject: [PATCH] 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. --- .../java/me/bobert/netneutrality/App.java | 21 +- .../events/WebhookDispatcher.java | 2 +- .../hooks/ContextEventHandler.java | 22 ++ .../hooks/EventListenerHandler.java | 18 ++ .../netneutrality/hooks/EventsHandler.java | 272 +++++------------- .../hooks/GuildLifecycleHandler.java | 113 ++++++++ .../hooks/InteractionHandlers.java | 60 ++++ .../hooks/MessageReceivedHandler.java | 57 ++++ .../netneutrality/hooks/ReadyHandler.java | 36 +++ .../hooks/VoiceEventPublisher.java | 74 +++++ .../netneutrality/music/MusicServiceImpl.java | 4 +- .../hooks/EventsHandlerTest.java | 34 +++ 12 files changed, 514 insertions(+), 199 deletions(-) create mode 100644 src/main/java/me/bobert/netneutrality/hooks/ContextEventHandler.java create mode 100644 src/main/java/me/bobert/netneutrality/hooks/EventListenerHandler.java create mode 100644 src/main/java/me/bobert/netneutrality/hooks/GuildLifecycleHandler.java create mode 100644 src/main/java/me/bobert/netneutrality/hooks/InteractionHandlers.java create mode 100644 src/main/java/me/bobert/netneutrality/hooks/MessageReceivedHandler.java create mode 100644 src/main/java/me/bobert/netneutrality/hooks/ReadyHandler.java create mode 100644 src/main/java/me/bobert/netneutrality/hooks/VoiceEventPublisher.java create mode 100644 src/test/java/me/bobert/netneutrality/hooks/EventsHandlerTest.java diff --git a/src/main/java/me/bobert/netneutrality/App.java b/src/main/java/me/bobert/netneutrality/App.java index 8c388e9..dab4d63 100644 --- a/src/main/java/me/bobert/netneutrality/App.java +++ b/src/main/java/me/bobert/netneutrality/App.java @@ -23,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; @@ -150,17 +158,22 @@ public class App { 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)); BotState.bind(guildServices, music); // JDA shards (priority 60); the events handler is attached to every shard before it connects. + List> 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(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, () -> { - commands.registerSlashCommands(shards.getAnyShard()); shards.setActivity(Activity.watching("you")); }, () -> { })); diff --git a/src/main/java/me/bobert/netneutrality/events/WebhookDispatcher.java b/src/main/java/me/bobert/netneutrality/events/WebhookDispatcher.java index 00e24fa..44f942d 100644 --- a/src/main/java/me/bobert/netneutrality/events/WebhookDispatcher.java +++ b/src/main/java/me/bobert/netneutrality/events/WebhookDispatcher.java @@ -93,7 +93,7 @@ public class WebhookDispatcher implements EventPublisher, Lifecycle { if (event == null) { 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()); while (!queue.offerLast(sequenced)) { MusicEvent dropped = queue.pollFirst(); diff --git a/src/main/java/me/bobert/netneutrality/hooks/ContextEventHandler.java b/src/main/java/me/bobert/netneutrality/hooks/ContextEventHandler.java new file mode 100644 index 0000000..8e44a4a --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/hooks/ContextEventHandler.java @@ -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 extends EventListenerHandler { + Context toContext(T event); + + @Override + default void handle(T event) { + } +} diff --git a/src/main/java/me/bobert/netneutrality/hooks/EventListenerHandler.java b/src/main/java/me/bobert/netneutrality/hooks/EventListenerHandler.java new file mode 100644 index 0000000..be58dd6 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/hooks/EventListenerHandler.java @@ -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 { + Class eventType(); + + void handle(T event); +} diff --git a/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java b/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java index 8f76e5c..ed0efc1 100644 --- a/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java +++ b/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java @@ -6,218 +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.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.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 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, Function> listeners = new ConcurrentHashMap<>(); - private final SettingsProvider settings; - private final GuildServiceProvider guildServices; + private final Map, List>> handlers = new ConcurrentHashMap<>(); private final CommandRegistry commands; private final TaskManager taskManager; - private final EventPublisher events; public EventsHandler( - SettingsProvider settings, - GuildServiceProvider guildServices, + List> handlers, CommandRegistry commands, 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.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(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())); + 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); } /** - * 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). + * Register a listener for an event associated with a {@link Context}. */ - 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 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 the type of the event that extends the {@link Event} class - */ - @SuppressWarnings("unchecked") public void addListener(Class eventClass, Function function) { - logger.info("Registering listener for {}", eventClass.getSimpleName()); - listeners.put(eventClass, (Function) function); + register(new ContextEventHandler() { + @Override + public Class 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 the type of the event that extends the {@link Event} class + * Adds a side-effect listener for a specified event type. */ public void addGenericListener(Class eventClass, Consumer consumer) { - addListener(eventClass, t -> { - consumer.accept(t); - return null; + register(new EventListenerHandler() { + @Override + public Class eventType() { + return eventClass; + } + + @Override + public void handle(T event) { + consumer.accept(event); + } }); } @@ -228,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 function = (Function) 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> matching = handlers.get(type); + if (matching == null) { + continue; + } + for (EventListenerHandler raw : matching) { + try { + EventListenerHandler handler = (EventListenerHandler) raw; + handler.handle(event); + if (handler instanceof ContextEventHandler 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> hierarchy(Class start) { + Set> result = new LinkedHashSet<>(); + Deque> 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; } } diff --git a/src/main/java/me/bobert/netneutrality/hooks/GuildLifecycleHandler.java b/src/main/java/me/bobert/netneutrality/hooks/GuildLifecycleHandler.java new file mode 100644 index 0000000..44420ef --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/hooks/GuildLifecycleHandler.java @@ -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> 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 { + @Override + public Class eventType() { + return GuildJoinEvent.class; + } + + @Override + public void handle(GuildJoinEvent event) { + onGuildJoin(event); + } + } + + private class Leave implements EventListenerHandler { + @Override + public Class eventType() { + return GuildLeaveEvent.class; + } + + @Override + public void handle(GuildLeaveEvent event) { + onGuildLeave(event.getGuild().getId()); + } + } + + private class NameUpdate implements EventListenerHandler { + @Override + public Class 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 { + @Override + public Class eventType() { + return GuildUpdateIconEvent.class; + } + + @Override + public void handle(GuildUpdateIconEvent event) { + guildServices.get(event.getGuild().getId()) + .updateMetadata(event.getGuild().getName(), event.getNewIconId()); + } + } +} diff --git a/src/main/java/me/bobert/netneutrality/hooks/InteractionHandlers.java b/src/main/java/me/bobert/netneutrality/hooks/InteractionHandlers.java new file mode 100644 index 0000000..e9bae1a --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/hooks/InteractionHandlers.java @@ -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> 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 ContextEventHandler of(Class type, Function mapper) { + return new ContextEventHandler<>() { + @Override + public Class eventType() { + return type; + } + + @Override + public Context toContext(T event) { + return mapper.apply(event); + } + }; + } +} diff --git a/src/main/java/me/bobert/netneutrality/hooks/MessageReceivedHandler.java b/src/main/java/me/bobert/netneutrality/hooks/MessageReceivedHandler.java new file mode 100644 index 0000000..3e3ce36 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/hooks/MessageReceivedHandler.java @@ -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 { + 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 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); + } +} diff --git a/src/main/java/me/bobert/netneutrality/hooks/ReadyHandler.java b/src/main/java/me/bobert/netneutrality/hooks/ReadyHandler.java new file mode 100644 index 0000000..5a4dcaf --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/hooks/ReadyHandler.java @@ -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 { + private final CommandRegistry commands; + + public ReadyHandler(CommandRegistry commands) { + this.commands = Objects.requireNonNull(commands, "commands"); + } + + @Override + public Class 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()); + } + } +} diff --git a/src/main/java/me/bobert/netneutrality/hooks/VoiceEventPublisher.java b/src/main/java/me/bobert/netneutrality/hooks/VoiceEventPublisher.java new file mode 100644 index 0000000..749038c --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/hooks/VoiceEventPublisher.java @@ -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 { + private final EventPublisher events; + + public VoiceEventPublisher(EventPublisher events) { + this.events = Objects.requireNonNull(events, "events"); + } + + @Override + public Class 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 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); + } +} diff --git a/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java b/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java index 6d8216d..f4132e1 100644 --- a/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java +++ b/src/main/java/me/bobert/netneutrality/music/MusicServiceImpl.java @@ -80,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 diff --git a/src/test/java/me/bobert/netneutrality/hooks/EventsHandlerTest.java b/src/test/java/me/bobert/netneutrality/hooks/EventsHandlerTest.java new file mode 100644 index 0000000..b0633bc --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/hooks/EventsHandlerTest.java @@ -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 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); + } +}