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.
This commit is contained in:
1 parent
e989bff9e3
commit
764adb6cbc
12 files changed
+512
-197
No files matched your search
@@ -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<EventListenerHandler<?>> handlers = new ArrayList<>();
|
||||
handlers.add(new MessageReceivedHandler(settings, guildServices));
|
||||
handlers.addAll(InteractionHandlers.create(settings));
|
||||
handlers.add(new ReadyHandler(commands));
|
||||
handlers.addAll(new GuildLifecycleHandler(guildServices).handlers());
|
||||
handlers.add(new VoiceEventPublisher(events));
|
||||
ShardService shards = context.register(new ShardService(
|
||||
settings, music, new EventsHandler(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"));
|
||||
}, () -> { }));
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
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<Class<? extends Event>, Function<? extends Event, Context>> listeners = new ConcurrentHashMap<>();
|
||||
private final SettingsProvider settings;
|
||||
private final GuildServiceProvider guildServices;
|
||||
private final Map<Class<?>, List<EventListenerHandler<?>>> handlers = new ConcurrentHashMap<>();
|
||||
private final CommandRegistry commands;
|
||||
private final TaskManager taskManager;
|
||||
private final EventPublisher events;
|
||||
|
||||
public EventsHandler(
|
||||
SettingsProvider settings,
|
||||
GuildServiceProvider guildServices,
|
||||
List<EventListenerHandler<?>> 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<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) {
|
||||
logger.info("Registering listener for {}", eventClass.getSimpleName());
|
||||
listeners.put(eventClass, (Function<? extends Event, Context>) function);
|
||||
register(new ContextEventHandler<T>() {
|
||||
@Override
|
||||
public Class<T> eventType() {
|
||||
return eventClass;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Context toContext(T event) {
|
||||
return function.apply(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a generic listener for a specified event type. The consumer will be invoked
|
||||
* whenever an event of the specified type is detected.
|
||||
*
|
||||
* @param eventClass the class type of the event to listen for
|
||||
* @param consumer the consumer to handle the event, which takes an event of the specified type and performs an action
|
||||
* @param <T> the type of the event that extends the {@link Event} class
|
||||
* Adds a side-effect listener for a specified event type.
|
||||
*/
|
||||
public <T extends Event> void addGenericListener(Class<T> eventClass, Consumer<T> consumer) {
|
||||
addListener(eventClass, t -> {
|
||||
consumer.accept(t);
|
||||
return null;
|
||||
register(new EventListenerHandler<T>() {
|
||||
@Override
|
||||
public Class<T> eventType() {
|
||||
return eventClass;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(T event) {
|
||||
consumer.accept(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<Event, Context> function = (Function<Event, Context>) listeners.get(eventClass);
|
||||
Context context = function.apply(event);
|
||||
for (Class<?> type : hierarchy(event.getClass())) {
|
||||
List<EventListenerHandler<?>> matching = handlers.get(type);
|
||||
if (matching == null) {
|
||||
continue;
|
||||
}
|
||||
for (EventListenerHandler<?> raw : matching) {
|
||||
try {
|
||||
EventListenerHandler<Event> handler = (EventListenerHandler<Event>) raw;
|
||||
handler.handle(event);
|
||||
if (handler instanceof ContextEventHandler<Event> contextHandler) {
|
||||
route(contextHandler.toContext(event));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Handler {} failed for {}", raw.getClass().getName(), event.getClass().getSimpleName(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void route(Context context) {
|
||||
if (context instanceof CommandContext commandContext) {
|
||||
handleCommand(commandContext);
|
||||
new CommandProcessor(commandContext, commands, taskManager).dispatch();
|
||||
}
|
||||
|
||||
if (context instanceof TaskContext taskContext) {
|
||||
handleTask(taskContext);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
eventClass = eventClass.getSuperclass();
|
||||
taskManager.handle(taskContext);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCommand(CommandContext context) {
|
||||
CommandProcessor processor = new CommandProcessor(context, commands, taskManager);
|
||||
processor.dispatch();
|
||||
private static Set<Class<?>> hierarchy(Class<?> start) {
|
||||
Set<Class<?>> result = new LinkedHashSet<>();
|
||||
Deque<Class<?>> queue = new ArrayDeque<>();
|
||||
queue.add(start);
|
||||
while (!queue.isEmpty()) {
|
||||
Class<?> type = queue.poll();
|
||||
if (!result.add(type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
private void handleTask(TaskContext context) {
|
||||
taskManager.handle(context);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user