diff --git a/BotCommands-core/build.gradle.kts b/BotCommands-core/build.gradle.kts index 6e5ba78285..c007d0d8c6 100644 --- a/BotCommands-core/build.gradle.kts +++ b/BotCommands-core/build.gradle.kts @@ -3,6 +3,7 @@ import dev.freya02.botcommands.tasks.GenerateBCInfoTask import dev.freya02.botcommands.utils.registerBucket4JDocs import dev.freya02.botcommands.utils.registerJetbrainsAnnotationsDocs import dev.freya02.botcommands.utils.registerSourceSet +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { id("repositories-conventions") @@ -166,6 +167,14 @@ kotlin { } } +tasks.named("compileTestKotlin") { + compilerOptions { + optIn.addAll( + "io.github.freya022.botcommands.api.core.hooks.custom.annotations.ExperimentalCustomEvents" + ) + } +} + publishedProjectEnvironment { configureJarArtifact( artifactId = "BotCommands-core", diff --git a/BotCommands-core/src/javaDocExamples/java/doc/java/examples/core/hooks/custom/MyCustomEventRequirementsProvider.java b/BotCommands-core/src/javaDocExamples/java/doc/java/examples/core/hooks/custom/MyCustomEventRequirementsProvider.java new file mode 100644 index 0000000000..b4dc39a27b --- /dev/null +++ b/BotCommands-core/src/javaDocExamples/java/doc/java/examples/core/hooks/custom/MyCustomEventRequirementsProvider.java @@ -0,0 +1,31 @@ +package doc.java.examples.core.hooks.custom; + +import io.github.freya022.botcommands.api.core.hooks.custom.CustomEventRequirements; +import io.github.freya022.botcommands.api.core.hooks.custom.CustomEventRequirementsProvider; +import io.github.freya022.botcommands.api.core.service.annotations.BService; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import org.jspecify.annotations.NullMarked; + +@BService +@NullMarked +public class MyCustomEventRequirementsProvider implements CustomEventRequirementsProvider { + // A custom event indicating a member moved to a different audio channel + static class GuildVoiceChannelMoveEvent { + // ... + } + + @Override + public CustomEventRequirements get(Class handledEventType) { + // Any event that is or extends GuildVoiceChannelMoveEvent + if (GuildVoiceChannelMoveEvent.class.isAssignableFrom(handledEventType)) { + // A member moving from a channel to another is signaled by a GuildVoiceUpdateEvent, + // so we give the same requirements + return CustomEventRequirements.fromEvents(GuildVoiceUpdateEvent.class); + } + + // For other events than those we directly support. + // It can also be events from a different module, + // in which case that module should have a CustomEventRequirementsProvider too. + return CustomEventRequirements.unknown(); + } +} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/annotations/BEventListener.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/annotations/BEventListener.kt index 1daef1cc80..5f098ef94c 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/annotations/BEventListener.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/annotations/BEventListener.kt @@ -5,19 +5,22 @@ import io.github.freya022.botcommands.api.core.config.BCoroutineScopesConfig import io.github.freya022.botcommands.api.core.config.BEventManagerConfig import io.github.freya022.botcommands.api.core.events.BGenericEvent import io.github.freya022.botcommands.api.core.hooks.EventDispatcher +import io.github.freya022.botcommands.api.core.hooks.custom.CustomEventRequirementsProvider import net.dv8tion.jda.api.events.GenericEvent import net.dv8tion.jda.api.requests.GatewayIntent import java.util.concurrent.TimeUnit /** - * Annotates a function as an event listener for a JDA or BC event. + * Annotates a function as an event listener for a JDA, BC, or custom event. * * Remember to always check the requirements of the events you're listening to! + * If an event does not fulfill their requirements, and they are not ignored, then the listener will be disabled! * * ### Requirements * - The declaring class must be a service * - The function must not be static - * - The first argument must be a subclass of [GenericEvent] or [BGenericEvent] + * - The first argument is typically a subclass of [GenericEvent] or [BGenericEvent], but can be anything other than `Any`/`Object` + * - If the event being listened to is a custom event, then a [CustomEventRequirementsProvider] service must be available. */ @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/EventDispatcher.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/EventDispatcher.kt index 947a3ff9a7..e50b7dde8d 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/EventDispatcher.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/EventDispatcher.kt @@ -10,7 +10,7 @@ import net.dv8tion.jda.api.events.GenericEvent import net.dv8tion.jda.api.hooks.EventListener /** - * Dispatches JDA and BC events to [@BEventListener][BEventListener] methods. + * Dispatches events to [@BEventListener][BEventListener] methods. */ @InterfacedService(acceptMultiple = false) abstract class EventDispatcher internal constructor() { diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/custom/CustomEventRequirements.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/custom/CustomEventRequirements.kt new file mode 100644 index 0000000000..07a7bb1036 --- /dev/null +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/custom/CustomEventRequirements.kt @@ -0,0 +1,96 @@ +package io.github.freya022.botcommands.api.core.hooks.custom + +import io.github.freya022.botcommands.api.core.hooks.custom.annotations.ExperimentalCustomEvents +import io.github.freya022.botcommands.api.core.utils.toEnumSet +import io.github.freya022.botcommands.internal.core.hooks.custom.CustomEventRequirementsImpl +import io.github.freya022.botcommands.internal.core.hooks.custom.EmptyCustomEventRequirements +import io.github.freya022.botcommands.internal.core.hooks.custom.UnknownCustomEventRequirements +import net.dv8tion.jda.api.events.GenericEvent +import net.dv8tion.jda.api.requests.GatewayIntent + +/** + * Represents the requirements of a custom event. + */ +@ExperimentalCustomEvents +interface CustomEventRequirements { + + /** + * Whether this custom event has no requirements. + */ + fun isEmpty(): Boolean + + /** + * Whether this custom event has unknown requirements. + */ + fun areUnknown(): Boolean + + /** + * The unmodifiable intent set required by this event. Can be empty. + */ + fun getIntents(): Set + + companion object { + + /** + * Creates an instance from the given intents. + */ + @JvmStatic + fun from(vararg intents: GatewayIntent): CustomEventRequirements { + return from(intents.toEnumSet()) + } + + /** + * Creates an instance from the given intents. + */ + @JvmStatic + fun from(intents: Collection): CustomEventRequirements { + if (intents.isEmpty()) + return none() + + return CustomEventRequirementsImpl(intents) + } + + /** + * Creates an instance from the intents required by the given events. + * + * This is a shortcut to as `from(GatewayIntent.fromEvents(events))`. + */ + @JvmStatic + @SafeVarargs + fun fromEvents(vararg events: Class): CustomEventRequirements { + return from(GatewayIntent.fromEvents(*events)) + } + + /** + * Creates an instance from the intents required by the given events. + * + * This is a shortcut to as `from(GatewayIntent.fromEvents(events))`. + */ + @JvmStatic + fun fromEvents(events: Collection>): CustomEventRequirements { + return from(GatewayIntent.fromEvents(events)) + } + + /** + * Creates an instance from the intents required by the given events. + * + * This is a shortcut to as `from(GatewayIntent.fromEvents(E::class.java))`. + */ + @JvmSynthetic + inline fun fromEvent(): CustomEventRequirements { + return from(GatewayIntent.fromEvents(E::class.java)) + } + + /** + * Creates an instance indicating the event has no requirements. + */ + @JvmStatic + fun none(): CustomEventRequirements = EmptyCustomEventRequirements + + /** + * Creates an instance indicating the event is unknown to the requirement provider. + */ + @JvmStatic + fun unknown(): CustomEventRequirements = UnknownCustomEventRequirements + } +} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/custom/CustomEventRequirementsProvider.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/custom/CustomEventRequirementsProvider.kt new file mode 100644 index 0000000000..9c330d23ba --- /dev/null +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/custom/CustomEventRequirementsProvider.kt @@ -0,0 +1,57 @@ +package io.github.freya022.botcommands.api.core.hooks.custom + +import io.github.freya022.botcommands.api.core.hooks.custom.annotations.ExperimentalCustomEvents +import io.github.freya022.botcommands.api.core.service.annotations.InterfacedService + +/** + * Provides the requirements for custom events. + * At least one service must implement this interface if you have a listener for a custom event. + * + * Exactly one instance must return (known) requirements for a given event type. + * + * ### Example + * + * ```java + * @BService + * @NullMarked + * public class MyCustomEventRequirementsProvider implements CustomEventRequirementsProvider { + * // A custom event indicating a member moved to a different audio channel + * static class GuildVoiceChannelMoveEvent { + * // ... + * } + * + * @Override + * public CustomEventRequirements get(Class handledEventType) { + * // Any event that is or extends GuildVoiceChannelMoveEvent + * if (GuildVoiceChannelMoveEvent.class.isAssignableFrom(handledEventType)) { + * // A member moving from a channel to another is signaled by a GuildVoiceUpdateEvent, + * // so we give the same requirements + * return CustomEventRequirements.fromEvents(GuildVoiceUpdateEvent.class); + * } + * + * // For other events than those we directly support. + * // It can also be events from a different module, + * // in which case that module should have a CustomEventRequirementsProvider too. + * return CustomEventRequirements.unknown(); + * } + * } + * ``` + * + * @see get + */ +@InterfacedService(acceptMultiple = true) +@ExperimentalCustomEvents +interface CustomEventRequirementsProvider { + + /** + * Returns a set of requirements for the provided event type, the type corresponds to the listener's first parameter type. + * + * This function must return [CustomEventRequirements.unknown()][CustomEventRequirements.unknown] if the provided type + * is not known (for example, if it comes from another module). Returning anything else in this case, is an error. + * + * @param handledEventType The type of event the listener can handle + * + * @return A [CustomEventRequirements] with the possible requirements + */ + fun get(handledEventType: Class<*>): CustomEventRequirements +} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/custom/annotations/ExperimentalCustomEvents.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/custom/annotations/ExperimentalCustomEvents.kt new file mode 100644 index 0000000000..4dbc780df3 --- /dev/null +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/custom/annotations/ExperimentalCustomEvents.kt @@ -0,0 +1,41 @@ +package io.github.freya022.botcommands.api.core.hooks.custom.annotations + +import kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS +import kotlin.annotation.AnnotationTarget.CLASS +import kotlin.annotation.AnnotationTarget.CONSTRUCTOR +import kotlin.annotation.AnnotationTarget.FIELD +import kotlin.annotation.AnnotationTarget.FUNCTION +import kotlin.annotation.AnnotationTarget.LOCAL_VARIABLE +import kotlin.annotation.AnnotationTarget.PROPERTY +import kotlin.annotation.AnnotationTarget.PROPERTY_GETTER +import kotlin.annotation.AnnotationTarget.PROPERTY_SETTER +import kotlin.annotation.AnnotationTarget.TYPEALIAS +import kotlin.annotation.AnnotationTarget.VALUE_PARAMETER + +/** + * Opt-in marker annotation for APIs of custom events that are considered experimental and are not subject to compatibility guarantees: + * The behavior of such API may be changed or the API may be removed completely in any further release. + * + * Please create an issue or join the Discord server if you encounter a problem or want to submit feedback. + * + * Any usage of a declaration annotated with `@ExperimentalCustomEvents` must be accepted either by + * annotating that usage with the [@OptIn][OptIn] annotation, e.g. `@OptIn(ExperimentalCustomEvents::class)`, + * or by using the compiler argument `-opt-in=io.github.freya022.botcommands.api.core.hooks.custom.annotations.ExperimentalCustomEvents`. + */ +@RequiresOptIn(level = RequiresOptIn.Level.ERROR) +@Retention(AnnotationRetention.BINARY) +@Target( + CLASS, + ANNOTATION_CLASS, + PROPERTY, + FIELD, + LOCAL_VARIABLE, + VALUE_PARAMETER, + CONSTRUCTOR, + FUNCTION, + PROPERTY_GETTER, + PROPERTY_SETTER, + TYPEALIAS +) +@MustBeDocumented +annotation class ExperimentalCustomEvents diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/utils/Collections.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/utils/Collections.kt index 7de8e0e98d..988bd9e2e0 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/utils/Collections.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/utils/Collections.kt @@ -12,6 +12,12 @@ inline fun > enumSetOfAll(): EnumSet = EnumSet.allOf(T::c inline fun > enumSetOf(vararg elems: T): EnumSet = enumSetOf().apply { addAll(elems) } inline fun , V> enumMapOf(): EnumMap = EnumMap(T::class.java) +inline fun > Array.toEnumSet(): EnumSet = enumSetOf().apply { addAll(this@toEnumSet) } +inline fun > Collection.toEnumSet(): EnumSet = when (this) { + is EnumSet -> EnumSet.copyOf(this) + else -> enumSetOf().also { it.addAll(this) } +} + fun Collection.unmodifiableView(): Collection { return Collections.unmodifiableCollection(this) } diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/ClassPathFunction.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/ClassPathFunction.kt index 99e8aca21d..32e377655b 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/ClassPathFunction.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/ClassPathFunction.kt @@ -26,7 +26,7 @@ internal sealed class ClassPathFunction { if (this === other) return true if (other !is ClassPathFunction) return false - return function == other.function + return instance === other.instance && function == other.function } override fun hashCode(): Int { diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventDispatcherImpl.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventDispatcherImpl.kt index bc1a4fed08..d643b06bbe 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventDispatcherImpl.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventDispatcherImpl.kt @@ -5,7 +5,9 @@ import io.github.freya022.botcommands.api.core.config.BCoroutineScopesConfig import io.github.freya022.botcommands.api.core.events.InitializationEvent import io.github.freya022.botcommands.api.core.hooks.EventDispatcher import io.github.freya022.botcommands.api.core.objectLogger +import io.github.freya022.botcommands.api.core.service.ServiceContainer import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.core.service.lazy import io.github.freya022.botcommands.api.core.utils.loggerOf import io.github.freya022.botcommands.api.core.utils.simpleNestedName import io.github.freya022.botcommands.internal.utils.shortSignature @@ -21,15 +23,19 @@ private val logger = KotlinLogging.loggerOf() @BService internal class EventDispatcherImpl internal constructor( coroutineScopesConfig: BCoroutineScopesConfig, - private val eventListenerRegistry: EventListenerRegistry, + serviceContainer: ServiceContainer, ) : EventDispatcher() { private val eventManagerCoroutineScope: CoroutineScope = coroutineScopesConfig.eventManagerScope private val asyncCoroutineScope: CoroutineScope = coroutineScopesConfig.eventDispatcherScope + // Registry may fetch classes that also dispatch events, causing a circular dependency + private val eventListenerRegistry: EventListenerRegistry by serviceContainer.lazy() + internal fun onEvent(event: GenericEvent) { // No need to check for `event` type as if it's in the map, then it's recognized - val handlers = eventListenerRegistry[event.javaClass] ?: return + val handlers = eventListenerRegistry[event.javaClass] + if (handlers.isEmpty) return // Run blocking handlers first handlers[RunMode.BLOCKING]?.let { eventHandlers -> @@ -63,7 +69,8 @@ internal class EventDispatcherImpl internal constructor( override suspend fun dispatchEvent(event: Any) { // No need to check for `event` type as if it's in the map, then it's recognized - val handlers = eventListenerRegistry[event.javaClass] ?: return + val handlers = eventListenerRegistry[event.javaClass] + if (handlers.isEmpty) return // Run blocking handlers first handlers[RunMode.BLOCKING]?.forEach { eventHandler -> @@ -86,7 +93,8 @@ internal class EventDispatcherImpl internal constructor( override fun dispatchEventAsync(event: Any): List> { // Try not to switch context on non-handled events // No need to check for `event` type as if it's in the map, then it's recognized - val handlers = eventListenerRegistry[event.javaClass] ?: return emptyList() + val handlers = eventListenerRegistry[event.javaClass] + if (handlers.isEmpty) return emptyList() return handlers.map { eventHandler -> asyncCoroutineScope.async { runEventHandler(eventHandler, event) } diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventHandlerFunction.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventHandlerFunction.kt index fb68ab63be..0f4c723e36 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventHandlerFunction.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventHandlerFunction.kt @@ -6,6 +6,7 @@ import io.github.freya022.botcommands.internal.core.ClassPathFunction import kotlin.time.Duration internal class EventHandlerFunction( + val eventType: Class<*>, val classPathFunction: ClassPathFunction, val priority: Int, val runMode: BEventListener.RunMode, diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventListenerList.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventListenerList.kt index 7939d31121..66807f560f 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventListenerList.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventListenerList.kt @@ -1,7 +1,7 @@ package io.github.freya022.botcommands.internal.core.hooks import io.github.freya022.botcommands.api.core.annotations.BEventListener.RunMode -import java.util.* +import io.github.freya022.botcommands.api.core.utils.enumMapOf import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock @@ -9,7 +9,9 @@ internal class EventListenerList { // Only protect modification operations, traversal is fine private val lock = ReentrantLock() - private var map: Map> = emptyMap() + private var map: Map> = emptyMap() + + val isEmpty: Boolean get() = map.isEmpty() operator fun get(mode: RunMode): List? = map[mode] @@ -26,25 +28,21 @@ internal class EventListenerList { this.map = newMap } - fun removeAll(removedList: EventListenerList): Boolean = lock.withLock { + fun removeAll(handlers: Collection) = lock.withLock { val newMap = newMap() - var removedAny = false - removedList.map.forEach { (mode, handlers) -> - val newHandlers = newMap[mode] ?: return@forEach - removedAny = removedAny || newHandlers.removeAll(handlers) - } + // Likely more efficient to do bulk operations on a few lists than getting the right list and removing items one by one + newMap.values.forEach { newHandlers -> newHandlers.removeAll(handlers) } // Remove entries containing an empty list RunMode.entries.forEach { mode -> newMap.remove(mode, emptyList()) } this.map = newMap - return removedAny } - private fun newMap() = EnumMap>(RunMode::class.java).apply { + private fun newMap() = enumMapOf>().apply { map.forEach { (mode, handlers) -> put(mode, handlers.toMutableList() /* copy */) } } -} \ No newline at end of file +} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventListenerRegistry.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventListenerRegistry.kt index 86321c8ca1..9ef92d22c4 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventListenerRegistry.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventListenerRegistry.kt @@ -1,16 +1,12 @@ package io.github.freya022.botcommands.internal.core.hooks -import io.github.freya022.botcommands.api.core.JDAService import io.github.freya022.botcommands.api.core.annotations.BEventListener import io.github.freya022.botcommands.api.core.config.BConfig -import io.github.freya022.botcommands.api.core.events.BGenericEvent import io.github.freya022.botcommands.api.core.service.ServiceContainer import io.github.freya022.botcommands.api.core.service.annotations.BService -import io.github.freya022.botcommands.api.core.utils.enumSetOf import io.github.freya022.botcommands.api.core.utils.findAnnotationRecursive -import io.github.freya022.botcommands.api.core.utils.isSubclassOf +import io.github.freya022.botcommands.api.core.utils.toEnumSet import io.github.freya022.botcommands.internal.core.ClassPathFunction -import io.github.freya022.botcommands.internal.core.exceptions.InternalException import io.github.freya022.botcommands.internal.core.requiredFilter import io.github.freya022.botcommands.internal.core.service.FunctionAnnotationsMap import io.github.freya022.botcommands.internal.core.service.canCreateWrappedService @@ -18,10 +14,6 @@ import io.github.freya022.botcommands.internal.core.service.tryGetWrappedService import io.github.freya022.botcommands.internal.core.toClassPathFunctions import io.github.freya022.botcommands.internal.utils.* import io.github.freya022.botcommands.internal.utils.ReflectionUtils.nonInstanceParameters -import io.github.oshai.kotlinlogging.KotlinLogging -import net.dv8tion.jda.api.events.Event -import net.dv8tion.jda.api.events.GenericEvent -import net.dv8tion.jda.api.requests.GatewayIntent import java.util.concurrent.ConcurrentHashMap import kotlin.reflect.full.functions import kotlin.reflect.jvm.jvmErasure @@ -29,24 +21,20 @@ import kotlin.time.Duration import kotlin.time.toDuration import kotlin.time.toDurationUnit -private val logger = KotlinLogging.logger { } - @BService internal class EventListenerRegistry internal constructor( - private val config: BConfig, + config: BConfig, private val serviceContainer: ServiceContainer, - private val eventTreeService: EventTreeService, - private val jdaService: JDAService, + private val requirementsVerifier: EventListenerRequirementsVerifier, functionAnnotationsMap: FunctionAnnotationsMap, ) { - private typealias ClassName = String - private typealias EventMap = MutableMap - private val defaultTimeout: Duration = config.eventManagerConfig.defaultTimeout ?: Duration.INFINITE - private val map: EventMap = ConcurrentHashMap() - private val listeners: MutableMap, EventMap> = ConcurrentHashMap() + /** Listener instance -> handlers */ + private val listenerFunctionMap: MutableMap> = ConcurrentHashMap() + /** Maps event types to the listener subtypes they can fire to */ + private val resolvedListeners: MutableMap, EventListenerList> = ConcurrentHashMap() init { functionAnnotationsMap @@ -54,8 +42,19 @@ internal class EventListenerRegistry internal constructor( .addAsEventListeners() } - internal operator fun get(eventType: Class<*>): EventListenerList? { - return map[eventType.name] + internal operator fun get(eventType: Class<*>): EventListenerList { + return resolvedListeners.computeIfAbsent(eventType) { eventType -> + // Create the list of handlers that are to be fired by the provided event type + val list = EventListenerList() + for (handlerFunction in listenerFunctionMap.values.flatten()) { + val eventErasure = handlerFunction.eventType + if (eventErasure.isAssignableFrom(eventType)) { + list.add(handlerFunction) + } + } + + list + } } internal fun addEventListener(listener: Any) { @@ -64,23 +63,23 @@ internal class EventListenerRegistry internal constructor( .withFilter(FunctionFilter.annotation()) .toClassPathFunctions(listener) .addAsEventListeners() + + // Clear the "event type -> handlers" associations, since we added a new listener, the handlers need to be recomputed + resolvedListeners.clear() } internal fun removeEventListener(listener: Any) { - listeners.remove(listener.javaClass)?.let { instanceMap -> - instanceMap.forEach { (kClass, functions) -> - val functionMap = map[kClass] - ?: throwInternal("Listener was registered without having its functions added to the listener map") - if (!functionMap.removeAll(functions)) { - logger.error(InternalException("Unable to remove listener functions from registered functions")) { "An exception occurred while removing event listener $listener" } - } - } + val handlerFunctions = listenerFunctionMap.remove(listener) ?: return + + // Remove handlers of the provided listener from the resolved listeners + for (listenerList in resolvedListeners.values) { + listenerList.removeAll(handlerFunctions) } } private fun Collection.addAsEventListeners() = this .requiredFilter(FunctionFilter.nonStatic()) - .requiredFilter(FunctionFilter.firstArg(GenericEvent::class, BGenericEvent::class)) + .requiredFilter(FunctionFilter.firstArgNot(Any::class)) .requiredFilter(FunctionFilter.noOptional()) .forEach { classPathFunc -> val function = classPathFunc.function @@ -89,17 +88,8 @@ internal class EventListenerRegistry internal constructor( val parameters = function.nonInstanceParameters - val eventErasure = parameters.first().type.jvmErasure.java - if (!annotation.ignoreIntents && eventErasure.isSubclassOf()) { - @Suppress("UNCHECKED_CAST") - val requiredIntents = GatewayIntent.fromEvents(eventErasure as Class) - val missingIntents = requiredIntents - jdaService.intents - config.ignoredIntents - enumSetOf(*annotation.ignoredIntents) - if (missingIntents.isNotEmpty()) { - return@forEach logger.debug { "Skipping event listener ${function.shortSignature} as it is missing intents: $missingIntents" } - } - - // Cannot check for RawGatewayEvent as JDA is not present yet and there is no config for it - } + val eventErasure: Class = parameters.first().type.jvmErasure.java + requirementsVerifier.verifyFor(function, annotation.ignoreIntents, annotation.ignoredIntents.toEnumSet(), eventErasure) val eventParameters = parameters.drop(1) // The main risk was with injected services, as they may not be available at that point, @@ -113,7 +103,9 @@ internal class EventListenerRegistry internal constructor( ) } } - val eventHandlerFunction = EventHandlerFunction(classPathFunction = classPathFunc, + val eventHandlerFunction = EventHandlerFunction( + eventType = eventErasure, + classPathFunction = classPathFunc, runMode = annotation.mode, timeout = getTimeout(annotation), priority = annotation.priority, @@ -122,18 +114,9 @@ internal class EventListenerRegistry internal constructor( eventParameters.map { serviceContainer.tryGetWrappedService(it).getOrThrow() } }) - val allEventTypes = eventTreeService.getSubclasses(eventErasure) + eventErasure.name - classPathFunc.javaClazz.let { clazz -> - val instanceMap = listeners.computeIfAbsent(clazz) { hashMapOf() } - - allEventTypes.forEach { - instanceMap.computeIfAbsent(it) { EventListenerList() }.add(eventHandlerFunction) - } - } - - allEventTypes.forEach { - map.computeIfAbsent(it) { EventListenerList() }.add(eventHandlerFunction) - } + // Create or update list of handlers owned by the listener + // This is effectively the same as a CopyOnWriteArrayList, but takes advantage of the ConcurrentHashMap's locking + listenerFunctionMap.merge(classPathFunc.instance, listOf(eventHandlerFunction), List::plus) } private fun getTimeout(annotation: BEventListener): Duration? { diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventListenerRequirementsVerifier.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventListenerRequirementsVerifier.kt new file mode 100644 index 0000000000..767af637dd --- /dev/null +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventListenerRequirementsVerifier.kt @@ -0,0 +1,134 @@ +package io.github.freya022.botcommands.internal.core.hooks + +import io.github.freya022.botcommands.api.core.JDAService +import io.github.freya022.botcommands.api.core.config.BConfig +import io.github.freya022.botcommands.api.core.events.BEvent +import io.github.freya022.botcommands.api.core.events.BGenericEvent +import io.github.freya022.botcommands.api.core.hooks.custom.CustomEventRequirementsProvider +import io.github.freya022.botcommands.api.core.hooks.custom.annotations.ExperimentalCustomEvents +import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.core.service.annotations.ServiceType +import io.github.freya022.botcommands.api.core.utils.isSubclassOf +import io.github.freya022.botcommands.api.core.utils.joinAsList +import io.github.freya022.botcommands.api.core.utils.shortQualifiedName +import io.github.freya022.botcommands.internal.utils.classRef +import io.github.freya022.botcommands.internal.utils.shortSignature +import io.github.freya022.botcommands.internal.utils.throwState +import io.github.oshai.kotlinlogging.KotlinLogging +import net.dv8tion.jda.api.events.Event +import net.dv8tion.jda.api.events.GenericEvent +import net.dv8tion.jda.api.requests.GatewayIntent +import kotlin.reflect.KFunction + +internal interface EventListenerRequirementsVerifier { + + fun verifyFor( + function: KFunction<*>, + locallySkipIntentChecks: Boolean, + locallySkippedIntents: Set, + eventErasure: Class<*>, + ): Boolean +} + +@OptIn(ExperimentalCustomEvents::class) +@BService +@ServiceType(EventListenerRequirementsVerifier::class) +internal class EventListenerRequirementsVerifierImpl( + private val config: BConfig, + private val jdaService: JDAService, + private val customEventRequirementsProviders: List, +) : EventListenerRequirementsVerifier { + + private companion object { + + private val logger = KotlinLogging.logger { } + } + + init { + // Try to enforce requirement providers to only return requirements for events they truly know about + // in other words, forbid implementations from returning fake requirements. + for (requirementsProvider in customEventRequirementsProviders) { + val eventRequirements = requirementsProvider.get(FakeEvent::class.java) + check(eventRequirements.areUnknown()) { + "Custom event requirement providers are required to return 'CustomEventRequirements.unknown()' on unhandled events, and '${requirementsProvider.javaClass.shortQualifiedName}' fails that" + } + } + } + + override fun verifyFor( + function: KFunction<*>, + locallySkipIntentChecks: Boolean, + locallySkippedIntents: Set, + eventErasure: Class<*>, + ): Boolean { + // Make sure custom events don't implement JDA and BC generic events + if (eventErasure.isSubclassOf()) { + check(eventErasure.packageName.startsWith("net.dv8tion.jda.api.events")) { + "Custom events must not implement ${GenericEvent::class.java.name}!" + } + } else if (eventErasure.isSubclassOf()) { + check(eventErasure.packageName.startsWith("io.github.freya022.botcommands.api")) { + "Custom events must not implement ${BGenericEvent::class.java.name}!" + } + } + + return locallySkipIntentChecks || checkIntents(function, eventErasure, locallySkippedIntents) + } + + private fun checkIntents(function: KFunction<*>, eventErasure: Class<*>, locallySkippedIntents: Set): Boolean { + if (eventErasure.isSubclassOf()) { + @Suppress("UNCHECKED_CAST") + val requiredIntents = GatewayIntent.fromEvents(eventErasure as Class) + val missingIntents = getMissingIntents(requiredIntents, locallySkippedIntents) + if (missingIntents.isNotEmpty()) { + logger.debug { "Skipping JDA event listener ${function.shortSignature} as it is missing intents: $missingIntents" } + return false + } + + // Cannot check for RawGatewayEvent as JDA is not present yet and there is no config for it + } else if (!eventErasure.isSubclassOf()) { + return checkCustomEventRequirements(function, eventErasure, locallySkippedIntents) + } + + return true + } + + private fun checkCustomEventRequirements(function: KFunction<*>, eventErasure: Class<*>, locallySkippedIntents: Set): Boolean { + check(customEventRequirementsProviders.isNotEmpty()) { + "No ${classRef()} are available (custom event listener at ${function.shortSignature})" + } + + val passedRequirementProviders = ArrayList(1) + for (requirementsProvider in customEventRequirementsProviders) { + val eventRequirements = requirementsProvider.get(eventErasure) + if (eventRequirements.areUnknown()) { + continue + } else if (eventRequirements.isEmpty()) { + passedRequirementProviders.add(requirementsProvider) + continue + } + + val missingIntents = getMissingIntents(eventRequirements.getIntents(), locallySkippedIntents) + if (missingIntents.isNotEmpty()) { + logger.debug { "Skipping custom event listener ${function.shortSignature} as it is missing intents: $missingIntents" } + return false + } + + passedRequirementProviders.add(requirementsProvider) + } + + if (passedRequirementProviders.size > 1) { + throwState("Multiple ${classRef()} returned requirements for '${eventErasure.shortQualifiedName}':\n${passedRequirementProviders.joinAsList { it.javaClass.shortQualifiedName }}") + } else if (passedRequirementProviders.isNotEmpty()) { + return true + } else { + throwState("No ${classRef()} returned requirements for '${eventErasure.shortQualifiedName}', available providers:\n${customEventRequirementsProviders.joinAsList { it.javaClass.shortQualifiedName }}") + } + } + + private fun getMissingIntents(requiredIntents: Set, locallySkippedIntents: Set): Set { + return requiredIntents - jdaService.intents - config.ignoredIntents - locallySkippedIntents + } + + private interface FakeEvent +} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventTreeService.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventTreeService.kt deleted file mode 100644 index bc84272db4..0000000000 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventTreeService.kt +++ /dev/null @@ -1,31 +0,0 @@ -package io.github.freya022.botcommands.internal.core.hooks - -import io.github.classgraph.ClassGraph -import io.github.freya022.botcommands.api.core.events.BGenericEvent -import io.github.freya022.botcommands.api.core.service.annotations.BService -import io.github.oshai.kotlinlogging.KotlinLogging -import net.dv8tion.jda.api.events.GenericEvent -import java.util.* - -private val logger = KotlinLogging.logger { } - -@BService -internal class EventTreeService internal constructor() { - - private typealias ClassName = String - - private val map: Map> = ClassGraph() - .acceptPackages(GenericEvent::class.java.packageName, BGenericEvent::class.java.packageName) - .disableRuntimeInvisibleAnnotations() - .disableModuleScanning() - .enableClassInfo() - .scan().use { scanResult -> - (scanResult.getClassesImplementing(GenericEvent::class.java) + scanResult.getClassesImplementing(BGenericEvent::class.java)).associate { info -> - info.name to Collections.unmodifiableList(info.subclasses.map { subclassInfo -> subclassInfo.name }) - } - } - - internal fun getSubclasses(clazz: Class<*>): List = map[clazz.name] ?: emptyList().also { - logger.warn { "Unknown event type: ${clazz.name}" } - } -} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/custom/CustomEventRequirementsImpl.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/custom/CustomEventRequirementsImpl.kt new file mode 100644 index 0000000000..9d792da7d9 --- /dev/null +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/custom/CustomEventRequirementsImpl.kt @@ -0,0 +1,21 @@ +package io.github.freya022.botcommands.internal.core.hooks.custom + +import io.github.freya022.botcommands.api.core.hooks.custom.CustomEventRequirements +import io.github.freya022.botcommands.api.core.hooks.custom.annotations.ExperimentalCustomEvents +import io.github.freya022.botcommands.api.core.utils.toEnumSet +import io.github.freya022.botcommands.api.core.utils.unmodifiableView +import net.dv8tion.jda.api.requests.GatewayIntent + +@OptIn(ExperimentalCustomEvents::class) +internal class CustomEventRequirementsImpl( + intents: Collection, +) : CustomEventRequirements { + + private val intents: Set = intents.toEnumSet().unmodifiableView() + + override fun isEmpty(): Boolean = intents.isEmpty() + + override fun areUnknown(): Boolean = false + + override fun getIntents(): Set = intents +} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/custom/EmptyCustomEventRequirements.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/custom/EmptyCustomEventRequirements.kt new file mode 100644 index 0000000000..de65a966c7 --- /dev/null +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/custom/EmptyCustomEventRequirements.kt @@ -0,0 +1,15 @@ +package io.github.freya022.botcommands.internal.core.hooks.custom + +import io.github.freya022.botcommands.api.core.hooks.custom.CustomEventRequirements +import io.github.freya022.botcommands.api.core.hooks.custom.annotations.ExperimentalCustomEvents +import net.dv8tion.jda.api.requests.GatewayIntent + +@OptIn(ExperimentalCustomEvents::class) +internal object EmptyCustomEventRequirements : CustomEventRequirements { + + override fun isEmpty(): Boolean = true + + override fun areUnknown(): Boolean = false + + override fun getIntents(): Set = emptySet() +} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/custom/UnknownCustomEventRequirements.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/custom/UnknownCustomEventRequirements.kt new file mode 100644 index 0000000000..c2e01df7f4 --- /dev/null +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/custom/UnknownCustomEventRequirements.kt @@ -0,0 +1,15 @@ +package io.github.freya022.botcommands.internal.core.hooks.custom + +import io.github.freya022.botcommands.api.core.hooks.custom.CustomEventRequirements +import io.github.freya022.botcommands.api.core.hooks.custom.annotations.ExperimentalCustomEvents +import net.dv8tion.jda.api.requests.GatewayIntent + +@OptIn(ExperimentalCustomEvents::class) +internal object UnknownCustomEventRequirements : CustomEventRequirements { + + override fun isEmpty(): Boolean = false + + override fun areUnknown(): Boolean = true + + override fun getIntents(): Set = emptySet() +} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/utils/FunctionFilter.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/utils/FunctionFilter.kt index d0039f3c7a..a02eb700b9 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/utils/FunctionFilter.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/utils/FunctionFilter.kt @@ -65,6 +65,17 @@ internal abstract class FunctionFilter { override fun filter(function: Function): Boolean = hasFirstArg(function, types) } + fun firstArgNot(vararg types: KClass<*>) = object : FunctionFilter() { + override val errorMessage: String + get() = "Function cannot have a first parameter of type: ${types.toTypesArrayString()}" + + override fun filter(function: Function): Boolean = function.nonInstanceParameters.none { param -> + val erasure = param.type.jvmErasure + // If an erasure of parameter is any of supplied types + types.any { erasure == it } + } + } + fun static() = object : FunctionFilter() { override val errorMessage: String get() = "Function must be static" diff --git a/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/core/hooks/EventDispatcherTests.kt b/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/core/hooks/EventDispatcherTests.kt index 9bf7ac8d84..32e1138b48 100644 --- a/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/core/hooks/EventDispatcherTests.kt +++ b/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/core/hooks/EventDispatcherTests.kt @@ -6,6 +6,8 @@ import dev.freya02.botcommands.method.accessors.internal.MethodAccessorFactory import io.github.freya022.botcommands.api.core.annotations.BEventListener import io.github.freya022.botcommands.api.core.config.BCoroutineScopesConfigBuilder import io.github.freya022.botcommands.api.core.events.BReadyEvent +import io.github.freya022.botcommands.api.core.service.ServiceContainer +import io.github.freya022.botcommands.api.core.service.getService import io.github.freya022.botcommands.internal.core.ClassPathFunction import io.github.freya022.botcommands.internal.core.hooks.EventDispatcherImpl import io.github.freya022.botcommands.internal.core.hooks.EventHandlerFunction @@ -40,10 +42,10 @@ object EventDispatcherTests { val expectedInstance = ReadyTestListener() val expectedFunction = ReadyTestListener::onReady val listenerRegistry = mockk { - every { get(BReadyEvent::class.java) } returns mockk { - every { get(any()) } returns emptyList() - every { get(BEventListener.RunMode.BLOCKING) } returns listOf( + every { get(BReadyEvent::class.java) } returns EventListenerList().apply { + add( EventHandlerFunction( + BReadyEvent::class.java, ClassPathFunction(expectedInstance, expectedFunction), priority = 0, runMode = BEventListener.RunMode.BLOCKING, @@ -54,7 +56,9 @@ object EventDispatcherTests { } } - val dispatcher = EventDispatcherImpl(BCoroutineScopesConfigBuilder().build(), listenerRegistry) + val dispatcher = EventDispatcherImpl(BCoroutineScopesConfigBuilder().build(), mockk { + every { getService() } returns listenerRegistry + }) assertThrows { dispatcher.dispatchEventJava(mockk()) } } diff --git a/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/core/hooks/EventListenerRegistryTests.kt b/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/core/hooks/EventListenerRegistryTests.kt new file mode 100644 index 0000000000..35406baa71 --- /dev/null +++ b/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/core/hooks/EventListenerRegistryTests.kt @@ -0,0 +1,198 @@ +package io.github.freya022.botcommands.core.hooks + +import io.github.freya022.botcommands.api.core.annotations.BEventListener +import io.github.freya022.botcommands.api.core.annotations.BEventListener.RunMode +import io.github.freya022.botcommands.api.core.config.BConfigBuilder +import io.github.freya022.botcommands.api.core.service.ServiceContainer +import io.github.freya022.botcommands.internal.core.ClassPathFunction +import io.github.freya022.botcommands.internal.core.hooks.EventListenerRegistry +import io.github.freya022.botcommands.internal.core.hooks.EventListenerRequirementsVerifier +import io.github.freya022.botcommands.internal.core.service.FunctionAnnotationsMap +import io.mockk.every +import io.mockk.mockk +import net.dv8tion.jda.api.events.Event +import net.dv8tion.jda.api.events.emoji.GenericEmojiEvent +import net.dv8tion.jda.api.events.emoji.update.EmojiUpdateNameEvent +import net.dv8tion.jda.api.events.emoji.update.GenericEmojiUpdateEvent +import net.dv8tion.jda.api.requests.GatewayIntent +import org.junit.jupiter.api.assertThrows +import kotlin.reflect.KFunction +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals + +object EventListenerRegistryTests { + + private val NOOP_VERIFIER = object : EventListenerRequirementsVerifier { + override fun verifyFor( + function: KFunction<*>, + locallySkipIntentChecks: Boolean, + locallySkippedIntents: Set, + eventErasure: Class<*> + ): Boolean = true + } + + @Test + fun `Listeners of Any are forbidden`() { + class TestListener { + fun onReady(@Suppress("unused") event: Any) {} + } + + val ex = assertThrows { + EventListenerRegistry( + BConfigBuilder().build(), + mockk(), + NOOP_VERIFIER, + mockk { + every { get() } returns listOf( + ClassPathFunction(TestListener(), TestListener::onReady), + ) + }, + ) + } + + assertContains(ex.message.orEmpty(), "Function cannot have a first parameter of type: [Object]") + } + + @Test + fun `Add event listener`() { + // In particular, this should test that adding an event listener of sub/super/same-type than an existing listener should work + // indicating the dispatch cache is cleared properly + + class A { + @BEventListener + fun foo(@Suppress("unused") event: GenericEmojiEvent) {} + } + + val registry = EventListenerRegistry( + BConfigBuilder().build(), + mockk(), + NOOP_VERIFIER, + mockk { + every { get() } returns listOf( + ClassPathFunction(A(), A::foo), + ) + }, + ) + + // Generate cache for this event + assertEquals(1, registry[GenericEmojiUpdateEvent::class.java][RunMode.SHARED]!!.size) + + // Make another listener and make sure the cache grows with the new listeners + class WithSameType { + @BEventListener + fun foo(@Suppress("unused") event: GenericEmojiEvent) {} + } + + class WithSubType { + @BEventListener + fun foo(@Suppress("unused") event: EmojiUpdateNameEvent) {} + } + + class WithSuperType { + @BEventListener + fun foo(@Suppress("unused") event: Event) {} + } + + registry.addEventListener(WithSameType()) + registry.addEventListener(WithSubType()) + registry.addEventListener(WithSuperType()) + + assertEquals(3, registry[GenericEmojiUpdateEvent::class.java][RunMode.SHARED]!!.size) + assertEquals(4, registry[EmojiUpdateNameEvent::class.java][RunMode.SHARED]!!.size) + } + + @Test + fun `Remove event listener`() { + // Make sure the event listener does not get fired anymore, but similar listeners of same classes (but diff instance) do + + class A { + @BEventListener + fun foo(@Suppress("unused") event: Event) {} + } + + val a1 = A() + val a2 = A() + + val registry = EventListenerRegistry( + BConfigBuilder().build(), + mockk(), + NOOP_VERIFIER, + mockk { + every { get() } returns listOf( + ClassPathFunction(a1, A::foo), + ClassPathFunction(a2, A::foo), + ) + }, + ) + + assertEquals(2, registry[Event::class.java][RunMode.SHARED]!!.size) + + registry.removeEventListener(a1) + + assertEquals(a2, registry[Event::class.java][RunMode.SHARED]!!.single().classPathFunction.instance) + } + + @Test + fun `JDA Events are dispatched to listeners of all subclasses`() { + class A { + @BEventListener + fun a(@Suppress("unused") event: EmojiUpdateNameEvent) {} + @BEventListener + fun b(@Suppress("unused") event: GenericEmojiUpdateEvent<*>) {} + @BEventListener + fun c(@Suppress("unused") event: GenericEmojiEvent) {} + @BEventListener + fun d(@Suppress("unused") event: Event) {} + } + + val registry = EventListenerRegistry( + BConfigBuilder().build(), + mockk(), + NOOP_VERIFIER, + mockk { + val instance = A() + every { get() } returns listOf( + ClassPathFunction(instance, A::a), + ClassPathFunction(instance, A::b), + ClassPathFunction(instance, A::c), + ClassPathFunction(instance, A::d), + ) + }, + ) + + assertEquals(4, registry[EmojiUpdateNameEvent::class.java][RunMode.SHARED]!!.size) + } + + @Test + fun `Custom events are dispatched to listeners of all subclasses`() { + abstract class MyGenericEvent + abstract class MyGenericUpdateEvent : MyGenericEvent() + class MyEvent : MyGenericUpdateEvent() + + class A { + @BEventListener + fun a(@Suppress("unused") event: MyEvent) {} + @BEventListener + fun b(@Suppress("unused") event: MyGenericUpdateEvent) {} + @BEventListener + fun c(@Suppress("unused") event: MyGenericEvent) {} + } + + val registry = EventListenerRegistry( + BConfigBuilder().build(), + mockk(), + NOOP_VERIFIER, + mockk { + val instance = A() + every { get() } returns listOf( + ClassPathFunction(instance, A::a), + ClassPathFunction(instance, A::b), + ClassPathFunction(instance, A::c), + ) + }, + ) + + assertEquals(3, registry[MyEvent::class.java][RunMode.SHARED]!!.size) + } +} diff --git a/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/core/hooks/EventListenerRequirementsVerifierTests.kt b/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/core/hooks/EventListenerRequirementsVerifierTests.kt new file mode 100644 index 0000000000..706f45f916 --- /dev/null +++ b/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/core/hooks/EventListenerRequirementsVerifierTests.kt @@ -0,0 +1,138 @@ +package io.github.freya022.botcommands.core.hooks + +import io.github.freya022.botcommands.api.core.JDAService +import io.github.freya022.botcommands.api.core.config.BConfigBuilder +import io.github.freya022.botcommands.api.core.events.BGenericEvent +import io.github.freya022.botcommands.api.core.hooks.custom.CustomEventRequirements +import io.github.freya022.botcommands.api.core.hooks.custom.CustomEventRequirementsProvider +import io.github.freya022.botcommands.internal.core.hooks.EventListenerRequirementsVerifierImpl +import io.mockk.every +import io.mockk.mockk +import net.dv8tion.jda.api.events.GenericEvent +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import kotlin.reflect.KFunction +import kotlin.test.Test +import kotlin.test.assertEquals + +object EventListenerRequirementsVerifierTests { + + @Test + fun `Custom event requirement provider should be checked for implementation correctness`() { + // Make sure all CustomEventRequirementsProvider do not return requirements for other events than theirs + + class CorrectProvider : CustomEventRequirementsProvider { + override fun get(handledEventType: Class<*>): CustomEventRequirements = CustomEventRequirements.unknown() + } + + class WrongProvider : CustomEventRequirementsProvider { + override fun get(handledEventType: Class<*>): CustomEventRequirements = CustomEventRequirements.none() + } + + val ex = assertThrows { + EventListenerRequirementsVerifierImpl( + BConfigBuilder().build(), + mockk(), + customEventRequirementsProviders = listOf(CorrectProvider(), WrongProvider()), + ) + } + + assertEquals( + $$"Custom event requirement providers are required to return 'CustomEventRequirements.unknown()' on unhandled events, and 'i.g.f.b.c.h.EventListenerRequirementsVerifierTests$Custom event requirement provider should be checked for implementation correctness$WrongProvider' fails that", + ex.message, + ) + + assertDoesNotThrow { + EventListenerRequirementsVerifierImpl( + BConfigBuilder().build(), + mockk(), + customEventRequirementsProviders = listOf(CorrectProvider()), + ) + } + + assertDoesNotThrow { + EventListenerRequirementsVerifierImpl( + BConfigBuilder().build(), + mockk(), + customEventRequirementsProviders = emptyList(), + ) + } + } + + @Test + fun `Throw when multiple requirements providers return known requirements`() { + class MyEvent + + class Provider : CustomEventRequirementsProvider { + override fun get(handledEventType: Class<*>): CustomEventRequirements { + if (handledEventType == MyEvent::class.java) { + return CustomEventRequirements.none() + } + return CustomEventRequirements.unknown() + } + } + + class A { + fun foo(@Suppress("UNUSED_PARAMETER") event: MyEvent) {} + } + + val ex = assertThrows { + val verifier = EventListenerRequirementsVerifierImpl( + BConfigBuilder().build(), + mockk { + every { intents } returns setOf() + }, + customEventRequirementsProviders = listOf(Provider(), Provider()), + ) + + verifier.verifyFor(A::foo, locallySkipIntentChecks = false, locallySkippedIntents = emptySet(), MyEvent::class.java) + } + + assertEquals( + true, + ex.message?.startsWith($$"Multiple CustomEventRequirementsProvider returned requirements for 'i.g.f.b.c.h.EventListenerRequirementsVerifierTests$Throw when multiple requirements providers return known requirements$MyEvent'") + ) + } + + @ParameterizedTest + @MethodSource("customEventsWithIllegalSubclass") + fun `Custom events cannot extend JDA and BC events`(function: KFunction<*>, eventType: Class<*>, unexpectedEventSubclass: Class<*>) { + class Provider : CustomEventRequirementsProvider { + override fun get(handledEventType: Class<*>): CustomEventRequirements = CustomEventRequirements.unknown() + } + + val ex = assertThrows { + val verifier = EventListenerRequirementsVerifierImpl( + BConfigBuilder().build(), + mockk { + every { intents } returns setOf() + }, + customEventRequirementsProviders = listOf(Provider()), + ) + + verifier.verifyFor(function, locallySkipIntentChecks = false, locallySkippedIntents = emptySet(), eventType) + } + + assertEquals("Custom events must not implement ${unexpectedEventSubclass.name}!", ex.message) + } + + @JvmStatic + fun customEventsWithIllegalSubclass(): List { + abstract class JdaEvent : GenericEvent + abstract class BcEvent : BGenericEvent + + class A { + fun jda(@Suppress("UNUSED_PARAMETER") event: JdaEvent) {} + + fun bc(@Suppress("UNUSED_PARAMETER") event: BcEvent) {} + } + + return listOf( + Arguments.argumentSet("JDA", A::jda, JdaEvent::class.java, GenericEvent::class.java), + Arguments.argumentSet("BC", A::bc, BcEvent::class.java, BGenericEvent::class.java), + ) + } +}