From 4411abfcd4064aa92d76bd81c4cbcdbc9edb719c Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:16:08 +0200 Subject: [PATCH 01/17] Rework how event listeners are filtered by incoming event type Previously, all event types were known, and so we were able to group the handlers by what they were listening to However this required scanning the events present at runtime, which not only took some time, but also prevents using custom events The new approach instead computes the handlers eligible for a certain event when it is first fired, this performs slightly better and works for any event type and simplifies the logic for adding/removing listeners --- .../core/hooks/EventDispatcherImpl.kt | 9 ++- .../core/hooks/EventHandlerFunction.kt | 1 + .../internal/core/hooks/EventListenerList.kt | 20 +++--- .../core/hooks/EventListenerRegistry.kt | 61 ++++++++++--------- .../internal/core/hooks/EventTreeService.kt | 31 ---------- .../core/hooks/EventDispatcherTests.kt | 1 + 6 files changed, 48 insertions(+), 75 deletions(-) delete mode 100644 BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventTreeService.kt 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..82570a1af7 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 @@ -29,7 +29,8 @@ internal class EventDispatcherImpl internal constructor( 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 +64,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 +88,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..bf2a5884ef 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 @@ -10,7 +10,6 @@ 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.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 @@ -35,18 +34,16 @@ private val logger = KotlinLogging.logger { } internal class EventListenerRegistry internal constructor( private val config: BConfig, private val serviceContainer: ServiceContainer, - private val eventTreeService: EventTreeService, private val jdaService: JDAService, 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 +51,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,17 +72,17 @@ 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) } } @@ -113,7 +121,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 +132,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/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/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..b1e4a196c3 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 @@ -44,6 +44,7 @@ object EventDispatcherTests { every { get(any()) } returns emptyList() every { get(BEventListener.RunMode.BLOCKING) } returns listOf( EventHandlerFunction( + BReadyEvent::class.java, ClassPathFunction(expectedInstance, expectedFunction), priority = 0, runMode = BEventListener.RunMode.BLOCKING, From b94baff6d6b3b9530e631f8bd924316b9eccba9f Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:16:56 +0200 Subject: [PATCH 02/17] EventDispatcherTests: Don't mock `EventListenerList` It is unnecessary --- .../freya022/botcommands/core/hooks/EventDispatcherTests.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 b1e4a196c3..f325859d51 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 @@ -40,9 +40,8 @@ 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), From f06056516b1589a57f29d015d5c07dd8d9979eef Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:17:23 +0200 Subject: [PATCH 03/17] EventListenerRegistry: Test `Any` handlers are forbidden --- .../core/hooks/EventListenerRegistryTests.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/core/hooks/EventListenerRegistryTests.kt 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..8caad45ca8 --- /dev/null +++ b/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/core/hooks/EventListenerRegistryTests.kt @@ -0,0 +1,38 @@ +package io.github.freya022.botcommands.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.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.service.FunctionAnnotationsMap +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.assertThrows +import kotlin.test.Test +import kotlin.test.assertContains + +object EventListenerRegistryTests { + @Test + fun `Listeners of Any are forbidden`() { + class TestListener { + fun onReady(@Suppress("unused") event: Any) {} + } + + val ex = assertThrows { + EventListenerRegistry( + BConfigBuilder().build(), + mockk(), + mockk(), + mockk { + every { get() } returns listOf( + ClassPathFunction(TestListener(), TestListener::onReady), + ) + }, + ) + } + + assertContains(ex.message.orEmpty(), "Function must have a first parameter with a superclass of: [GenericEvent, BGenericEvent]") + } +} From 1f9977d3b717042fc37d78626b865d22c1a9c746 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:15:00 +0200 Subject: [PATCH 04/17] `ClassPathFunction#equals`: Check instance is the same reference --- .../freya022/botcommands/internal/core/ClassPathFunction.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 { From 840ff4e8f1fa6ef25aed71993da396b4f1b0fd5c Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:25:05 +0200 Subject: [PATCH 05/17] test adding/removing listeners, superclass dispatch --- .../core/hooks/EventListenerRegistryTests.kt | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) 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 index 8caad45ca8..e29cb20d3f 100644 --- 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 @@ -2,6 +2,7 @@ package io.github.freya022.botcommands.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.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 @@ -9,9 +10,15 @@ import io.github.freya022.botcommands.internal.core.hooks.EventListenerRegistry 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.test.Test import kotlin.test.assertContains +import kotlin.test.assertEquals object EventListenerRegistryTests { @Test @@ -35,4 +42,120 @@ object EventListenerRegistryTests { assertContains(ex.message.orEmpty(), "Function must have a first parameter with a superclass of: [GenericEvent, BGenericEvent]") } + + @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(), + mockk { + every { intents } returns setOf(GatewayIntent.GUILD_EXPRESSIONS) + }, + 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(), + mockk { + every { intents } returns setOf() + }, + 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(), + mockk { + every { intents } returns setOf(GatewayIntent.GUILD_EXPRESSIONS) + }, + 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) + } } From f27bc5b20f4eb619a709d5051df54533bcaeeed8 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:39:41 +0200 Subject: [PATCH 06/17] Allow non-`Any` event listener --- .../core/hooks/EventListenerRegistry.kt | 2 +- .../internal/utils/FunctionFilter.kt | 11 ++++++ .../core/hooks/EventListenerRegistryTests.kt | 36 ++++++++++++++++++- 3 files changed, 47 insertions(+), 2 deletions(-) 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 bf2a5884ef..1d77339eba 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 @@ -88,7 +88,7 @@ internal class EventListenerRegistry internal constructor( 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 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/EventListenerRegistryTests.kt b/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/core/hooks/EventListenerRegistryTests.kt index e29cb20d3f..a6866f6422 100644 --- 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 @@ -40,7 +40,7 @@ object EventListenerRegistryTests { ) } - assertContains(ex.message.orEmpty(), "Function must have a first parameter with a superclass of: [GenericEvent, BGenericEvent]") + assertContains(ex.message.orEmpty(), "Function cannot have a first parameter of type: [Object]") } @Test @@ -158,4 +158,38 @@ object EventListenerRegistryTests { 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(), + mockk { + every { intents } returns setOf() + }, + 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) + } } From 674c11eeb18681bff65cb23deb5348be4066a76d Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:37:29 +0200 Subject: [PATCH 07/17] Add custom event requirements provider, docs, opt-in annotation --- BotCommands-core/build.gradle.kts | 9 ++ .../api/core/annotations/BEventListener.kt | 7 +- .../api/core/hooks/EventDispatcher.kt | 2 +- .../hooks/custom/CustomEventRequirements.kt | 64 +++++++++++ .../custom/CustomEventRequirementsProvider.kt | 29 +++++ .../annotations/ExperimentalCustomEvents.kt | 41 +++++++ .../botcommands/api/core/utils/Collections.kt | 6 + .../core/hooks/EventListenerRegistry.kt | 87 ++++++++++++--- .../custom/CustomEventRequirementsImpl.kt | 21 ++++ .../custom/EmptyCustomEventRequirements.kt | 15 +++ .../custom/UnknownCustomEventRequirements.kt | 15 +++ .../core/hooks/EventListenerRegistryTests.kt | 104 ++++++++++++++++++ 12 files changed, 382 insertions(+), 18 deletions(-) create mode 100644 BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/custom/CustomEventRequirements.kt create mode 100644 BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/custom/CustomEventRequirementsProvider.kt create mode 100644 BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/custom/annotations/ExperimentalCustomEvents.kt create mode 100644 BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/custom/CustomEventRequirementsImpl.kt create mode 100644 BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/custom/EmptyCustomEventRequirements.kt create mode 100644 BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/custom/UnknownCustomEventRequirements.kt 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/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..0a77baba8d 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 JDA and BC events to [@BEventListener][BEventListener] methods. Custom events are also supported. */ @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..a912453ba3 --- /dev/null +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/custom/CustomEventRequirements.kt @@ -0,0 +1,64 @@ +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.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(intents: Array): 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 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..607e1fcb25 --- /dev/null +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/custom/CustomEventRequirementsProvider.kt @@ -0,0 +1,29 @@ +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. + * + * @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..d006022673 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/hooks/EventListenerRegistry.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventListenerRegistry.kt index 1d77339eba..e2101d1d62 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 @@ -3,12 +3,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.events.BEvent +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.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.* import io.github.freya022.botcommands.internal.core.ClassPathFunction import io.github.freya022.botcommands.internal.core.requiredFilter import io.github.freya022.botcommands.internal.core.service.FunctionAnnotationsMap @@ -19,9 +19,9 @@ 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.KFunction import kotlin.reflect.full.functions import kotlin.reflect.jvm.jvmErasure import kotlin.time.Duration @@ -31,10 +31,12 @@ import kotlin.time.toDurationUnit private val logger = KotlinLogging.logger { } @BService +@OptIn(ExperimentalCustomEvents::class) internal class EventListenerRegistry internal constructor( private val config: BConfig, private val serviceContainer: ServiceContainer, private val jdaService: JDAService, + private val customEventRequirementsProviders: List, functionAnnotationsMap: FunctionAnnotationsMap, ) { @@ -46,6 +48,15 @@ internal class EventListenerRegistry internal constructor( private val resolvedListeners: MutableMap, EventListenerList> = ConcurrentHashMap() 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" + } + } + functionAnnotationsMap .get() .addAsEventListeners() @@ -97,16 +108,9 @@ 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 + if (!annotation.ignoreIntents && !checkIntents(function, eventErasure, annotation.ignoredIntents.toEnumSet())) { + return@forEach } val eventParameters = parameters.drop(1) @@ -137,6 +141,57 @@ internal class EventListenerRegistry internal constructor( listenerFunctionMap.merge(classPathFunc.instance, listOf(eventHandlerFunction), List::plus) } + private fun checkIntents(function: KFunction<*>, eventErasure: Class<*>, ignoredIntents: Set): Boolean { + fun getMissingIntents(requiredIntents: Set): Set { + return requiredIntents - jdaService.intents - config.ignoredIntents - ignoredIntents + } + + if (eventErasure.isSubclassOf()) { + @Suppress("UNCHECKED_CAST") + val requiredIntents = GatewayIntent.fromEvents(eventErasure as Class) + val missingIntents = getMissingIntents(requiredIntents) + 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()) { + if (customEventRequirementsProviders.isEmpty()) { + throwState("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()) + 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 }}") + } + } + + return true + } + private fun getTimeout(annotation: BEventListener): Duration? { if (annotation.timeout < 0) return Duration.INFINITE @@ -144,4 +199,6 @@ internal class EventListenerRegistry internal constructor( it.takeIfFinite() ?: defaultTimeout.takeIfFinite() } } + + private interface FakeEvent } 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/test/kotlin/io/github/freya022/botcommands/core/hooks/EventListenerRegistryTests.kt b/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/core/hooks/EventListenerRegistryTests.kt index a6866f6422..4f32b49d44 100644 --- 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 @@ -4,7 +4,10 @@ import io.github.freya022.botcommands.api.core.JDAService 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.hooks.custom.CustomEventRequirements +import io.github.freya022.botcommands.api.core.hooks.custom.CustomEventRequirementsProvider import io.github.freya022.botcommands.api.core.service.ServiceContainer +import io.github.freya022.botcommands.api.core.utils.isSubclassOf import io.github.freya022.botcommands.internal.core.ClassPathFunction import io.github.freya022.botcommands.internal.core.hooks.EventListenerRegistry import io.github.freya022.botcommands.internal.core.service.FunctionAnnotationsMap @@ -15,6 +18,7 @@ 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.assertDoesNotThrow import org.junit.jupiter.api.assertThrows import kotlin.test.Test import kotlin.test.assertContains @@ -32,6 +36,7 @@ object EventListenerRegistryTests { BConfigBuilder().build(), mockk(), mockk(), + customEventRequirementsProviders = emptyList(), mockk { every { get() } returns listOf( ClassPathFunction(TestListener(), TestListener::onReady), @@ -59,6 +64,7 @@ object EventListenerRegistryTests { mockk { every { intents } returns setOf(GatewayIntent.GUILD_EXPRESSIONS) }, + customEventRequirementsProviders = emptyList(), mockk { every { get() } returns listOf( ClassPathFunction(A(), A::foo), @@ -111,6 +117,7 @@ object EventListenerRegistryTests { mockk { every { intents } returns setOf() }, + customEventRequirementsProviders = emptyList(), mockk { every { get() } returns listOf( ClassPathFunction(a1, A::foo), @@ -145,6 +152,7 @@ object EventListenerRegistryTests { mockk { every { intents } returns setOf(GatewayIntent.GUILD_EXPRESSIONS) }, + customEventRequirementsProviders = emptyList(), mockk { val instance = A() every { get() } returns listOf( @@ -180,6 +188,14 @@ object EventListenerRegistryTests { mockk { every { intents } returns setOf() }, + customEventRequirementsProviders = listOf(object : CustomEventRequirementsProvider { + override fun get(handledEventType: Class<*>): CustomEventRequirements { + if (handledEventType.isSubclassOf()) { + return CustomEventRequirements.none() + } + return CustomEventRequirements.unknown() + } + }), mockk { val instance = A() every { get() } returns listOf( @@ -192,4 +208,92 @@ object EventListenerRegistryTests { assertEquals(3, registry[MyEvent::class.java][RunMode.SHARED]!!.size) } + + @Test + fun `Custom event requirement provider should be checked for implementation correctness`() { + // Make sure the registry tests 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 { + EventListenerRegistry( + BConfigBuilder().build(), + mockk(), + mockk(), + customEventRequirementsProviders = listOf(CorrectProvider(), WrongProvider()), + mockk(), + ) + } + + assertEquals( + $$"Custom event requirement providers are required to return 'CustomEventRequirements.unknown()' on unhandled events, and 'i.g.f.b.c.h.EventListenerRegistryTests$Custom event requirement provider should be checked for implementation correctness$WrongProvider' fails that", + ex.message, + ) + + assertDoesNotThrow { + EventListenerRegistry( + BConfigBuilder().build(), + mockk(), + mockk(), + customEventRequirementsProviders = listOf(CorrectProvider()), + mockk(relaxed = true), + ) + } + + assertDoesNotThrow { + EventListenerRegistry( + BConfigBuilder().build(), + mockk(), + mockk(), + customEventRequirementsProviders = listOf(), + mockk(relaxed = true), + ) + } + } + + @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 { + @BEventListener + fun foo(@Suppress("UNUSED_PARAMETER") event: MyEvent) {} + } + + val ex = assertThrows { + EventListenerRegistry( + BConfigBuilder().build(), + mockk(), + mockk { + every { intents } returns setOf() + }, + customEventRequirementsProviders = listOf(Provider(), Provider()), + mockk { + every { get() } returns listOf( + ClassPathFunction(A(), A::foo), + ) + }, + ) + } + + assertEquals( + true, + ex.message?.startsWith($$"Multiple CustomEventRequirementsProvider returned requirements for 'i.g.f.b.c.h.EventListenerRegistryTests$Throw when multiple requirements providers return known requirements$MyEvent'") + ) + } } From f68bdbc5f0a37ed142e4ba330eecd910f0cf17f9 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:20:28 +0200 Subject: [PATCH 08/17] Pass `EventListenerRegistry` lazily to `EventDispatcherImpl` Would have caused a circular dependency when a user attempts to inject an EventDispatcher in an event listener class --- .../internal/core/hooks/EventDispatcherImpl.kt | 5 ++++- .../internal/core/service/LazyServiceImpl.kt | 13 ++++++++++++- .../botcommands/core/hooks/EventDispatcherTests.kt | 3 ++- 3 files changed, 18 insertions(+), 3 deletions(-) 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 82570a1af7..655f2d7edc 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,6 +5,7 @@ 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.LazyService import io.github.freya022.botcommands.api.core.service.annotations.BService import io.github.freya022.botcommands.api.core.utils.loggerOf import io.github.freya022.botcommands.api.core.utils.simpleNestedName @@ -21,12 +22,14 @@ private val logger = KotlinLogging.loggerOf() @BService internal class EventDispatcherImpl internal constructor( coroutineScopesConfig: BCoroutineScopesConfig, - private val eventListenerRegistry: EventListenerRegistry, + eventListenerRegistry: LazyService, ) : EventDispatcher() { private val eventManagerCoroutineScope: CoroutineScope = coroutineScopesConfig.eventManagerScope private val asyncCoroutineScope: CoroutineScope = coroutineScopesConfig.eventDispatcherScope + private val eventListenerRegistry: EventListenerRegistry by eventListenerRegistry + 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] diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/LazyServiceImpl.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/LazyServiceImpl.kt index 1cc7a4738d..34a0cb72f8 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/LazyServiceImpl.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/LazyServiceImpl.kt @@ -119,6 +119,15 @@ private class FallbackLazyServiceImpl( } } +private class CompletedLazyService(override val value: T) : LazyService { + + override fun canCreateService(): Boolean = true + + override fun getServiceError(): ServiceError? = null + + override fun isInitialized(): Boolean = true +} + internal fun ServiceContainer.implicitlyNamedLazyService(clazz: KClass, name: String?): LazyService = ImplicitNamedLazyServiceImpl(this, clazz, name) @@ -128,4 +137,6 @@ internal fun ServiceContainer.lazyService(clazz: KClass, name: Stri @PublishedApi internal fun ServiceContainer.lazyServiceOrElse(clazz: KClass, name: String?, block: () -> R): LazyService = - FallbackLazyServiceImpl(this, clazz, name, block) \ No newline at end of file + FallbackLazyServiceImpl(this, clazz, name, block) + +internal fun lazyServiceOf(value: T): LazyService = CompletedLazyService(value) 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 f325859d51..bb7c4f04ed 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 @@ -12,6 +12,7 @@ import io.github.freya022.botcommands.internal.core.hooks.EventHandlerFunction import io.github.freya022.botcommands.internal.core.hooks.EventListenerList import io.github.freya022.botcommands.internal.core.hooks.EventListenerRegistry import io.github.freya022.botcommands.internal.core.method.accessors.MethodAccessorFactoryProvider +import io.github.freya022.botcommands.internal.core.service.lazyServiceOf import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -54,7 +55,7 @@ object EventDispatcherTests { } } - val dispatcher = EventDispatcherImpl(BCoroutineScopesConfigBuilder().build(), listenerRegistry) + val dispatcher = EventDispatcherImpl(BCoroutineScopesConfigBuilder().build(), lazyServiceOf(listenerRegistry)) assertThrows { dispatcher.dispatchEventJava(mockk()) } } From de06ab09de935a8694fe856387810ee21409b373 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:07:26 +0200 Subject: [PATCH 09/17] Add more factories for `CustomEventRequirements` --- .../hooks/custom/CustomEventRequirements.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) 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 index a912453ba3..989e5cfe95 100644 --- 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 @@ -5,6 +5,7 @@ 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 /** @@ -49,6 +50,36 @@ interface CustomEventRequirements { 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 + 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. */ From c2292271618162983b3a281f1c34fad619f3775f Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:08:45 +0200 Subject: [PATCH 10/17] Refactor --- .../botcommands/internal/core/hooks/EventListenerRegistry.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 e2101d1d62..4d8541a00e 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 @@ -157,8 +157,8 @@ internal class EventListenerRegistry internal constructor( // Cannot check for RawGatewayEvent as JDA is not present yet and there is no config for it } else if (!eventErasure.isSubclassOf()) { - if (customEventRequirementsProviders.isEmpty()) { - throwState("No ${classRef()} are available (custom event listener at ${function.shortSignature})") + check(customEventRequirementsProviders.isNotEmpty()) { + "No ${classRef()} are available (custom event listener at ${function.shortSignature})" } val passedRequirementProviders = ArrayList(1) From d27838679c98fde0dfae307cbbfca0c258c18990 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:38:03 +0200 Subject: [PATCH 11/17] Check custom events don't extend ``(B)GenericEvent` It's an assumption in some places --- .../core/hooks/EventListenerRegistry.kt | 12 +++++ .../core/hooks/EventListenerRegistryTests.kt | 51 +++++++++++++++++++ 2 files changed, 63 insertions(+) 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 4d8541a00e..4bf73c1eba 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 @@ -4,6 +4,7 @@ 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.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.ServiceContainer @@ -19,6 +20,7 @@ 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.KFunction @@ -109,6 +111,16 @@ internal class EventListenerRegistry internal constructor( val parameters = function.nonInstanceParameters val eventErasure: Class = parameters.first().type.jvmErasure.java + 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}!" + } + } + if (!annotation.ignoreIntents && !checkIntents(function, eventErasure, annotation.ignoredIntents.toEnumSet())) { return@forEach } 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 index 4f32b49d44..e581fe9c37 100644 --- 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 @@ -4,6 +4,7 @@ import io.github.freya022.botcommands.api.core.JDAService 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.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.api.core.service.ServiceContainer @@ -14,12 +15,17 @@ import io.github.freya022.botcommands.internal.core.service.FunctionAnnotationsM import io.mockk.every import io.mockk.mockk import net.dv8tion.jda.api.events.Event +import net.dv8tion.jda.api.events.GenericEvent 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.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.assertContains import kotlin.test.assertEquals @@ -296,4 +302,49 @@ object EventListenerRegistryTests { ex.message?.startsWith($$"Multiple CustomEventRequirementsProvider returned requirements for 'i.g.f.b.c.h.EventListenerRegistryTests$Throw when multiple requirements providers return known requirements$MyEvent'") ) } + + @ParameterizedTest + @MethodSource("customEventsWithIllegalSubclass") + fun `Custom events cannot extend JDA and BC events`(instance: Any, function: KFunction<*>, eventType: Class<*>) { + class Provider : CustomEventRequirementsProvider { + override fun get(handledEventType: Class<*>): CustomEventRequirements = CustomEventRequirements.unknown() + } + + val ex = assertThrows { + EventListenerRegistry( + BConfigBuilder().build(), + mockk(), + mockk { + every { intents } returns setOf() + }, + customEventRequirementsProviders = listOf(Provider()), + mockk { + every { get() } returns listOf( + ClassPathFunction(instance, function), + ) + }, + ) + } + + assertEquals("Custom events must not implement ${eventType.name}!", ex.message) + } + + @JvmStatic + fun customEventsWithIllegalSubclass(): List { + abstract class JdaEvent : GenericEvent + abstract class BcEvent : BGenericEvent + + class A { + @BEventListener + fun jda(@Suppress("UNUSED_PARAMETER") event: JdaEvent) {} + + @BEventListener + fun bc(@Suppress("UNUSED_PARAMETER") event: BcEvent) {} + } + + return listOf( + Arguments.argumentSet("JDA", A(), A::jda, GenericEvent::class.java), + Arguments.argumentSet("BC", A(), A::bc, BGenericEvent::class.java), + ) + } } From 5403c2b478b12e325cf4d22835b2a1aaff57179e Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:51:07 +0200 Subject: [PATCH 12/17] Separate verifying event listener requirements Makes it easier to test and declutters the registry service --- .../core/hooks/EventListenerRegistry.kt | 97 +-------- .../EventListenerRequirementsVerifier.kt | 134 +++++++++++++ .../core/hooks/EventListenerRegistryTests.kt | 184 ++---------------- .../EventListenerRequirementsVerifierTests.kt | 138 +++++++++++++ 4 files changed, 293 insertions(+), 260 deletions(-) create mode 100644 BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventListenerRequirementsVerifier.kt create mode 100644 BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/core/hooks/EventListenerRequirementsVerifierTests.kt 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 4bf73c1eba..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,15 +1,11 @@ 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.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.ServiceContainer import io.github.freya022.botcommands.api.core.service.annotations.BService -import io.github.freya022.botcommands.api.core.utils.* +import io.github.freya022.botcommands.api.core.utils.findAnnotationRecursive +import io.github.freya022.botcommands.api.core.utils.toEnumSet import io.github.freya022.botcommands.internal.core.ClassPathFunction import io.github.freya022.botcommands.internal.core.requiredFilter import io.github.freya022.botcommands.internal.core.service.FunctionAnnotationsMap @@ -18,27 +14,18 @@ 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.KFunction import kotlin.reflect.full.functions import kotlin.reflect.jvm.jvmErasure import kotlin.time.Duration import kotlin.time.toDuration import kotlin.time.toDurationUnit -private val logger = KotlinLogging.logger { } - @BService -@OptIn(ExperimentalCustomEvents::class) internal class EventListenerRegistry internal constructor( - private val config: BConfig, + config: BConfig, private val serviceContainer: ServiceContainer, - private val jdaService: JDAService, - private val customEventRequirementsProviders: List, + private val requirementsVerifier: EventListenerRequirementsVerifier, functionAnnotationsMap: FunctionAnnotationsMap, ) { @@ -50,15 +37,6 @@ internal class EventListenerRegistry internal constructor( private val resolvedListeners: MutableMap, EventListenerList> = ConcurrentHashMap() 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" - } - } - functionAnnotationsMap .get() .addAsEventListeners() @@ -111,19 +89,7 @@ internal class EventListenerRegistry internal constructor( val parameters = function.nonInstanceParameters val eventErasure: Class = parameters.first().type.jvmErasure.java - 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}!" - } - } - - if (!annotation.ignoreIntents && !checkIntents(function, eventErasure, annotation.ignoredIntents.toEnumSet())) { - return@forEach - } + 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, @@ -153,57 +119,6 @@ internal class EventListenerRegistry internal constructor( listenerFunctionMap.merge(classPathFunc.instance, listOf(eventHandlerFunction), List::plus) } - private fun checkIntents(function: KFunction<*>, eventErasure: Class<*>, ignoredIntents: Set): Boolean { - fun getMissingIntents(requiredIntents: Set): Set { - return requiredIntents - jdaService.intents - config.ignoredIntents - ignoredIntents - } - - if (eventErasure.isSubclassOf()) { - @Suppress("UNCHECKED_CAST") - val requiredIntents = GatewayIntent.fromEvents(eventErasure as Class) - val missingIntents = getMissingIntents(requiredIntents) - 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()) { - 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()) - 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 }}") - } - } - - return true - } - private fun getTimeout(annotation: BEventListener): Duration? { if (annotation.timeout < 0) return Duration.INFINITE @@ -211,6 +126,4 @@ internal class EventListenerRegistry internal constructor( it.takeIfFinite() ?: defaultTimeout.takeIfFinite() } } - - private interface FakeEvent } 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/test/kotlin/io/github/freya022/botcommands/core/hooks/EventListenerRegistryTests.kt b/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/core/hooks/EventListenerRegistryTests.kt index e581fe9c37..35406baa71 100644 --- 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 @@ -1,36 +1,37 @@ package io.github.freya022.botcommands.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.annotations.BEventListener.RunMode 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.api.core.service.ServiceContainer -import io.github.freya022.botcommands.api.core.utils.isSubclassOf 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.GenericEvent 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.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.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 { @@ -41,8 +42,7 @@ object EventListenerRegistryTests { EventListenerRegistry( BConfigBuilder().build(), mockk(), - mockk(), - customEventRequirementsProviders = emptyList(), + NOOP_VERIFIER, mockk { every { get() } returns listOf( ClassPathFunction(TestListener(), TestListener::onReady), @@ -67,10 +67,7 @@ object EventListenerRegistryTests { val registry = EventListenerRegistry( BConfigBuilder().build(), mockk(), - mockk { - every { intents } returns setOf(GatewayIntent.GUILD_EXPRESSIONS) - }, - customEventRequirementsProviders = emptyList(), + NOOP_VERIFIER, mockk { every { get() } returns listOf( ClassPathFunction(A(), A::foo), @@ -120,10 +117,7 @@ object EventListenerRegistryTests { val registry = EventListenerRegistry( BConfigBuilder().build(), mockk(), - mockk { - every { intents } returns setOf() - }, - customEventRequirementsProviders = emptyList(), + NOOP_VERIFIER, mockk { every { get() } returns listOf( ClassPathFunction(a1, A::foo), @@ -155,10 +149,7 @@ object EventListenerRegistryTests { val registry = EventListenerRegistry( BConfigBuilder().build(), mockk(), - mockk { - every { intents } returns setOf(GatewayIntent.GUILD_EXPRESSIONS) - }, - customEventRequirementsProviders = emptyList(), + NOOP_VERIFIER, mockk { val instance = A() every { get() } returns listOf( @@ -191,17 +182,7 @@ object EventListenerRegistryTests { val registry = EventListenerRegistry( BConfigBuilder().build(), mockk(), - mockk { - every { intents } returns setOf() - }, - customEventRequirementsProviders = listOf(object : CustomEventRequirementsProvider { - override fun get(handledEventType: Class<*>): CustomEventRequirements { - if (handledEventType.isSubclassOf()) { - return CustomEventRequirements.none() - } - return CustomEventRequirements.unknown() - } - }), + NOOP_VERIFIER, mockk { val instance = A() every { get() } returns listOf( @@ -214,137 +195,4 @@ object EventListenerRegistryTests { assertEquals(3, registry[MyEvent::class.java][RunMode.SHARED]!!.size) } - - @Test - fun `Custom event requirement provider should be checked for implementation correctness`() { - // Make sure the registry tests 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 { - EventListenerRegistry( - BConfigBuilder().build(), - mockk(), - mockk(), - customEventRequirementsProviders = listOf(CorrectProvider(), WrongProvider()), - mockk(), - ) - } - - assertEquals( - $$"Custom event requirement providers are required to return 'CustomEventRequirements.unknown()' on unhandled events, and 'i.g.f.b.c.h.EventListenerRegistryTests$Custom event requirement provider should be checked for implementation correctness$WrongProvider' fails that", - ex.message, - ) - - assertDoesNotThrow { - EventListenerRegistry( - BConfigBuilder().build(), - mockk(), - mockk(), - customEventRequirementsProviders = listOf(CorrectProvider()), - mockk(relaxed = true), - ) - } - - assertDoesNotThrow { - EventListenerRegistry( - BConfigBuilder().build(), - mockk(), - mockk(), - customEventRequirementsProviders = listOf(), - mockk(relaxed = true), - ) - } - } - - @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 { - @BEventListener - fun foo(@Suppress("UNUSED_PARAMETER") event: MyEvent) {} - } - - val ex = assertThrows { - EventListenerRegistry( - BConfigBuilder().build(), - mockk(), - mockk { - every { intents } returns setOf() - }, - customEventRequirementsProviders = listOf(Provider(), Provider()), - mockk { - every { get() } returns listOf( - ClassPathFunction(A(), A::foo), - ) - }, - ) - } - - assertEquals( - true, - ex.message?.startsWith($$"Multiple CustomEventRequirementsProvider returned requirements for 'i.g.f.b.c.h.EventListenerRegistryTests$Throw when multiple requirements providers return known requirements$MyEvent'") - ) - } - - @ParameterizedTest - @MethodSource("customEventsWithIllegalSubclass") - fun `Custom events cannot extend JDA and BC events`(instance: Any, function: KFunction<*>, eventType: Class<*>) { - class Provider : CustomEventRequirementsProvider { - override fun get(handledEventType: Class<*>): CustomEventRequirements = CustomEventRequirements.unknown() - } - - val ex = assertThrows { - EventListenerRegistry( - BConfigBuilder().build(), - mockk(), - mockk { - every { intents } returns setOf() - }, - customEventRequirementsProviders = listOf(Provider()), - mockk { - every { get() } returns listOf( - ClassPathFunction(instance, function), - ) - }, - ) - } - - assertEquals("Custom events must not implement ${eventType.name}!", ex.message) - } - - @JvmStatic - fun customEventsWithIllegalSubclass(): List { - abstract class JdaEvent : GenericEvent - abstract class BcEvent : BGenericEvent - - class A { - @BEventListener - fun jda(@Suppress("UNUSED_PARAMETER") event: JdaEvent) {} - - @BEventListener - fun bc(@Suppress("UNUSED_PARAMETER") event: BcEvent) {} - } - - return listOf( - Arguments.argumentSet("JDA", A(), A::jda, GenericEvent::class.java), - Arguments.argumentSet("BC", A(), A::bc, BGenericEvent::class.java), - ) - } } 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), + ) + } +} From 2184a87c920a231361a73e3f963c4300d057e40b Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:59:08 +0200 Subject: [PATCH 13/17] Revert "Pass `EventListenerRegistry` lazily to `EventDispatcherImpl`" This reverts commit f68bdbc5f0a37ed142e4ba330eecd910f0cf17f9. --- .../internal/core/hooks/EventDispatcherImpl.kt | 5 +---- .../internal/core/service/LazyServiceImpl.kt | 13 +------------ .../botcommands/core/hooks/EventDispatcherTests.kt | 3 +-- 3 files changed, 3 insertions(+), 18 deletions(-) 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 655f2d7edc..82570a1af7 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,6 @@ 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.LazyService import io.github.freya022.botcommands.api.core.service.annotations.BService import io.github.freya022.botcommands.api.core.utils.loggerOf import io.github.freya022.botcommands.api.core.utils.simpleNestedName @@ -22,14 +21,12 @@ private val logger = KotlinLogging.loggerOf() @BService internal class EventDispatcherImpl internal constructor( coroutineScopesConfig: BCoroutineScopesConfig, - eventListenerRegistry: LazyService, + private val eventListenerRegistry: EventListenerRegistry, ) : EventDispatcher() { private val eventManagerCoroutineScope: CoroutineScope = coroutineScopesConfig.eventManagerScope private val asyncCoroutineScope: CoroutineScope = coroutineScopesConfig.eventDispatcherScope - private val eventListenerRegistry: EventListenerRegistry by eventListenerRegistry - 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] diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/LazyServiceImpl.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/LazyServiceImpl.kt index 34a0cb72f8..1cc7a4738d 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/LazyServiceImpl.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/LazyServiceImpl.kt @@ -119,15 +119,6 @@ private class FallbackLazyServiceImpl( } } -private class CompletedLazyService(override val value: T) : LazyService { - - override fun canCreateService(): Boolean = true - - override fun getServiceError(): ServiceError? = null - - override fun isInitialized(): Boolean = true -} - internal fun ServiceContainer.implicitlyNamedLazyService(clazz: KClass, name: String?): LazyService = ImplicitNamedLazyServiceImpl(this, clazz, name) @@ -137,6 +128,4 @@ internal fun ServiceContainer.lazyService(clazz: KClass, name: Stri @PublishedApi internal fun ServiceContainer.lazyServiceOrElse(clazz: KClass, name: String?, block: () -> R): LazyService = - FallbackLazyServiceImpl(this, clazz, name, block) - -internal fun lazyServiceOf(value: T): LazyService = CompletedLazyService(value) + FallbackLazyServiceImpl(this, clazz, name, block) \ No newline at end of file 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 bb7c4f04ed..f325859d51 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 @@ -12,7 +12,6 @@ import io.github.freya022.botcommands.internal.core.hooks.EventHandlerFunction import io.github.freya022.botcommands.internal.core.hooks.EventListenerList import io.github.freya022.botcommands.internal.core.hooks.EventListenerRegistry import io.github.freya022.botcommands.internal.core.method.accessors.MethodAccessorFactoryProvider -import io.github.freya022.botcommands.internal.core.service.lazyServiceOf import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -55,7 +54,7 @@ object EventDispatcherTests { } } - val dispatcher = EventDispatcherImpl(BCoroutineScopesConfigBuilder().build(), lazyServiceOf(listenerRegistry)) + val dispatcher = EventDispatcherImpl(BCoroutineScopesConfigBuilder().build(), listenerRegistry) assertThrows { dispatcher.dispatchEventJava(mockk()) } } From 6c9a6ee5d93d75bd35c649192d4d20995b9a035e Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:06:47 +0200 Subject: [PATCH 14/17] Fetch registry lazily in `EventDispatcherImpl` As the registry may reference classes that uses the dispatcher --- .../botcommands/internal/core/hooks/EventDispatcherImpl.kt | 7 ++++++- .../botcommands/core/hooks/EventDispatcherTests.kt | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) 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 82570a1af7..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,12 +23,15 @@ 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] 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 f325859d51..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 @@ -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()) } } From fce7f4fe272547d28fd6d11610a6a74987f591ac Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:06:57 +0200 Subject: [PATCH 15/17] Adjust docs --- .../freya022/botcommands/api/core/hooks/EventDispatcher.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0a77baba8d..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. Custom events are also supported. + * Dispatches events to [@BEventListener][BEventListener] methods. */ @InterfacedService(acceptMultiple = false) abstract class EventDispatcher internal constructor() { From 0a424ebf2dcf354ff40e153db8af87b5baa16f3c Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:33:06 +0200 Subject: [PATCH 16/17] Fix some Java usability problems --- .../api/core/hooks/custom/CustomEventRequirements.kt | 3 ++- .../github/freya022/botcommands/api/core/utils/Collections.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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 index 989e5cfe95..07a7bb1036 100644 --- 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 @@ -35,7 +35,7 @@ interface CustomEventRequirements { * Creates an instance from the given intents. */ @JvmStatic - fun from(intents: Array): CustomEventRequirements { + fun from(vararg intents: GatewayIntent): CustomEventRequirements { return from(intents.toEnumSet()) } @@ -56,6 +56,7 @@ interface CustomEventRequirements { * This is a shortcut to as `from(GatewayIntent.fromEvents(events))`. */ @JvmStatic + @SafeVarargs fun fromEvents(vararg events: Class): CustomEventRequirements { return from(GatewayIntent.fromEvents(*events)) } 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 d006022673..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,7 +12,7 @@ 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 > 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) } From d80e2205621c174c61c86bf671c5db6c8110eed5 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:38:06 +0200 Subject: [PATCH 17/17] Add `CustomEventRequirementsProvider` example --- .../MyCustomEventRequirementsProvider.java | 31 +++++++++++++++++++ .../custom/CustomEventRequirementsProvider.kt | 28 +++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 BotCommands-core/src/javaDocExamples/java/doc/java/examples/core/hooks/custom/MyCustomEventRequirementsProvider.java 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/hooks/custom/CustomEventRequirementsProvider.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/hooks/custom/CustomEventRequirementsProvider.kt index 607e1fcb25..9c330d23ba 100644 --- 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 @@ -9,6 +9,34 @@ import io.github.freya022.botcommands.api.core.service.annotations.InterfacedSer * * 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)