diff --git a/.idea/misc.xml b/.idea/misc.xml
index b21d13d100..f9cee6c190 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -4,7 +4,7 @@
-
+
diff --git a/.idea/modules.xml b/.idea/modules.xml
index f7c708e3bb..7bc838d592 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -8,8 +8,10 @@
+
+
-
\ No newline at end of file
+
diff --git a/.idea/modules/plugins/kord-extensions.plugins.main.iml b/.idea/modules/plugins/kord-extensions.plugins.main.iml
new file mode 100644
index 0000000000..ec6c1d1ca7
--- /dev/null
+++ b/.idea/modules/plugins/kord-extensions.plugins.main.iml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules/plugins/kord-extensions.plugins.test.iml b/.idea/modules/plugins/kord-extensions.plugins.test.iml
new file mode 100644
index 0000000000..563d69839f
--- /dev/null
+++ b/.idea/modules/plugins/kord-extensions.plugins.test.iml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/annotation-processor/build.gradle.kts b/annotation-processor/build.gradle.kts
index 43825c3cf1..8ee90db321 100644
--- a/annotation-processor/build.gradle.kts
+++ b/annotation-processor/build.gradle.kts
@@ -26,12 +26,6 @@ dokkaModule {
}
java {
- sourceCompatibility = JavaVersion.VERSION_11
- targetCompatibility = JavaVersion.VERSION_11
-}
-
-kotlin {
- jvmToolchain {
- languageVersion.set(JavaLanguageVersion.of("11"))
- }
+ sourceCompatibility = JavaVersion.VERSION_13
+ targetCompatibility = JavaVersion.VERSION_13
}
diff --git a/buildSrc/src/main/kotlin/kordex-module.gradle.kts b/buildSrc/src/main/kotlin/kordex-module.gradle.kts
index 2b053f486c..8a96f967ae 100644
--- a/buildSrc/src/main/kotlin/kordex-module.gradle.kts
+++ b/buildSrc/src/main/kotlin/kordex-module.gradle.kts
@@ -29,10 +29,6 @@ tasks {
kotlin {
explicitApi()
-
- jvmToolchain {
- languageVersion.set(JavaLanguageVersion.of("11"))
- }
}
jar {
@@ -43,8 +39,8 @@ tasks {
rootProject.file("LICENSE").copyTo(rootProject.file("build/LICENSE-kordex"), true)
tasks.withType().configureEach {
- sourceCompatibility = "11"
- targetCompatibility = "11"
+ sourceCompatibility = "13"
+ targetCompatibility = "13"
}
withType().configureEach {
@@ -53,7 +49,7 @@ tasks {
}
kotlinOptions {
- jvmTarget = "11"
+ jvmTarget = "13"
}
}
}
diff --git a/gradle.properties b/gradle.properties
index edeb68eb82..f508d87ff9 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -3,7 +3,7 @@ kotlin.incremental = true
ksp.incremental = false
-projectVersion = 1.5.9-SNAPSHOT
+projectVersion = 1.5.10-SNAPSHOT
#dokka will run out of memory with the default meta space
org.gradle.jvmargs=-XX:MaxMetaspaceSize=1024m
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 11b49d8f7c..c5255fe06c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -23,6 +23,7 @@ logback-groovy = "1.14.5"
logging = "5.1.0"
mongodb = "4.10.2"
pf4j = "3.10.0"
+semver = "1.4.2"
sentry = "6.29.0"
slf4j = "2.0.9"
time4j-base = "5.9.3"
@@ -53,12 +54,14 @@ ksp = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref =
ktor-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
kx-coro = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kx-coro"}
kx-ser = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kx-ser" }
+kx-ser-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kx-ser" }
linkie = { module = "me.shedaniel:linkie-core", version.ref = "linkie" }
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
logback-groovy = { module = "io.github.virtualdogbert:logback-groovy-config", version.ref = "logback-groovy" }
kotlin-logging = { module = "io.github.oshai:kotlin-logging", version.ref = "logging" }
mongodb = { module = "org.mongodb:mongodb-driver-kotlin-coroutine", version.ref = "mongodb" }
pf4j = { module = "org.pf4j:pf4j", version.ref = "pf4j" }
+semver = { module = "io.github.z4kn4fein:semver", version.ref = "semver" }
sentry = { module = "io.sentry:sentry", version.ref = "sentry" }
slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
time4j-base = { module = "net.time4j:time4j-base", version.ref = "time4j-base" }
diff --git a/plugins/build.gradle.kts b/plugins/build.gradle.kts
new file mode 100644
index 0000000000..8490d7cf22
--- /dev/null
+++ b/plugins/build.gradle.kts
@@ -0,0 +1,39 @@
+buildscript {
+ repositories {
+ maven {
+ name = "Sonatype Snapshots"
+ url = uri("https://oss.sonatype.org/content/repositories/snapshots")
+ }
+ }
+}
+
+plugins {
+ `kordex-module`
+ `published-module`
+ `dokka-module`
+ `tested-module`
+}
+
+metadata {
+ name = "KordEx: Plugins"
+ description = "Self-contained API implementing a simple plugin system"
+}
+
+group = "com.kotlindiscord.kord.extensions"
+
+dependencies {
+ detektPlugins(libs.detekt)
+ detektPlugins(libs.detekt.libraries)
+
+ implementation(libs.bundles.logging)
+ implementation(libs.kotlin.stdlib)
+ implementation(libs.kx.ser)
+ implementation(libs.kx.ser.json) // No ktor dep
+ implementation(libs.semver)
+
+ testImplementation(libs.groovy) // For logback config
+ testImplementation(libs.jansi)
+ testImplementation(libs.junit)
+ testImplementation(libs.logback)
+ testImplementation(libs.logback.groovy)
+}
diff --git a/plugins/plugin-load-test/build.gradle.kts b/plugins/plugin-load-test/build.gradle.kts
new file mode 100644
index 0000000000..1a80bb0251
--- /dev/null
+++ b/plugins/plugin-load-test/build.gradle.kts
@@ -0,0 +1,66 @@
+buildscript {
+ repositories {
+ maven {
+ name = "Sonatype Snapshots"
+ url = uri("https://oss.sonatype.org/content/repositories/snapshots")
+ }
+ }
+}
+
+plugins {
+ `kordex-module`
+ `dokka-module`
+ `tested-module`
+}
+
+group = "com.kotlindiscord.kord.extensions"
+
+dependencies {
+ detektPlugins(libs.detekt)
+ detektPlugins(libs.detekt.libraries)
+
+ testImplementation(libs.bundles.logging)
+ testImplementation(libs.kotlin.stdlib)
+ testImplementation(libs.kx.ser)
+ testImplementation(libs.kx.ser.json) // No ktor dep
+ testImplementation(libs.semver)
+
+ testImplementation(libs.groovy) // For logback config
+ testImplementation(libs.jansi)
+ testImplementation(libs.junit)
+ testImplementation(libs.logback)
+ testImplementation(libs.logback.groovy)
+
+ // Make sure these get built before the test module
+ testImplementation(project(":plugins:test-plugin-core"))
+ testImplementation(project(":plugins:test-plugin-1"))
+ testImplementation(project(":plugins:test-plugin-2"))
+}
+
+val copyTestJars = tasks.register("copyTestJars") {
+ val matchRegex = Regex("^.*(\\d|-SNAPSHOT)\\.jar\$")
+
+ val root = rootProject.rootDir
+ val pluginDir = root.resolve("plugins/plugin-load-test/tmp/plugins")
+
+ val testOneJar = root.resolve("plugins/test-plugin-1/build/libs")
+ .listFiles()
+ ?.first {
+ it.name.matches(matchRegex)
+ }
+
+ val testTwoJar = root.resolve("plugins/test-plugin-2/build/libs")
+ .listFiles()
+ ?.first {
+ it.name.matches(matchRegex)
+ }
+
+ pluginDir.mkdirs()
+
+ from(testOneJar, testTwoJar)
+ into(pluginDir)
+}
+
+tasks.test {
+ dependsOn(copyTestJars)
+}
diff --git a/plugins/plugin-load-test/src/test/kotlin/PluginJarTests.kt b/plugins/plugin-load-test/src/test/kotlin/PluginJarTests.kt
new file mode 100644
index 0000000000..ad006249ab
--- /dev/null
+++ b/plugins/plugin-load-test/src/test/kotlin/PluginJarTests.kt
@@ -0,0 +1,28 @@
+import com.kotlindiscord.kord.extensions.plugins.PluginManager
+import com.kotlindiscord.kord.extensions.plugins.test.core.TestPlugin
+import org.junit.jupiter.api.Assertions
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class PluginJarTests {
+ private val pluginManager = PluginManager(
+ baseTypeReference = "TestPlugin",
+ pluginDirectory = "tmp/plugins"
+ )
+
+ @Test
+ fun `Standard plugin load`() {
+ pluginManager.loadAllPlugins()
+
+ Assertions.assertEquals(
+ "test-one",
+ pluginManager.loadPlugin("test-one")?.get()?.manifest?.id
+ )
+
+ Assertions.assertEquals(
+ "test-two",
+ pluginManager.loadPlugin("test-two")?.get()?.manifest?.id
+ )
+ }
+}
diff --git a/plugins/plugin-load-test/src/test/resources/junit-platform.properties b/plugins/plugin-load-test/src/test/resources/junit-platform.properties
new file mode 100644
index 0000000000..580f511dad
--- /dev/null
+++ b/plugins/plugin-load-test/src/test/resources/junit-platform.properties
@@ -0,0 +1,7 @@
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+#
+
+junit.jupiter.execution.parallel.enabled=true
diff --git a/plugins/plugin-load-test/src/test/resources/logback.groovy b/plugins/plugin-load-test/src/test/resources/logback.groovy
new file mode 100644
index 0000000000..a0c69989f4
--- /dev/null
+++ b/plugins/plugin-load-test/src/test/resources/logback.groovy
@@ -0,0 +1,40 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import ch.qos.logback.classic.encoder.PatternLayoutEncoder
+import ch.qos.logback.core.joran.spi.ConsoleTarget
+import ch.qos.logback.core.ConsoleAppender
+import ch.qos.logback.core.FileAppender
+
+def environment = System.getenv("ENVIRONMENT") ?: "dev"
+def defaultLevel = TRACE
+
+if (environment == "spam") {
+ logger("dev.kord.rest.DefaultGateway", TRACE)
+} else {
+ // Silence warning about missing native PRNG
+ logger("io.ktor.util.random", ERROR)
+}
+
+appender("CONSOLE", ConsoleAppender) {
+ encoder(PatternLayoutEncoder) {
+ pattern = "%boldGreen(%d{yyyy-MM-dd}) %boldYellow(%d{HH:mm:ss}) %gray(|) %highlight(%5level) %gray(|) %boldMagenta(%40.40logger{40}) %gray(|) %msg%n"
+
+ withJansi = true
+ }
+
+ target = ConsoleTarget.SystemOut
+}
+
+appender("FILE", FileAppender) {
+ file = "output.log"
+
+ encoder(PatternLayoutEncoder) {
+ pattern = "%d{yyyy-MM-dd HH:mm:ss:SSS Z} | %5level | %40.40logger{40} | %msg%n"
+ }
+}
+
+root(defaultLevel, ["CONSOLE", "FILE"])
diff --git a/plugins/plugin-load-test/src/test/resources/logbackCompiler.groovy b/plugins/plugin-load-test/src/test/resources/logbackCompiler.groovy
new file mode 100644
index 0000000000..a374f9af62
--- /dev/null
+++ b/plugins/plugin-load-test/src/test/resources/logbackCompiler.groovy
@@ -0,0 +1,458 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+importsAcceptList = [
+ 'ch.qos.logback.core.testUtil.SampleConverter',
+
+ 'ch.qos.logback.core.testUtil.StringListAppender',
+ 'java.lang.Object',
+ 'org.springframework.beans.factory.annotation.Autowired',
+ 'java.nio.charset.Charset.forName',
+ 'com.logentries.logback.LogentriesAppender',
+ 'grails.util.BuildSettings',
+ 'grails.util.Environment',
+ 'io.micronaut.context.env.Environment',
+ 'org.slf4j.MDC',
+ 'org.springframework.boot.logging.logback.ColorConverter',
+ 'org.springframework.boot.logging.logback.WhitespaceThrowableProxyConverter',
+ 'java.nio.charset.Charset',
+ 'java.nio.charset.StandardCharsets',
+
+ 'ch.qos.logback.core.BasicStatusManager',
+ 'ch.qos.logback.core.ConsoleAppender',
+ 'ch.qos.logback.core.hook.ShutdownHook',
+ 'ch.qos.logback.core.hook.ShutdownHookBase',
+ 'ch.qos.logback.core.hook.DelayingShutdownHook',
+ 'ch.qos.logback.core.spi.PropertyContainer',
+ 'ch.qos.logback.core.spi.ContextAwareBase',
+ 'ch.qos.logback.core.spi.LogbackLock',
+ 'ch.qos.logback.core.spi.FilterAttachableImpl',
+ 'ch.qos.logback.core.spi.ContextAwareImpl',
+ 'ch.qos.logback.core.spi.ScanException',
+ 'ch.qos.logback.core.spi.DeferredProcessingAware',
+ 'ch.qos.logback.core.spi.ContextAware',
+ 'ch.qos.logback.core.spi.LifeCycle',
+ 'ch.qos.logback.core.spi.FilterReply',
+ 'ch.qos.logback.core.spi.PreSerializationTransformer',
+ 'ch.qos.logback.core.spi.AppenderAttachable',
+ 'ch.qos.logback.core.spi.CyclicBufferTracker',
+ 'ch.qos.logback.core.spi.FilterAttachable',
+ 'ch.qos.logback.core.spi.ComponentTracker',
+ 'ch.qos.logback.core.spi.AppenderAttachableImpl',
+ 'ch.qos.logback.core.spi.PropertyDefiner',
+ 'ch.qos.logback.core.spi.AbstractComponentTracker',
+ 'ch.qos.logback.core.property.FileExistsPropertyDefiner',
+ 'ch.qos.logback.core.property.ResourceExistsPropertyDefiner',
+ 'ch.qos.logback.core.CoreConstants',
+ 'ch.qos.logback.core.layout.EchoLayout',
+ 'ch.qos.logback.core.Appender',
+ 'ch.qos.logback.core.joran.JoranConfiguratorBase',
+ 'ch.qos.logback.core.joran.spi.ActionException',
+ 'ch.qos.logback.core.joran.spi.HostClassAndPropertyDouble',
+ 'ch.qos.logback.core.joran.spi.JoranException',
+ 'ch.qos.logback.core.joran.spi.NoAutoStart',
+ 'ch.qos.logback.core.joran.spi.EventPlayer',
+ 'ch.qos.logback.core.joran.spi.XMLUtil',
+ 'ch.qos.logback.core.joran.spi.ConsoleTarget',
+ 'ch.qos.logback.core.joran.spi.Interpreter',
+ 'ch.qos.logback.core.joran.spi.SimpleRuleStore',
+ 'ch.qos.logback.core.joran.spi.InterpretationContext',
+ 'ch.qos.logback.core.joran.spi.RuleStore',
+ 'ch.qos.logback.core.joran.spi.NoAutoStartUtil',
+ 'ch.qos.logback.core.joran.spi.ElementSelector',
+ 'ch.qos.logback.core.joran.spi.ConfigurationWatchList',
+ 'ch.qos.logback.core.joran.spi.DefaultNestedComponentRegistry',
+ 'ch.qos.logback.core.joran.spi.DefaultClass',
+ 'ch.qos.logback.core.joran.spi.ElementPath',
+ 'ch.qos.logback.core.joran.conditional.ThenOrElseActionBase',
+ 'ch.qos.logback.core.joran.conditional.Condition',
+ 'ch.qos.logback.core.joran.conditional.PropertyWrapperForScripts',
+ 'ch.qos.logback.core.joran.conditional.ThenAction',
+ 'ch.qos.logback.core.joran.conditional.PropertyEvalScriptBuilder',
+ 'ch.qos.logback.core.joran.conditional.ElseAction',
+ 'ch.qos.logback.core.joran.conditional.IfAction',
+ 'ch.qos.logback.core.joran.util.beans.BeanDescriptionCache',
+ 'ch.qos.logback.core.joran.util.beans.BeanDescriptionFactory',
+ 'ch.qos.logback.core.joran.util.beans.BeanDescription',
+ 'ch.qos.logback.core.joran.util.beans.BeanUtil',
+ 'ch.qos.logback.core.joran.util.PropertySetter',
+ 'ch.qos.logback.core.joran.util.ConfigurationWatchListUtil',
+ 'ch.qos.logback.core.joran.util.StringToObjectConverter',
+ 'ch.qos.logback.core.joran.GenericConfigurator',
+ 'ch.qos.logback.core.joran.action.ImplicitAction',
+ 'ch.qos.logback.core.joran.action.IncludeAction',
+ 'ch.qos.logback.core.joran.action.NOPAction',
+ 'ch.qos.logback.core.joran.action.IADataForBasicProperty',
+ 'ch.qos.logback.core.joran.action.TimestampAction',
+ 'ch.qos.logback.core.joran.action.AbstractEventEvaluatorAction',
+ 'ch.qos.logback.core.joran.action.ParamAction',
+ 'ch.qos.logback.core.joran.action.AppenderAction',
+ 'ch.qos.logback.core.joran.action.DefinePropertyAction',
+ 'ch.qos.logback.core.joran.action.StatusListenerAction',
+ 'ch.qos.logback.core.joran.action.ContextPropertyAction',
+ 'ch.qos.logback.core.joran.action.NestedComplexPropertyIA',
+ 'ch.qos.logback.core.joran.action.NestedBasicPropertyIA',
+ 'ch.qos.logback.core.joran.action.Action',
+ 'ch.qos.logback.core.joran.action.AppenderRefAction',
+ 'ch.qos.logback.core.joran.action.ActionUtil',
+ 'ch.qos.logback.core.joran.action.ShutdownHookAction',
+ 'ch.qos.logback.core.joran.action.IADataForComplexProperty',
+ 'ch.qos.logback.core.joran.action.ConversionRuleAction',
+ 'ch.qos.logback.core.joran.action.ActionConst',
+ 'ch.qos.logback.core.joran.action.PropertyAction',
+ 'ch.qos.logback.core.joran.action.NewRuleAction',
+ 'ch.qos.logback.core.joran.node.ComponentNode',
+ 'ch.qos.logback.core.joran.event.EndEvent',
+ 'ch.qos.logback.core.joran.event.SaxEventRecorder',
+ 'ch.qos.logback.core.joran.event.SaxEvent',
+ 'ch.qos.logback.core.joran.event.BodyEvent',
+ 'ch.qos.logback.core.joran.event.StartEvent',
+ 'ch.qos.logback.core.joran.event.InPlayListener',
+ 'ch.qos.logback.core.joran.event.stax.EndEvent',
+ 'ch.qos.logback.core.joran.event.stax.StaxEventRecorder',
+ 'ch.qos.logback.core.joran.event.stax.BodyEvent',
+ 'ch.qos.logback.core.joran.event.stax.StartEvent',
+ 'ch.qos.logback.core.joran.event.stax.StaxEvent',
+ 'ch.qos.logback.core.LogbackException',
+ 'ch.qos.logback.core.PropertyDefinerBase',
+ 'ch.qos.logback.core.helpers.CyclicBuffer',
+ 'ch.qos.logback.core.helpers.ThrowableToStringArray',
+ 'ch.qos.logback.core.helpers.Transform',
+ 'ch.qos.logback.core.helpers.NOPAppender',
+ 'ch.qos.logback.core.net.LoginAuthenticator',
+ 'ch.qos.logback.core.net.DefaultSocketConnector',
+ 'ch.qos.logback.core.net.ssl.KeyStoreFactoryBean',
+ 'ch.qos.logback.core.net.ssl.SSLParametersConfiguration',
+ 'ch.qos.logback.core.net.ssl.SSLComponent',
+ 'ch.qos.logback.core.net.ssl.SSLNestedComponentRegistryRules',
+ 'ch.qos.logback.core.net.ssl.SSLConfigurableSocket',
+ 'ch.qos.logback.core.net.ssl.SSLConfigurableServerSocket',
+ 'ch.qos.logback.core.net.ssl.SSLConfiguration',
+ 'ch.qos.logback.core.net.ssl.ConfigurableSSLSocketFactory',
+ 'ch.qos.logback.core.net.ssl.ConfigurableSSLServerSocketFactory',
+ 'ch.qos.logback.core.net.ssl.SecureRandomFactoryBean',
+ 'ch.qos.logback.core.net.ssl.SSLContextFactoryBean',
+ 'ch.qos.logback.core.net.ssl.SSL',
+ 'ch.qos.logback.core.net.ssl.SSLConfigurable',
+ 'ch.qos.logback.core.net.ssl.TrustManagerFactoryFactoryBean',
+ 'ch.qos.logback.core.net.ssl.KeyManagerFactoryFactoryBean',
+ 'ch.qos.logback.core.net.SMTPAppenderBase',
+ 'ch.qos.logback.core.net.SyslogAppenderBase',
+ 'ch.qos.logback.core.net.SocketConnector',
+ 'ch.qos.logback.core.net.SyslogOutputStream',
+ 'ch.qos.logback.core.net.QueueFactory',
+ 'ch.qos.logback.core.net.HardenedObjectInputStream',
+ 'ch.qos.logback.core.net.AbstractSocketAppender',
+ 'ch.qos.logback.core.net.AbstractSSLSocketAppender',
+ 'ch.qos.logback.core.net.ObjectWriterFactory',
+ 'ch.qos.logback.core.net.ObjectWriter',
+ 'ch.qos.logback.core.net.AutoFlushingObjectWriter',
+ 'ch.qos.logback.core.net.SyslogConstants',
+ 'ch.qos.logback.core.net.server.ServerRunner',
+ 'ch.qos.logback.core.net.server.Client',
+ 'ch.qos.logback.core.net.server.ServerListener',
+ 'ch.qos.logback.core.net.server.RemoteReceiverStreamClient',
+ 'ch.qos.logback.core.net.server.AbstractServerSocketAppender',
+ 'ch.qos.logback.core.net.server.ClientVisitor',
+ 'ch.qos.logback.core.net.server.RemoteReceiverClient',
+ 'ch.qos.logback.core.net.server.RemoteReceiverServerRunner',
+ 'ch.qos.logback.core.net.server.SSLServerSocketAppenderBase',
+ 'ch.qos.logback.core.net.server.ConcurrentServerRunner',
+ 'ch.qos.logback.core.net.server.ServerSocketListener',
+ 'ch.qos.logback.core.net.server.RemoteReceiverServerListener',
+ 'ch.qos.logback.core.UnsynchronizedAppenderBase',
+ 'ch.qos.logback.core.AsyncAppenderBase',
+ 'ch.qos.logback.core.util.CloseUtil',
+ 'ch.qos.logback.core.util.DatePatternToRegexUtil',
+ 'ch.qos.logback.core.util.StatusListenerConfigHelper',
+ 'ch.qos.logback.core.util.SystemInfo',
+ 'ch.qos.logback.core.util.DefaultInvocationGate',
+ 'ch.qos.logback.core.util.CachingDateFormatter',
+ 'ch.qos.logback.core.util.InterruptUtil',
+ 'ch.qos.logback.core.util.LocationUtil',
+ 'ch.qos.logback.core.util.TimeUtil',
+ 'ch.qos.logback.core.util.COWArrayList',
+ 'ch.qos.logback.core.util.Loader',
+ 'ch.qos.logback.core.util.CharSequenceState',
+ 'ch.qos.logback.core.util.StatusPrinter',
+ 'ch.qos.logback.core.util.Duration',
+ 'ch.qos.logback.core.util.ContentTypeUtil',
+ 'ch.qos.logback.core.util.FileUtil',
+ 'ch.qos.logback.core.util.DynamicClassLoadingException',
+ 'ch.qos.logback.core.util.InvocationGate',
+ 'ch.qos.logback.core.util.OptionHelper',
+ 'ch.qos.logback.core.util.IncompatibleClassException',
+ 'ch.qos.logback.core.util.ExecutorServiceUtil',
+ 'ch.qos.logback.core.util.StringCollectionUtil',
+ 'ch.qos.logback.core.util.CharSequenceToRegexMapper',
+ 'ch.qos.logback.core.util.FixedDelay',
+ 'ch.qos.logback.core.util.FileSize',
+ 'ch.qos.logback.core.util.DelayStrategy',
+ 'ch.qos.logback.core.util.EnvUtil',
+ 'ch.qos.logback.core.util.ContextUtil',
+ 'ch.qos.logback.core.util.AggregationType',
+ 'ch.qos.logback.core.util.PropertySetterException',
+ 'ch.qos.logback.core.LifeCycleManager',
+ 'ch.qos.logback.core.LayoutBase',
+ 'ch.qos.logback.core.encoder.NonClosableInputStream',
+ 'ch.qos.logback.core.encoder.Encoder',
+ 'ch.qos.logback.core.encoder.ByteArrayUtil',
+ 'ch.qos.logback.core.encoder.EncoderBase',
+ 'ch.qos.logback.core.encoder.EchoEncoder',
+ 'ch.qos.logback.core.encoder.LayoutWrappingEncoder',
+ 'ch.qos.logback.core.recovery.RecoveryCoordinator',
+ 'ch.qos.logback.core.recovery.ResilientOutputStreamBase',
+ 'ch.qos.logback.core.recovery.ResilientSyslogOutputStream',
+ 'ch.qos.logback.core.recovery.ResilientFileOutputStream',
+ 'ch.qos.logback.core.AppenderBase',
+ 'ch.qos.logback.core.subst.Node',
+ 'ch.qos.logback.core.subst.Parser',
+ 'ch.qos.logback.core.subst.Token',
+ 'ch.qos.logback.core.subst.NodeToStringTransformer',
+ 'ch.qos.logback.core.subst.Tokenizer',
+ 'ch.qos.logback.core.FileAppender',
+ 'ch.qos.logback.core.sift.AppenderFactory',
+ 'ch.qos.logback.core.sift.SiftingAppenderBase',
+ 'ch.qos.logback.core.sift.SiftingJoranConfiguratorBase',
+ 'ch.qos.logback.core.sift.AbstractDiscriminator',
+ 'ch.qos.logback.core.sift.Discriminator',
+ 'ch.qos.logback.core.sift.AbstractAppenderFactoryUsingJoran',
+ 'ch.qos.logback.core.sift.AppenderTracker',
+ 'ch.qos.logback.core.sift.DefaultDiscriminator',
+ 'ch.qos.logback.core.html.CssBuilder',
+ 'ch.qos.logback.core.html.NOPThrowableRenderer',
+ 'ch.qos.logback.core.html.HTMLLayoutBase',
+ 'ch.qos.logback.core.html.IThrowableRenderer',
+ 'ch.qos.logback.core.rolling.TriggeringPolicyBase',
+ 'ch.qos.logback.core.rolling.helper.Compressor',
+ 'ch.qos.logback.core.rolling.helper.PeriodicityType',
+ 'ch.qos.logback.core.rolling.helper.TokenConverter',
+ 'ch.qos.logback.core.rolling.helper.IntegerTokenConverter',
+ 'ch.qos.logback.core.rolling.helper.CompressionMode',
+ 'ch.qos.logback.core.rolling.helper.ArchiveRemover',
+ 'ch.qos.logback.core.rolling.helper.FileFilterUtil',
+ 'ch.qos.logback.core.rolling.helper.RenameUtil',
+ 'ch.qos.logback.core.rolling.helper.DateTokenConverter',
+ 'ch.qos.logback.core.rolling.helper.FileNamePattern',
+ 'ch.qos.logback.core.rolling.helper.RollingCalendar',
+ 'ch.qos.logback.core.rolling.helper.FileStoreUtil',
+ 'ch.qos.logback.core.rolling.helper.SizeAndTimeBasedArchiveRemover',
+ 'ch.qos.logback.core.rolling.helper.TimeBasedArchiveRemover',
+ 'ch.qos.logback.core.rolling.helper.MonoTypedConverter',
+ 'ch.qos.logback.core.rolling.RollingPolicyBase',
+ 'ch.qos.logback.core.rolling.RollingFileAppender',
+ 'ch.qos.logback.core.rolling.FixedWindowRollingPolicy',
+ 'ch.qos.logback.core.rolling.TimeBasedFileNamingAndTriggeringPolicyBase',
+ 'ch.qos.logback.core.rolling.TimeBasedFileNamingAndTriggeringPolicy',
+ 'ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy',
+ 'ch.qos.logback.core.rolling.RollingPolicy',
+ 'ch.qos.logback.core.rolling.TimeBasedRollingPolicy',
+ 'ch.qos.logback.core.rolling.DefaultTimeBasedFileNamingAndTriggeringPolicy',
+ 'ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy',
+ 'ch.qos.logback.core.rolling.RolloverFailure',
+ 'ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP',
+ 'ch.qos.logback.core.rolling.TriggeringPolicy',
+ 'ch.qos.logback.core.pattern.ReplacingCompositeConverter',
+ 'ch.qos.logback.core.pattern.ConverterUtil',
+ 'ch.qos.logback.core.pattern.parser.Compiler',
+ 'ch.qos.logback.core.pattern.parser.Node',
+ 'ch.qos.logback.core.pattern.parser.Parser',
+ 'ch.qos.logback.core.pattern.parser.Token',
+ 'ch.qos.logback.core.pattern.parser.OptionTokenizer',
+ 'ch.qos.logback.core.pattern.parser.TokenStream',
+ 'ch.qos.logback.core.pattern.parser.CompositeNode',
+ 'ch.qos.logback.core.pattern.parser.FormattingNode',
+ 'ch.qos.logback.core.pattern.parser.SimpleKeywordNode',
+ 'ch.qos.logback.core.pattern.Converter',
+ 'ch.qos.logback.core.pattern.PatternLayoutEncoderBase',
+ 'ch.qos.logback.core.pattern.LiteralConverter',
+ 'ch.qos.logback.core.pattern.PostCompileProcessor',
+ 'ch.qos.logback.core.pattern.util.RegularEscapeUtil',
+ 'ch.qos.logback.core.pattern.util.AsIsEscapeUtil',
+ 'ch.qos.logback.core.pattern.util.AlmostAsIsEscapeUtil',
+ 'ch.qos.logback.core.pattern.util.IEscapeUtil',
+ 'ch.qos.logback.core.pattern.util.RestrictedEscapeUtil',
+ 'ch.qos.logback.core.pattern.SpacePadder',
+ 'ch.qos.logback.core.pattern.CompositeConverter',
+ 'ch.qos.logback.core.pattern.PatternLayoutBase',
+ 'ch.qos.logback.core.pattern.DynamicConverter',
+ 'ch.qos.logback.core.pattern.color.YellowCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.ANSIConstants',
+ 'ch.qos.logback.core.pattern.color.BoldYellowCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.BoldBlueCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.BoldWhiteCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.CyanCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.MagentaCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.BlueCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.BlackCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.ForegroundCompositeConverterBase',
+ 'ch.qos.logback.core.pattern.color.GrayCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.BoldMagentaCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.BoldCyanCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.RedCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.BoldGreenCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.BoldRedCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.GreenCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.WhiteCompositeConverter',
+ 'ch.qos.logback.core.pattern.FormattingConverter',
+ 'ch.qos.logback.core.pattern.IdentityCompositeConverter',
+ 'ch.qos.logback.core.pattern.FormatInfo',
+ 'ch.qos.logback.core.OutputStreamAppender',
+ 'ch.qos.logback.core.boolex.JaninoEventEvaluatorBase',
+ 'ch.qos.logback.core.boolex.Matcher',
+ 'ch.qos.logback.core.boolex.EventEvaluatorBase',
+ 'ch.qos.logback.core.boolex.EvaluationException',
+ 'ch.qos.logback.core.boolex.EventEvaluator',
+ 'ch.qos.logback.core.read.CyclicBufferAppender',
+ 'ch.qos.logback.core.read.ListAppender',
+ 'ch.qos.logback.core.Context',
+ 'ch.qos.logback.core.ContextBase',
+ 'ch.qos.logback.core.status.StatusListenerAsList',
+ 'ch.qos.logback.core.status.StatusBase',
+ 'ch.qos.logback.core.status.NopStatusListener',
+ 'ch.qos.logback.core.status.StatusUtil',
+ 'ch.qos.logback.core.status.OnPrintStreamStatusListenerBase',
+ 'ch.qos.logback.core.status.StatusManager',
+ 'ch.qos.logback.core.status.ViewStatusMessagesServletBase',
+ 'ch.qos.logback.core.status.ErrorStatus',
+ 'ch.qos.logback.core.status.Status',
+ 'ch.qos.logback.core.status.StatusListener',
+ 'ch.qos.logback.core.status.InfoStatus',
+ 'ch.qos.logback.core.status.OnConsoleStatusListener',
+ 'ch.qos.logback.core.status.WarnStatus',
+ 'ch.qos.logback.core.status.OnErrorConsoleStatusListener',
+ 'ch.qos.logback.core.filter.EvaluatorFilter',
+ 'ch.qos.logback.core.filter.Filter',
+ 'ch.qos.logback.core.filter.AbstractMatcherFilter',
+ 'ch.qos.logback.core.Layout',
+ 'ch.qos.logback.classic.ViewStatusMessagesServlet',
+ 'ch.qos.logback.classic.ClassicConstants',
+ 'ch.qos.logback.classic.layout.TTLLLayout',
+ 'ch.qos.logback.classic.helpers.MDCInsertingServletFilter',
+ 'ch.qos.logback.classic.Level',
+ 'ch.qos.logback.classic.Level.off',
+ 'ch.qos.logback.classic.Level.error',
+ 'ch.qos.logback.classic.Level.warn',
+ 'ch.qos.logback.classic.Level.info',
+ 'ch.qos.logback.classic.Level.debug',
+ 'ch.qos.logback.classic.Level.trace',
+ 'ch.qos.logback.classic.Level.all,',
+ 'ch.qos.logback.classic.net.SSLSocketReceiver',
+ 'ch.qos.logback.classic.net.ReceiverBase',
+ 'ch.qos.logback.classic.net.SimpleSocketServer',
+ 'ch.qos.logback.classic.net.SimpleSSLSocketServer',
+ 'ch.qos.logback.classic.net.SocketNode',
+ 'ch.qos.logback.classic.net.SMTPAppender',
+ 'ch.qos.logback.classic.net.SocketReceiver',
+ 'ch.qos.logback.classic.net.SocketAcceptor',
+ 'ch.qos.logback.classic.net.SSLSocketAppender',
+ 'ch.qos.logback.classic.net.LoggingEventPreSerializationTransformer',
+ 'ch.qos.logback.classic.net.server.RemoteAppenderStreamClient',
+ 'ch.qos.logback.classic.net.server.RemoteAppenderServerListener',
+ 'ch.qos.logback.classic.net.server.SSLServerSocketAppender',
+ 'ch.qos.logback.classic.net.server.RemoteAppenderClient',
+ 'ch.qos.logback.classic.net.server.HardenedLoggingEventInputStream',
+ 'ch.qos.logback.classic.net.server.ServerSocketAppender',
+ 'ch.qos.logback.classic.net.server.SSLServerSocketReceiver',
+ 'ch.qos.logback.classic.net.server.RemoteAppenderServerRunner',
+ 'ch.qos.logback.classic.net.server.ServerSocketReceiver',
+ 'ch.qos.logback.classic.net.SocketAppender',
+ 'ch.qos.logback.classic.net.SyslogAppender',
+ 'ch.qos.logback.classic.PatternLayout',
+ 'ch.qos.logback.classic.util.ContextSelectorStaticBinder',
+ 'ch.qos.logback.classic.util.StatusViaSLF4JLoggerFactory',
+ 'ch.qos.logback.classic.util.JNDIUtil',
+ 'ch.qos.logback.classic.util.LevelToSyslogSeverity',
+ 'ch.qos.logback.classic.util.LoggerNameUtil',
+ 'ch.qos.logback.classic.util.LogbackMDCAdapter',
+ 'ch.qos.logback.classic.util.CopyOnInheritThreadLocal',
+ 'ch.qos.logback.classic.util.ContextInitializer',
+ 'ch.qos.logback.classic.util.EnvUtil',
+ 'ch.qos.logback.classic.util.DefaultNestedComponentRules',
+ 'ch.qos.logback.classic.AsyncAppender',
+ 'ch.qos.logback.classic.jul.JULHelper',
+ 'ch.qos.logback.classic.jul.LevelChangePropagator',
+ 'ch.qos.logback.classic.encoder.PatternLayoutEncoder',
+ 'ch.qos.logback.classic.db.names.DBNameResolver',
+ 'ch.qos.logback.classic.db.names.ColumnName',
+ 'ch.qos.logback.classic.db.names.TableName',
+ 'ch.qos.logback.classic.db.names.DefaultDBNameResolver',
+ 'ch.qos.logback.classic.db.names.SimpleDBNameResolver',
+ 'ch.qos.logback.classic.log4j.XMLLayout',
+ 'ch.qos.logback.classic.LoggerContext',
+ 'ch.qos.logback.classic.turbo.TurboFilter',
+ 'ch.qos.logback.classic.turbo.MDCFilter',
+ 'ch.qos.logback.classic.turbo.ReconfigureOnChangeFilter',
+ 'ch.qos.logback.classic.turbo.DuplicateMessageFilter',
+ 'ch.qos.logback.classic.turbo.MarkerFilter',
+ 'ch.qos.logback.classic.turbo.MDCValueLevelPair',
+ 'ch.qos.logback.classic.turbo.DynamicThresholdFilter',
+ 'ch.qos.logback.classic.turbo.MatchingFilter',
+ 'ch.qos.logback.classic.turbo.LRUMessageCache',
+ 'ch.qos.logback.classic.selector.servlet.LoggerContextFilter',
+ 'ch.qos.logback.classic.selector.servlet.ContextDetachingSCL',
+ 'ch.qos.logback.classic.selector.ContextJNDISelector',
+ 'ch.qos.logback.classic.selector.DefaultContextSelector',
+ 'ch.qos.logback.classic.selector.ContextSelector',
+ 'ch.qos.logback.classic.sift.MDCBasedDiscriminator',
+ 'ch.qos.logback.classic.sift.SiftingJoranConfigurator',
+ 'ch.qos.logback.classic.sift.JNDIBasedContextDiscriminator',
+ 'ch.qos.logback.classic.sift.AppenderFactoryUsingJoran',
+ 'ch.qos.logback.classic.sift.ContextBasedDiscriminator',
+ 'ch.qos.logback.classic.sift.SiftingAppender',
+ 'ch.qos.logback.classic.sift.SiftAction',
+ 'ch.qos.logback.classic.html.UrlCssBuilder',
+ 'ch.qos.logback.classic.html.HTMLLayout',
+ 'ch.qos.logback.classic.html.DefaultCssBuilder',
+ 'ch.qos.logback.classic.html.DefaultThrowableRenderer',
+ 'ch.qos.logback.classic.Logger',
+ 'ch.qos.logback.classic.pattern.ThrowableHandlingConverter',
+ 'ch.qos.logback.classic.pattern.ContextNameConverter',
+ 'ch.qos.logback.classic.pattern.LocalSequenceNumberConverter',
+ 'ch.qos.logback.classic.pattern.ClassOfCallerConverter',
+ 'ch.qos.logback.classic.pattern.PrefixCompositeConverter',
+ 'ch.qos.logback.classic.pattern.LineOfCallerConverter',
+ 'ch.qos.logback.classic.pattern.EnsureExceptionHandling',
+ 'ch.qos.logback.classic.pattern.TargetLengthBasedClassNameAbbreviator',
+ 'ch.qos.logback.classic.pattern.FileOfCallerConverter',
+ 'ch.qos.logback.classic.pattern.LevelConverter',
+ 'ch.qos.logback.classic.pattern.ExtendedThrowableProxyConverter',
+ 'ch.qos.logback.classic.pattern.NamedConverter',
+ 'ch.qos.logback.classic.pattern.ClassicConverter',
+ 'ch.qos.logback.classic.pattern.NopThrowableInformationConverter',
+ 'ch.qos.logback.classic.pattern.RootCauseFirstThrowableProxyConverter',
+ 'ch.qos.logback.classic.pattern.MethodOfCallerConverter',
+ 'ch.qos.logback.classic.pattern.CallerDataConverter',
+ 'ch.qos.logback.classic.pattern.ClassNameOnlyAbbreviator',
+ 'ch.qos.logback.classic.pattern.MarkerConverter',
+ 'ch.qos.logback.classic.pattern.RelativeTimeConverter',
+ 'ch.qos.logback.classic.pattern.DateConverter',
+ 'ch.qos.logback.classic.pattern.PropertyConverter',
+ 'ch.qos.logback.classic.pattern.ThreadConverter',
+ 'ch.qos.logback.classic.pattern.LineSeparatorConverter',
+ 'ch.qos.logback.classic.pattern.MDCConverter',
+ 'ch.qos.logback.classic.pattern.color.HighlightingCompositeConverter',
+ 'ch.qos.logback.classic.pattern.ThrowableProxyConverter',
+ 'ch.qos.logback.classic.pattern.Abbreviator',
+ 'ch.qos.logback.classic.pattern.Util',
+ 'ch.qos.logback.classic.pattern.LoggerConverter',
+ 'ch.qos.logback.classic.pattern.SyslogStartConverter',
+ 'ch.qos.logback.classic.pattern.MessageConverter',
+ 'ch.qos.logback.classic.gaffer.GafferUtil',
+ 'ch.qos.logback.classic.boolex.OnMarkerEvaluator',
+ 'ch.qos.logback.classic.boolex.JaninoEventEvaluator',
+ 'ch.qos.logback.classic.boolex.OnErrorEvaluator',
+ 'ch.qos.logback.classic.boolex.GEventEvaluator',
+ 'ch.qos.logback.classic.boolex.IEvaluator',
+ 'ch.qos.logback.classic.filter.ThresholdFilter',
+ 'ch.qos.logback.classic.filter.LevelFilter',
+ 'java.lang.System',
+ 'java.lang.System.getenv',
+ 'java.lang.System.getProperty',
+ 'java.lang.System.getenv',
+ 'java.util.Map.getOrDefault',
+ 'com.kotlindiscord.kord.extensions.utils._EnvironmentKt.envOrNull',
+]
diff --git a/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/ParentPluginClassLoader.kt b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/ParentPluginClassLoader.kt
new file mode 100644
index 0000000000..09102517fb
--- /dev/null
+++ b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/ParentPluginClassLoader.kt
@@ -0,0 +1,30 @@
+package com.kotlindiscord.kord.extensions.plugins
+
+public class ParentPluginClassLoader(
+ public val pluginsDirectory: String,
+) : ClassLoader() {
+ private val childClassLoaders: MutableMap = mutableMapOf()
+
+ public fun loadPluginLoader(pluginId: String, jarPath: String): Boolean {
+ if (pluginId in childClassLoaders) {
+ return false
+ }
+
+ childClassLoaders[pluginId] = PluginClassLoader(jarPath, this)
+
+ return true
+ }
+
+ public fun removePluginLoader(pluginId: String): Boolean =
+ childClassLoaders.remove(pluginId) != null
+
+ override fun loadClass(name: String?): Class<*> {
+ for (cl in childClassLoaders.values) {
+ try {
+ return cl.loadClassWithoutRecursion(name)
+ } catch (_: ClassNotFoundException) {}
+ }
+
+ return getSystemClassLoader().loadClass(name)
+ }
+}
diff --git a/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/Plugin.kt b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/Plugin.kt
new file mode 100644
index 0000000000..b2495a3e5a
--- /dev/null
+++ b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/Plugin.kt
@@ -0,0 +1,25 @@
+package com.kotlindiscord.kord.extensions.plugins
+
+import com.kotlindiscord.kord.extensions.plugins.types.PluginManifest
+
+public abstract class Plugin> {
+ public lateinit var pluginManager: PluginManager
+ internal set
+
+ public lateinit var manifest: PluginManifest
+ internal set
+
+ public abstract fun load()
+ public abstract fun unload()
+
+ public open fun onPluginLoaded(plugin: T) {}
+ public open fun onPluginUnloaded(plugin: T) {}
+
+ public open fun internalLoad() {
+ load()
+ }
+
+ public open fun internalUnload() {
+ unload()
+ }
+}
diff --git a/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/PluginClassLoader.kt b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/PluginClassLoader.kt
new file mode 100644
index 0000000000..0abd4a1fd9
--- /dev/null
+++ b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/PluginClassLoader.kt
@@ -0,0 +1,29 @@
+package com.kotlindiscord.kord.extensions.plugins
+
+import java.net.URL
+import java.net.URLClassLoader
+
+public class PluginClassLoader(
+ public val jarPath: String,
+ private val parent: ParentPluginClassLoader
+) : ClassLoader() {
+ private val urlClassLoader = URLClassLoader(
+ arrayOf(URL("file://${parent.pluginsDirectory}/$jarPath")),
+ null
+ )
+
+ internal fun loadClassWithoutRecursion(name: String?): Class<*> =
+ urlClassLoader.loadClass(name)
+
+ override fun loadClass(name: String?): Class<*> {
+ return try {
+ urlClassLoader.loadClass(name)
+ } catch (e: ClassNotFoundException) {
+ parent.loadClass(name)
+ }
+ }
+
+ public fun close() {
+ urlClassLoader.close()
+ }
+}
diff --git a/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/PluginManager.kt b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/PluginManager.kt
new file mode 100644
index 0000000000..774b5f947d
--- /dev/null
+++ b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/PluginManager.kt
@@ -0,0 +1,219 @@
+@file:Suppress("TooGenericExceptionCaught")
+
+package com.kotlindiscord.kord.extensions.plugins
+
+import com.kotlindiscord.kord.extensions.plugins.constraints.ConstraintChecker
+import com.kotlindiscord.kord.extensions.plugins.constraints.ConstraintResult
+import com.kotlindiscord.kord.extensions.plugins.types.PluginManifest
+import io.github.oshai.kotlinlogging.KotlinLogging
+import io.github.z4kn4fein.semver.Version
+import kotlinx.serialization.json.Json
+import java.io.File
+import java.lang.ref.WeakReference
+import java.nio.file.FileSystems
+import kotlin.io.path.exists
+import kotlin.io.path.readText
+
+public class PluginManager>(
+ public val baseTypeReference: String,
+ public val extraVersions: Map = mapOf(),
+ public val errorOnFailedConstraint: Boolean = true,
+
+ pluginDirectory: String = "plugins",
+ constraintCheckerCallback: (PluginManager<*>) -> ConstraintChecker = ::ConstraintChecker,
+) {
+ private val logger = KotlinLogging.logger {}
+ private val constraintChecker = constraintCheckerCallback(this)
+
+ public val pluginsDirectory: String = pluginDirectory.trimEnd('/', '\\')
+
+ private val classLoader: ParentPluginClassLoader = ParentPluginClassLoader(pluginsDirectory)
+ private val pluginManifests: MutableMap = mutableMapOf()
+
+ private val plugins: MutableMap = mutableMapOf()
+
+ @Suppress("UNCHECKED_CAST")
+ public fun loadPlugin(pluginId: String, setup: Boolean = true, checkManifest: Boolean = true): WeakReference? {
+ if (pluginId in plugins) {
+ return WeakReference(plugins[pluginId]!!)
+ }
+
+ val manifest = pluginManifests[pluginId]
+ ?: return null
+
+ if (checkManifest) {
+ val results = constraintChecker.checkPlugin(pluginId, pluginManifests)
+
+ if (results.isNotEmpty()) {
+ logConstraintResults(results)
+
+ return null
+ }
+ }
+
+ if (manifest.failed) {
+ logger.warn { "Not loading \"$pluginId\" as it failed to validate" }
+
+ return null
+ }
+
+ classLoader.loadPluginLoader(pluginId, manifest.jarPath)
+
+ val loadedClass = try {
+ classLoader.loadClass(manifest.classRef).getDeclaredConstructor().newInstance() as T?
+ } catch (e: ClassNotFoundException) {
+ logger.error { "Failed to load \"$pluginId\": Class not found" }
+
+ return null
+ }
+
+ if (loadedClass == null) {
+ logger.error { "Class ${manifest.classRef} for \"$pluginId\" does not extend $baseTypeReference" }
+
+ return null
+ }
+
+ loadedClass.manifest = manifest
+ loadedClass.pluginManager = this
+
+ plugins[pluginId] = loadedClass
+
+ if (setup) {
+ try {
+ loadedClass.internalLoad()
+ } catch (e: Exception) {
+ logger.error(e) { "Failed to set up plugin \"$pluginId\", load function threw an exception" }
+ unloadPlugin(pluginId)
+
+ return null
+ }
+ }
+
+ logger.debug { "Loaded \"$pluginId\"" }
+
+ return WeakReference(loadedClass)
+ }
+
+ public fun unloadPlugin(pluginId: String): Boolean {
+ if (pluginId in plugins) {
+ try {
+ plugins[pluginId]!!.internalUnload()
+ } catch (e: Exception) {
+ logger.error(e) { "Failed to properly unload plugin \"$pluginId\", unload function threw an exception" }
+ }
+
+ plugins.remove(pluginId)
+ classLoader.removePluginLoader(pluginId)
+
+ return true
+ }
+
+ return false
+ }
+
+ public fun loadAllPlugins() {
+ reloadManifests()
+
+ val pluginsToSetup = pluginManifests.keys.filter { it !in plugins }
+
+ for (manifest in pluginManifests.values) {
+ try {
+ loadPlugin(manifest.id, setup = false, checkManifest = false)
+ } catch (e: Exception) {
+ logger.error(e) { "Failed to load plugin ${manifest.id} from ${manifest.jarPath}" }
+ }
+ }
+
+ pluginsToSetup.forEach {
+ try {
+ plugins[it]?.internalLoad()
+ } catch (e: Exception) {
+ logger.error(e) { "Failed to set up plugin \"$it\", load function threw an exception" }
+
+ unloadPlugin(it)
+ }
+ }
+ }
+
+ public fun reloadManifests(): Boolean {
+ val files = File(pluginsDirectory).listFiles()
+
+ if (files == null) {
+ logger.error { "Plugin directory $pluginsDirectory does not exist or is inaccessible" }
+
+ return false
+ }
+
+ val foundManifests: MutableList = mutableListOf()
+
+ for (jarFile in files.filter { file -> file.extension == "jar" }) {
+ val zipFileSystem = FileSystems.newFileSystem(jarFile.toPath(), emptyMap())
+ val path = zipFileSystem.getPath("kordex.plugin.json")
+
+ if (!path.exists()) {
+ logger.warn { "Skipping ${jarFile.name} | JAR file does not contain a kordex.plugin.json file" }
+
+ continue
+ }
+
+ val manifest: PluginManifest = try {
+ Json.decodeFromString(path.readText())
+ } catch (e: Exception) {
+ logger.error(e) { "Skipping ${jarFile.name} | Failed to load invalid manifest in kordex.plugin.json" }
+
+ continue
+ }
+
+ zipFileSystem.close()
+
+ manifest.jarPath = jarFile.name
+
+ pluginManifests[manifest.id] = manifest
+ foundManifests.add(manifest)
+ }
+
+ val results = constraintChecker.checkAll(pluginManifests)
+
+ if (results.isNotEmpty()) {
+ logConstraintResults(results)
+
+ if (errorOnFailedConstraint) {
+ error("Failed to load plugins - check the log for more information.")
+ }
+ }
+
+ logger.info { "Loaded ${foundManifests.size} plugin manifests" }
+
+ return true
+ }
+
+ public fun logConstraintResults(results: MutableMap>) {
+ val length = results.keys.maxBy { it.length }.length
+
+ logger.error {
+ buildString {
+ appendLine("Plugin constraint errors detected:")
+ appendLine()
+
+ results.forEach { (pluginId, resultList) ->
+ resultList.forEach { result ->
+ appendLine("${pluginId.padEnd(length)} | ${result.readableString}")
+ }
+ }
+ }
+ }
+ }
+
+ public fun logConstraintResults(results: List) {
+ logger.error {
+ buildString {
+ appendLine("Plugin constraint errors detected:")
+ appendLine()
+
+ results.forEach { result ->
+ appendLine("${result.pluginId} | ${result.readableString}")
+ }
+ }
+ }
+ }
+}
diff --git a/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/constraints/ConstraintChecker.kt b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/constraints/ConstraintChecker.kt
new file mode 100644
index 0000000000..76ae8996c6
--- /dev/null
+++ b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/constraints/ConstraintChecker.kt
@@ -0,0 +1,135 @@
+package com.kotlindiscord.kord.extensions.plugins.constraints
+
+import com.kotlindiscord.kord.extensions.plugins.PluginManager
+import com.kotlindiscord.kord.extensions.plugins.types.PluginManifest
+import io.github.z4kn4fein.semver.constraints.satisfiedBy
+
+private const val MAX_DEPTH: Int = 5
+
+public open class ConstraintChecker(
+ protected val pluginManager: PluginManager<*>,
+) {
+ public open fun checkAll(manifests: Map): MutableMap> {
+ val results: MutableMap> = mutableMapOf()
+
+ manifests.forEach { (pluginId, _) ->
+ val result = checkPlugin(pluginId, manifests)
+
+ if (result.isNotEmpty()) {
+ results[pluginId] = result
+ }
+ }
+
+ return results
+ }
+
+ public open fun checkPlugin(
+ pluginId: String,
+ manifests: Map,
+ depth: Int = 0,
+ alreadyExamined: MutableSet = mutableSetOf()
+ ): List {
+ val results: MutableList = mutableListOf()
+
+ val manifest = manifests[pluginId]
+ ?: return emptyList() // Likely from the "extraVersions" map in the plugin manager
+
+ alreadyExamined.add(pluginId)
+
+ if (depth >= MAX_DEPTH) {
+ error("Maximum recursion depth exceeded")
+ }
+
+ for ((conflictingId, constraint) in manifest.constraints.conflicts) {
+ val conflictingVersion = manifests[conflictingId]?.version
+ ?: pluginManager.extraVersions[conflictingId]
+ ?: continue
+
+ if (constraint satisfiedBy conflictingVersion) {
+ results.add(
+ ConstraintResult.Conflict(
+ pluginId,
+ conflictingId,
+ conflictingVersion,
+ constraint
+ )
+ )
+ }
+ }
+
+ for ((neededId, constraint) in manifest.constraints.needs) {
+ val neededVersion = manifests[neededId]?.version
+ ?: pluginManager.extraVersions[neededId]
+
+ if (neededVersion == null) {
+ results.add(
+ ConstraintResult.Missing(
+ pluginId,
+ neededId,
+ constraint
+ )
+ )
+ } else if (!(constraint satisfiedBy neededVersion)) {
+ results.add(
+ ConstraintResult.WrongNeededVersion(
+ pluginId,
+ neededId,
+ neededVersion,
+ constraint
+ )
+ )
+ } else {
+ if (neededId in alreadyExamined) {
+ continue
+ }
+
+ val innerResults = checkPlugin(neededId, manifests, depth + 1, alreadyExamined)
+
+ if (innerResults.isNotEmpty()) {
+ results.add(
+ ConstraintResult.NeededPluginFailedConstraints(
+ pluginId, neededId, innerResults
+ )
+ )
+ }
+ }
+ }
+
+ for ((wantedId, constraint) in manifest.constraints.wants) {
+ val wantedVersion = manifests[wantedId]?.version
+ ?: pluginManager.extraVersions[wantedId]
+ ?: continue
+
+ if (!(constraint satisfiedBy wantedVersion)) {
+ results.add(
+ ConstraintResult.WrongWantedVersion(
+ pluginId,
+ wantedId,
+ wantedVersion,
+ constraint
+ )
+ )
+ } else {
+ if (wantedId in alreadyExamined) {
+ continue
+ }
+
+ val innerResults = checkPlugin(wantedId, manifests, depth + 1, alreadyExamined)
+
+ if (innerResults.isNotEmpty()) {
+ results.add(
+ ConstraintResult.WantedPluginFailedConstraints(
+ pluginId, wantedId, innerResults
+ )
+ )
+ }
+ }
+ }
+
+ if (results.isNotEmpty()) {
+ manifests[pluginId]?.failed = true
+ }
+
+ return results
+ }
+}
diff --git a/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/constraints/ConstraintResult.kt b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/constraints/ConstraintResult.kt
new file mode 100644
index 0000000000..8191f7f8ec
--- /dev/null
+++ b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/constraints/ConstraintResult.kt
@@ -0,0 +1,118 @@
+package com.kotlindiscord.kord.extensions.plugins.constraints
+
+import io.github.z4kn4fein.semver.Version
+import io.github.z4kn4fein.semver.constraints.Constraint
+
+private const val INDENT = "\t -> "
+
+public sealed class ConstraintResult(
+ public val pluginId: String,
+ public open val readableString: String,
+) {
+ public class Conflict(
+ pluginId: String,
+
+ public val conflictingPluginId: String,
+ public val conflictingPluginVersion: Version,
+ public val constraint: Constraint,
+ ) : ConstraintResult(
+ pluginId,
+ "Conflicts with \"$conflictingPluginId\" version $conflictingPluginVersion " +
+ "(constraint: $constraint)"
+ ) {
+ override fun toString(): String =
+ "Conflict: $pluginId -> $conflictingPluginId v$conflictingPluginVersion ($constraint)"
+ }
+
+ public class Missing(
+ pluginId: String,
+
+ public val neededPluginId: String,
+ public val constraint: Constraint,
+ ) : ConstraintResult(
+ pluginId,
+ "Needs \"$neededPluginId\" (constraint: $constraint), but it's missing"
+ ) {
+ override fun toString(): String =
+ "Missing: $pluginId -> $neededPluginId ($constraint)"
+ }
+
+ public sealed class WrongVersion(
+ pluginId: String,
+
+ public override val readableString: String,
+ ) : ConstraintResult(pluginId, readableString) {
+ public abstract val requiredPluginId: String
+ public abstract val requiredPluginVersion: Version
+ public abstract val constraint: Constraint
+ }
+
+ public class WrongNeededVersion(
+ pluginId: String,
+
+ public override val requiredPluginId: String,
+ public override val requiredPluginVersion: Version,
+ public override val constraint: Constraint,
+ ) : WrongVersion(
+ pluginId,
+ "Needs \"$requiredPluginId\" (constraint: $constraint), but incompatible version " +
+ "$requiredPluginVersion was provided"
+ ) {
+ override fun toString(): String =
+ "Need version: $pluginId -> $requiredPluginId $constraint, found $requiredPluginVersion"
+ }
+
+ public class WrongWantedVersion(
+ pluginId: String,
+
+ public override val requiredPluginId: String,
+ public override val requiredPluginVersion: Version,
+ public override val constraint: Constraint,
+ ) : WrongVersion(
+ pluginId,
+ "Wants \"$requiredPluginId\" (constraint: $constraint), but incompatible version " +
+ "$requiredPluginVersion was provided"
+ ) {
+ override fun toString(): String =
+ "Want version: $pluginId -> $requiredPluginId $constraint, found $requiredPluginVersion"
+ }
+
+ public sealed class FailedInnerConstraints(
+ pluginId: String,
+
+ public override val readableString: String,
+ ) : ConstraintResult(pluginId, readableString) {
+ public abstract val requiredPluginId: String
+ public abstract val innerResults: List
+ }
+
+ public class NeededPluginFailedConstraints(
+ pluginId: String,
+
+ public override val requiredPluginId: String,
+ public override val innerResults: List,
+ ) : FailedInnerConstraints(
+ pluginId,
+ "\"$requiredPluginId\" is required but has ${innerResults.size} failed constraints:\n" +
+ innerResults.joinToString("\n") { it.readableString.prependIndent(INDENT) }
+ ) {
+ override fun toString(): String =
+ "Needed failed constraints: $pluginId -> $requiredPluginId, ${innerResults.size} failures \n" +
+ innerResults.joinToString("\n") { it.toString().prependIndent(INDENT) }
+ }
+
+ public class WantedPluginFailedConstraints(
+ pluginId: String,
+
+ public override val requiredPluginId: String,
+ public override val innerResults: List,
+ ) : FailedInnerConstraints(
+ pluginId,
+ "\"$requiredPluginId\" is wanted but has ${innerResults.size} failed constraints:\n" +
+ innerResults.joinToString("\n") { it.readableString.prependIndent(INDENT) }
+ ) {
+ override fun toString(): String =
+ "Wanted failed constraints: $pluginId -> $requiredPluginId, ${innerResults.size} failures \n" +
+ innerResults.joinToString("\n") { it.toString().prependIndent(INDENT) }
+ }
+}
diff --git a/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/types/Aliases.kt b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/types/Aliases.kt
new file mode 100644
index 0000000000..7e6183a9f5
--- /dev/null
+++ b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/types/Aliases.kt
@@ -0,0 +1,5 @@
+package com.kotlindiscord.kord.extensions.plugins.types
+
+import io.github.z4kn4fein.semver.constraints.Constraint
+
+public typealias PluginConstraint = Map
diff --git a/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/types/PluginConstraints.kt b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/types/PluginConstraints.kt
new file mode 100644
index 0000000000..fd42a38c64
--- /dev/null
+++ b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/types/PluginConstraints.kt
@@ -0,0 +1,10 @@
+package com.kotlindiscord.kord.extensions.plugins.types
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+public data class PluginConstraints(
+ val conflicts: PluginConstraint = mapOf(),
+ val needs: PluginConstraint = mapOf(),
+ val wants: PluginConstraint = mapOf(),
+)
diff --git a/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/types/PluginManifest.kt b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/types/PluginManifest.kt
new file mode 100644
index 0000000000..876ab0c6fd
--- /dev/null
+++ b/plugins/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/types/PluginManifest.kt
@@ -0,0 +1,28 @@
+package com.kotlindiscord.kord.extensions.plugins.types
+
+import io.github.z4kn4fein.semver.Version
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
+
+@Serializable
+@Suppress("DataClassShouldBeImmutable")
+public data class PluginManifest(
+ @SerialName("class")
+ val classRef: String,
+
+ val id: String,
+
+ val description: String,
+ val license: String,
+ val name: String,
+ val version: Version,
+
+ val constraints: PluginConstraints = PluginConstraints(),
+) {
+ @Transient
+ lateinit var jarPath: String
+
+ @Transient
+ public var failed: Boolean = false
+}
diff --git a/plugins/src/test/kotlin/ManifestFixtures.kt b/plugins/src/test/kotlin/ManifestFixtures.kt
new file mode 100644
index 0000000000..45e98ed1cd
--- /dev/null
+++ b/plugins/src/test/kotlin/ManifestFixtures.kt
@@ -0,0 +1,224 @@
+import com.kotlindiscord.kord.extensions.plugins.types.PluginConstraints
+import com.kotlindiscord.kord.extensions.plugins.types.PluginManifest
+import io.github.z4kn4fein.semver.constraints.toConstraint
+import io.github.z4kn4fein.semver.toVersion
+
+object ManifestFixtures {
+ val good: Map = mapOf(
+ "one" to PluginManifest(
+ classRef = "a.b.OneKt",
+ id = "one",
+ description = "Test Plugin 1",
+ license = "MPL-2.0",
+ name = "Test Plugin 1",
+ version = "0.0.1".toVersion(),
+
+ constraints = PluginConstraints(
+ needs = mapOf(
+ "two" to ">= 0.0.1".toConstraint(),
+ "fake20" to "> 1.0.0".toConstraint()
+ ),
+
+ wants = mapOf(
+ "three" to "> 0.0.2".toConstraint()
+ )
+ )
+ ),
+
+ "two" to PluginManifest(
+ classRef = "a.b.TwoKt",
+ id = "two",
+ description = "Test Plugin 2",
+ license = "MPL-2.0",
+ name = "Test Plugin 2",
+ version = "0.0.2".toVersion(),
+
+ constraints = PluginConstraints(
+ needs = mapOf(
+ "one" to "*".toConstraint(),
+ "fake35" to "> 2.5.0".toConstraint()
+ ),
+
+ wants = mapOf(
+ "three" to "*".toConstraint()
+ )
+ )
+ ),
+
+ "three" to PluginManifest(
+ classRef = "a.b.TwoKt",
+ id = "two",
+ description = "Test Plugin 2",
+ license = "MPL-2.0",
+ name = "Test Plugin 2",
+ version = "0.0.3".toVersion()
+ ),
+ )
+
+ val missingNeeds: Map = mapOf(
+ "one" to PluginManifest(
+ classRef = "a.b.OneKt",
+ id = "one",
+ description = "Test Plugin 1",
+ license = "MPL-2.0",
+ name = "Test Plugin 1",
+ version = "0.0.1".toVersion(),
+
+ constraints = PluginConstraints(
+ needs = mapOf(
+ "two" to ">= 0.0.1".toConstraint(),
+ "fake20" to "> 1.0.0".toConstraint(),
+ "three" to "> 0.0.2".toConstraint()
+ ),
+ )
+ ),
+
+ "two" to PluginManifest(
+ classRef = "a.b.TwoKt",
+ id = "two",
+ description = "Test Plugin 2",
+ license = "MPL-2.0",
+ name = "Test Plugin 2",
+ version = "0.0.2".toVersion(),
+
+ constraints = PluginConstraints(
+ needs = mapOf(
+ "one" to "*".toConstraint(),
+ "fake35" to "> 2.5.0".toConstraint(),
+ "three" to "*".toConstraint()
+ ),
+ )
+ ),
+ )
+ val missingWants: Map = mapOf(
+ "one" to PluginManifest(
+ classRef = "a.b.OneKt",
+ id = "one",
+ description = "Test Plugin 1",
+ license = "MPL-2.0",
+ name = "Test Plugin 1",
+ version = "0.0.1".toVersion(),
+
+ constraints = PluginConstraints(
+ wants = mapOf(
+ "two" to ">= 0.0.1".toConstraint(),
+ "fake20" to "> 1.0.0".toConstraint(),
+ "three" to "> 0.0.2".toConstraint()
+ ),
+ )
+ ),
+
+ "two" to PluginManifest(
+ classRef = "a.b.TwoKt",
+ id = "two",
+ description = "Test Plugin 2",
+ license = "MPL-2.0",
+ name = "Test Plugin 2",
+ version = "0.0.2".toVersion(),
+
+ constraints = PluginConstraints(
+ wants = mapOf(
+ "one" to "*".toConstraint(),
+ "fake35" to "> 2.5.0".toConstraint(),
+ "three" to "*".toConstraint()
+ ),
+ )
+ ),
+ )
+
+ val badWants: Map = mapOf(
+ "one" to PluginManifest(
+ classRef = "a.b.OneKt",
+ id = "one",
+ description = "Test Plugin 1",
+ license = "MPL-2.0",
+ name = "Test Plugin 1",
+ version = "0.0.1".toVersion(),
+
+ constraints = PluginConstraints(
+ needs = mapOf(
+ "two" to ">= 0.0.1".toConstraint(),
+ "fake20" to "> 1.0.0".toConstraint()
+ ),
+
+ wants = mapOf(
+ "three" to "> 0.0.4".toConstraint()
+ )
+ )
+ ),
+
+ "two" to PluginManifest(
+ classRef = "a.b.TwoKt",
+ id = "two",
+ description = "Test Plugin 2",
+ license = "MPL-2.0",
+ name = "Test Plugin 2",
+ version = "0.0.2".toVersion(),
+
+ constraints = PluginConstraints(
+ needs = mapOf(
+ "one" to "*".toConstraint(),
+ "fake35" to "> 2.5.0".toConstraint()
+ ),
+
+ wants = mapOf(
+ "fake35" to "< 3.0.0".toConstraint()
+ )
+ )
+ ),
+
+ "three" to PluginManifest(
+ classRef = "a.b.TwoKt",
+ id = "two",
+ description = "Test Plugin 2",
+ license = "MPL-2.0",
+ name = "Test Plugin 2",
+ version = "0.0.3".toVersion()
+ ),
+ )
+
+ val badNeeds: Map = mapOf(
+ "one" to PluginManifest(
+ classRef = "a.b.OneKt",
+ id = "one",
+ description = "Test Plugin 1",
+ license = "MPL-2.0",
+ name = "Test Plugin 1",
+ version = "0.0.1".toVersion(),
+
+ constraints = PluginConstraints(
+ needs = mapOf(
+ "two" to ">= 0.0.1".toConstraint(),
+ "three" to "> 0.0.4".toConstraint(),
+
+ "fake20" to "> 1.0.0".toConstraint()
+ ),
+ )
+ ),
+
+ "two" to PluginManifest(
+ classRef = "a.b.TwoKt",
+ id = "two",
+ description = "Test Plugin 2",
+ license = "MPL-2.0",
+ name = "Test Plugin 2",
+ version = "0.0.2".toVersion(),
+
+ constraints = PluginConstraints(
+ needs = mapOf(
+ "one" to "*".toConstraint(),
+ "fake35" to "< 3.0.0".toConstraint(),
+ ),
+ )
+ ),
+
+ "three" to PluginManifest(
+ classRef = "a.b.TwoKt",
+ id = "two",
+ description = "Test Plugin 2",
+ license = "MPL-2.0",
+ name = "Test Plugin 2",
+ version = "0.0.3".toVersion()
+ ),
+ )
+}
diff --git a/plugins/src/test/kotlin/tests/ConstraintTest.kt b/plugins/src/test/kotlin/tests/ConstraintTest.kt
new file mode 100644
index 0000000000..418b301aaf
--- /dev/null
+++ b/plugins/src/test/kotlin/tests/ConstraintTest.kt
@@ -0,0 +1,125 @@
+package tests
+
+import ManifestFixtures
+import com.kotlindiscord.kord.extensions.plugins.PluginManager
+import com.kotlindiscord.kord.extensions.plugins.constraints.ConstraintChecker
+import com.kotlindiscord.kord.extensions.plugins.constraints.ConstraintResult
+import io.github.z4kn4fein.semver.toVersion
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.api.parallel.Execution
+import org.junit.jupiter.api.parallel.ExecutionMode
+import types.FakePlugin
+
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class ConstraintTest {
+ private val pluginManager = PluginManager(
+ "types.FakePlugin",
+
+ extraVersions = mapOf(
+ "fake10" to "1.0.0".toVersion(),
+ "fake15" to "1.5.0".toVersion(),
+ "fake20" to "2.0.0".toVersion(),
+ "fake25" to "2.5.0".toVersion(),
+ "fake30" to "3.0.0".toVersion(),
+ "fake35" to "3.5.0".toVersion(),
+ )
+ )
+
+ private val constraintChecker = ConstraintChecker(pluginManager)
+
+ @Test
+ @Execution(ExecutionMode.CONCURRENT)
+ fun `No problems with good constraints`() {
+ val results = constraintChecker.checkAll(ManifestFixtures.good)
+
+ assert(results.isEmpty()) {
+ "Expected zero results, got $results"
+ }
+ }
+
+ @Test
+ @Execution(ExecutionMode.CONCURRENT)
+ fun `No problems with missing wanted dependency`() {
+ val results = constraintChecker.checkAll(ManifestFixtures.missingWants)
+
+ assert(results.isEmpty()) {
+ "Expected zero results, got $results"
+ }
+ }
+
+ @Test
+ @Execution(ExecutionMode.CONCURRENT)
+ fun `Problems found with bad wants`() {
+ val results = constraintChecker.checkAll(ManifestFixtures.badWants)
+
+ assert(results.isNotEmpty()) {
+ "Expected at least one result, got zero"
+ }
+
+ val badResults = results.values.flatten().filter {
+ it !is ConstraintResult.WrongWantedVersion &&
+ it !is ConstraintResult.FailedInnerConstraints
+ }
+
+ assert(badResults.isEmpty()) {
+ "Expected only wanted version/inner failures, additionally found $badResults"
+ }
+ }
+
+ @Test
+ @Execution(ExecutionMode.CONCURRENT)
+ fun `Problems found with bad needs`() {
+ val results = constraintChecker.checkAll(ManifestFixtures.badNeeds)
+
+ assert(results.isNotEmpty()) {
+ "Expected at least one result, got zero"
+ }
+
+ val badResults = results.values.flatten().filter {
+ it !is ConstraintResult.WrongNeededVersion &&
+ it !is ConstraintResult.FailedInnerConstraints
+ }
+
+ assert(badResults.isEmpty()) {
+ "Expected only needed version/inner failures results, additionally found $badResults"
+ }
+ }
+
+ @Test
+ @Execution(ExecutionMode.CONCURRENT)
+ fun `Problems found with missing needed dependency`() {
+ val results = constraintChecker.checkAll(ManifestFixtures.missingNeeds)
+
+ assert(results.isNotEmpty()) {
+ "Expected at least one result, got zero"
+ }
+
+ val badResults = results.values.flatten().filter {
+ it !is ConstraintResult.Missing &&
+ it !is ConstraintResult.FailedInnerConstraints
+ }
+
+ assert(badResults.isEmpty()) {
+ "Expected only missing/inner failures, additionally found $badResults"
+ }
+ }
+
+ @Test
+ @Execution(ExecutionMode.CONCURRENT)
+ fun `Recursive checking with errors`() {
+ val results = constraintChecker.checkPlugin("one", ManifestFixtures.badWants)
+
+ assert(results.isNotEmpty()) {
+ "Expected at least one result, got zero"
+ }
+
+ val goodResults = results.filter {
+ it is ConstraintResult.FailedInnerConstraints
+ }
+
+ assert(goodResults.isNotEmpty()) {
+ "Expected inner failures results, but found zero; results: $results"
+ }
+ }
+}
diff --git a/plugins/src/test/kotlin/types/FakePlugin.kt b/plugins/src/test/kotlin/types/FakePlugin.kt
new file mode 100644
index 0000000000..d3aa07d4b8
--- /dev/null
+++ b/plugins/src/test/kotlin/types/FakePlugin.kt
@@ -0,0 +1,13 @@
+package types
+
+import com.kotlindiscord.kord.extensions.plugins.Plugin
+
+class FakePlugin : Plugin() {
+ override fun load() {
+ TODO("Not yet implemented")
+ }
+
+ override fun unload() {
+ TODO("Not yet implemented")
+ }
+}
diff --git a/plugins/src/test/resources/junit-platform.properties b/plugins/src/test/resources/junit-platform.properties
new file mode 100644
index 0000000000..580f511dad
--- /dev/null
+++ b/plugins/src/test/resources/junit-platform.properties
@@ -0,0 +1,7 @@
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+#
+
+junit.jupiter.execution.parallel.enabled=true
diff --git a/plugins/src/test/resources/logback.groovy b/plugins/src/test/resources/logback.groovy
new file mode 100644
index 0000000000..a0c69989f4
--- /dev/null
+++ b/plugins/src/test/resources/logback.groovy
@@ -0,0 +1,40 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import ch.qos.logback.classic.encoder.PatternLayoutEncoder
+import ch.qos.logback.core.joran.spi.ConsoleTarget
+import ch.qos.logback.core.ConsoleAppender
+import ch.qos.logback.core.FileAppender
+
+def environment = System.getenv("ENVIRONMENT") ?: "dev"
+def defaultLevel = TRACE
+
+if (environment == "spam") {
+ logger("dev.kord.rest.DefaultGateway", TRACE)
+} else {
+ // Silence warning about missing native PRNG
+ logger("io.ktor.util.random", ERROR)
+}
+
+appender("CONSOLE", ConsoleAppender) {
+ encoder(PatternLayoutEncoder) {
+ pattern = "%boldGreen(%d{yyyy-MM-dd}) %boldYellow(%d{HH:mm:ss}) %gray(|) %highlight(%5level) %gray(|) %boldMagenta(%40.40logger{40}) %gray(|) %msg%n"
+
+ withJansi = true
+ }
+
+ target = ConsoleTarget.SystemOut
+}
+
+appender("FILE", FileAppender) {
+ file = "output.log"
+
+ encoder(PatternLayoutEncoder) {
+ pattern = "%d{yyyy-MM-dd HH:mm:ss:SSS Z} | %5level | %40.40logger{40} | %msg%n"
+ }
+}
+
+root(defaultLevel, ["CONSOLE", "FILE"])
diff --git a/plugins/src/test/resources/logbackCompiler.groovy b/plugins/src/test/resources/logbackCompiler.groovy
new file mode 100644
index 0000000000..a374f9af62
--- /dev/null
+++ b/plugins/src/test/resources/logbackCompiler.groovy
@@ -0,0 +1,458 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+importsAcceptList = [
+ 'ch.qos.logback.core.testUtil.SampleConverter',
+
+ 'ch.qos.logback.core.testUtil.StringListAppender',
+ 'java.lang.Object',
+ 'org.springframework.beans.factory.annotation.Autowired',
+ 'java.nio.charset.Charset.forName',
+ 'com.logentries.logback.LogentriesAppender',
+ 'grails.util.BuildSettings',
+ 'grails.util.Environment',
+ 'io.micronaut.context.env.Environment',
+ 'org.slf4j.MDC',
+ 'org.springframework.boot.logging.logback.ColorConverter',
+ 'org.springframework.boot.logging.logback.WhitespaceThrowableProxyConverter',
+ 'java.nio.charset.Charset',
+ 'java.nio.charset.StandardCharsets',
+
+ 'ch.qos.logback.core.BasicStatusManager',
+ 'ch.qos.logback.core.ConsoleAppender',
+ 'ch.qos.logback.core.hook.ShutdownHook',
+ 'ch.qos.logback.core.hook.ShutdownHookBase',
+ 'ch.qos.logback.core.hook.DelayingShutdownHook',
+ 'ch.qos.logback.core.spi.PropertyContainer',
+ 'ch.qos.logback.core.spi.ContextAwareBase',
+ 'ch.qos.logback.core.spi.LogbackLock',
+ 'ch.qos.logback.core.spi.FilterAttachableImpl',
+ 'ch.qos.logback.core.spi.ContextAwareImpl',
+ 'ch.qos.logback.core.spi.ScanException',
+ 'ch.qos.logback.core.spi.DeferredProcessingAware',
+ 'ch.qos.logback.core.spi.ContextAware',
+ 'ch.qos.logback.core.spi.LifeCycle',
+ 'ch.qos.logback.core.spi.FilterReply',
+ 'ch.qos.logback.core.spi.PreSerializationTransformer',
+ 'ch.qos.logback.core.spi.AppenderAttachable',
+ 'ch.qos.logback.core.spi.CyclicBufferTracker',
+ 'ch.qos.logback.core.spi.FilterAttachable',
+ 'ch.qos.logback.core.spi.ComponentTracker',
+ 'ch.qos.logback.core.spi.AppenderAttachableImpl',
+ 'ch.qos.logback.core.spi.PropertyDefiner',
+ 'ch.qos.logback.core.spi.AbstractComponentTracker',
+ 'ch.qos.logback.core.property.FileExistsPropertyDefiner',
+ 'ch.qos.logback.core.property.ResourceExistsPropertyDefiner',
+ 'ch.qos.logback.core.CoreConstants',
+ 'ch.qos.logback.core.layout.EchoLayout',
+ 'ch.qos.logback.core.Appender',
+ 'ch.qos.logback.core.joran.JoranConfiguratorBase',
+ 'ch.qos.logback.core.joran.spi.ActionException',
+ 'ch.qos.logback.core.joran.spi.HostClassAndPropertyDouble',
+ 'ch.qos.logback.core.joran.spi.JoranException',
+ 'ch.qos.logback.core.joran.spi.NoAutoStart',
+ 'ch.qos.logback.core.joran.spi.EventPlayer',
+ 'ch.qos.logback.core.joran.spi.XMLUtil',
+ 'ch.qos.logback.core.joran.spi.ConsoleTarget',
+ 'ch.qos.logback.core.joran.spi.Interpreter',
+ 'ch.qos.logback.core.joran.spi.SimpleRuleStore',
+ 'ch.qos.logback.core.joran.spi.InterpretationContext',
+ 'ch.qos.logback.core.joran.spi.RuleStore',
+ 'ch.qos.logback.core.joran.spi.NoAutoStartUtil',
+ 'ch.qos.logback.core.joran.spi.ElementSelector',
+ 'ch.qos.logback.core.joran.spi.ConfigurationWatchList',
+ 'ch.qos.logback.core.joran.spi.DefaultNestedComponentRegistry',
+ 'ch.qos.logback.core.joran.spi.DefaultClass',
+ 'ch.qos.logback.core.joran.spi.ElementPath',
+ 'ch.qos.logback.core.joran.conditional.ThenOrElseActionBase',
+ 'ch.qos.logback.core.joran.conditional.Condition',
+ 'ch.qos.logback.core.joran.conditional.PropertyWrapperForScripts',
+ 'ch.qos.logback.core.joran.conditional.ThenAction',
+ 'ch.qos.logback.core.joran.conditional.PropertyEvalScriptBuilder',
+ 'ch.qos.logback.core.joran.conditional.ElseAction',
+ 'ch.qos.logback.core.joran.conditional.IfAction',
+ 'ch.qos.logback.core.joran.util.beans.BeanDescriptionCache',
+ 'ch.qos.logback.core.joran.util.beans.BeanDescriptionFactory',
+ 'ch.qos.logback.core.joran.util.beans.BeanDescription',
+ 'ch.qos.logback.core.joran.util.beans.BeanUtil',
+ 'ch.qos.logback.core.joran.util.PropertySetter',
+ 'ch.qos.logback.core.joran.util.ConfigurationWatchListUtil',
+ 'ch.qos.logback.core.joran.util.StringToObjectConverter',
+ 'ch.qos.logback.core.joran.GenericConfigurator',
+ 'ch.qos.logback.core.joran.action.ImplicitAction',
+ 'ch.qos.logback.core.joran.action.IncludeAction',
+ 'ch.qos.logback.core.joran.action.NOPAction',
+ 'ch.qos.logback.core.joran.action.IADataForBasicProperty',
+ 'ch.qos.logback.core.joran.action.TimestampAction',
+ 'ch.qos.logback.core.joran.action.AbstractEventEvaluatorAction',
+ 'ch.qos.logback.core.joran.action.ParamAction',
+ 'ch.qos.logback.core.joran.action.AppenderAction',
+ 'ch.qos.logback.core.joran.action.DefinePropertyAction',
+ 'ch.qos.logback.core.joran.action.StatusListenerAction',
+ 'ch.qos.logback.core.joran.action.ContextPropertyAction',
+ 'ch.qos.logback.core.joran.action.NestedComplexPropertyIA',
+ 'ch.qos.logback.core.joran.action.NestedBasicPropertyIA',
+ 'ch.qos.logback.core.joran.action.Action',
+ 'ch.qos.logback.core.joran.action.AppenderRefAction',
+ 'ch.qos.logback.core.joran.action.ActionUtil',
+ 'ch.qos.logback.core.joran.action.ShutdownHookAction',
+ 'ch.qos.logback.core.joran.action.IADataForComplexProperty',
+ 'ch.qos.logback.core.joran.action.ConversionRuleAction',
+ 'ch.qos.logback.core.joran.action.ActionConst',
+ 'ch.qos.logback.core.joran.action.PropertyAction',
+ 'ch.qos.logback.core.joran.action.NewRuleAction',
+ 'ch.qos.logback.core.joran.node.ComponentNode',
+ 'ch.qos.logback.core.joran.event.EndEvent',
+ 'ch.qos.logback.core.joran.event.SaxEventRecorder',
+ 'ch.qos.logback.core.joran.event.SaxEvent',
+ 'ch.qos.logback.core.joran.event.BodyEvent',
+ 'ch.qos.logback.core.joran.event.StartEvent',
+ 'ch.qos.logback.core.joran.event.InPlayListener',
+ 'ch.qos.logback.core.joran.event.stax.EndEvent',
+ 'ch.qos.logback.core.joran.event.stax.StaxEventRecorder',
+ 'ch.qos.logback.core.joran.event.stax.BodyEvent',
+ 'ch.qos.logback.core.joran.event.stax.StartEvent',
+ 'ch.qos.logback.core.joran.event.stax.StaxEvent',
+ 'ch.qos.logback.core.LogbackException',
+ 'ch.qos.logback.core.PropertyDefinerBase',
+ 'ch.qos.logback.core.helpers.CyclicBuffer',
+ 'ch.qos.logback.core.helpers.ThrowableToStringArray',
+ 'ch.qos.logback.core.helpers.Transform',
+ 'ch.qos.logback.core.helpers.NOPAppender',
+ 'ch.qos.logback.core.net.LoginAuthenticator',
+ 'ch.qos.logback.core.net.DefaultSocketConnector',
+ 'ch.qos.logback.core.net.ssl.KeyStoreFactoryBean',
+ 'ch.qos.logback.core.net.ssl.SSLParametersConfiguration',
+ 'ch.qos.logback.core.net.ssl.SSLComponent',
+ 'ch.qos.logback.core.net.ssl.SSLNestedComponentRegistryRules',
+ 'ch.qos.logback.core.net.ssl.SSLConfigurableSocket',
+ 'ch.qos.logback.core.net.ssl.SSLConfigurableServerSocket',
+ 'ch.qos.logback.core.net.ssl.SSLConfiguration',
+ 'ch.qos.logback.core.net.ssl.ConfigurableSSLSocketFactory',
+ 'ch.qos.logback.core.net.ssl.ConfigurableSSLServerSocketFactory',
+ 'ch.qos.logback.core.net.ssl.SecureRandomFactoryBean',
+ 'ch.qos.logback.core.net.ssl.SSLContextFactoryBean',
+ 'ch.qos.logback.core.net.ssl.SSL',
+ 'ch.qos.logback.core.net.ssl.SSLConfigurable',
+ 'ch.qos.logback.core.net.ssl.TrustManagerFactoryFactoryBean',
+ 'ch.qos.logback.core.net.ssl.KeyManagerFactoryFactoryBean',
+ 'ch.qos.logback.core.net.SMTPAppenderBase',
+ 'ch.qos.logback.core.net.SyslogAppenderBase',
+ 'ch.qos.logback.core.net.SocketConnector',
+ 'ch.qos.logback.core.net.SyslogOutputStream',
+ 'ch.qos.logback.core.net.QueueFactory',
+ 'ch.qos.logback.core.net.HardenedObjectInputStream',
+ 'ch.qos.logback.core.net.AbstractSocketAppender',
+ 'ch.qos.logback.core.net.AbstractSSLSocketAppender',
+ 'ch.qos.logback.core.net.ObjectWriterFactory',
+ 'ch.qos.logback.core.net.ObjectWriter',
+ 'ch.qos.logback.core.net.AutoFlushingObjectWriter',
+ 'ch.qos.logback.core.net.SyslogConstants',
+ 'ch.qos.logback.core.net.server.ServerRunner',
+ 'ch.qos.logback.core.net.server.Client',
+ 'ch.qos.logback.core.net.server.ServerListener',
+ 'ch.qos.logback.core.net.server.RemoteReceiverStreamClient',
+ 'ch.qos.logback.core.net.server.AbstractServerSocketAppender',
+ 'ch.qos.logback.core.net.server.ClientVisitor',
+ 'ch.qos.logback.core.net.server.RemoteReceiverClient',
+ 'ch.qos.logback.core.net.server.RemoteReceiverServerRunner',
+ 'ch.qos.logback.core.net.server.SSLServerSocketAppenderBase',
+ 'ch.qos.logback.core.net.server.ConcurrentServerRunner',
+ 'ch.qos.logback.core.net.server.ServerSocketListener',
+ 'ch.qos.logback.core.net.server.RemoteReceiverServerListener',
+ 'ch.qos.logback.core.UnsynchronizedAppenderBase',
+ 'ch.qos.logback.core.AsyncAppenderBase',
+ 'ch.qos.logback.core.util.CloseUtil',
+ 'ch.qos.logback.core.util.DatePatternToRegexUtil',
+ 'ch.qos.logback.core.util.StatusListenerConfigHelper',
+ 'ch.qos.logback.core.util.SystemInfo',
+ 'ch.qos.logback.core.util.DefaultInvocationGate',
+ 'ch.qos.logback.core.util.CachingDateFormatter',
+ 'ch.qos.logback.core.util.InterruptUtil',
+ 'ch.qos.logback.core.util.LocationUtil',
+ 'ch.qos.logback.core.util.TimeUtil',
+ 'ch.qos.logback.core.util.COWArrayList',
+ 'ch.qos.logback.core.util.Loader',
+ 'ch.qos.logback.core.util.CharSequenceState',
+ 'ch.qos.logback.core.util.StatusPrinter',
+ 'ch.qos.logback.core.util.Duration',
+ 'ch.qos.logback.core.util.ContentTypeUtil',
+ 'ch.qos.logback.core.util.FileUtil',
+ 'ch.qos.logback.core.util.DynamicClassLoadingException',
+ 'ch.qos.logback.core.util.InvocationGate',
+ 'ch.qos.logback.core.util.OptionHelper',
+ 'ch.qos.logback.core.util.IncompatibleClassException',
+ 'ch.qos.logback.core.util.ExecutorServiceUtil',
+ 'ch.qos.logback.core.util.StringCollectionUtil',
+ 'ch.qos.logback.core.util.CharSequenceToRegexMapper',
+ 'ch.qos.logback.core.util.FixedDelay',
+ 'ch.qos.logback.core.util.FileSize',
+ 'ch.qos.logback.core.util.DelayStrategy',
+ 'ch.qos.logback.core.util.EnvUtil',
+ 'ch.qos.logback.core.util.ContextUtil',
+ 'ch.qos.logback.core.util.AggregationType',
+ 'ch.qos.logback.core.util.PropertySetterException',
+ 'ch.qos.logback.core.LifeCycleManager',
+ 'ch.qos.logback.core.LayoutBase',
+ 'ch.qos.logback.core.encoder.NonClosableInputStream',
+ 'ch.qos.logback.core.encoder.Encoder',
+ 'ch.qos.logback.core.encoder.ByteArrayUtil',
+ 'ch.qos.logback.core.encoder.EncoderBase',
+ 'ch.qos.logback.core.encoder.EchoEncoder',
+ 'ch.qos.logback.core.encoder.LayoutWrappingEncoder',
+ 'ch.qos.logback.core.recovery.RecoveryCoordinator',
+ 'ch.qos.logback.core.recovery.ResilientOutputStreamBase',
+ 'ch.qos.logback.core.recovery.ResilientSyslogOutputStream',
+ 'ch.qos.logback.core.recovery.ResilientFileOutputStream',
+ 'ch.qos.logback.core.AppenderBase',
+ 'ch.qos.logback.core.subst.Node',
+ 'ch.qos.logback.core.subst.Parser',
+ 'ch.qos.logback.core.subst.Token',
+ 'ch.qos.logback.core.subst.NodeToStringTransformer',
+ 'ch.qos.logback.core.subst.Tokenizer',
+ 'ch.qos.logback.core.FileAppender',
+ 'ch.qos.logback.core.sift.AppenderFactory',
+ 'ch.qos.logback.core.sift.SiftingAppenderBase',
+ 'ch.qos.logback.core.sift.SiftingJoranConfiguratorBase',
+ 'ch.qos.logback.core.sift.AbstractDiscriminator',
+ 'ch.qos.logback.core.sift.Discriminator',
+ 'ch.qos.logback.core.sift.AbstractAppenderFactoryUsingJoran',
+ 'ch.qos.logback.core.sift.AppenderTracker',
+ 'ch.qos.logback.core.sift.DefaultDiscriminator',
+ 'ch.qos.logback.core.html.CssBuilder',
+ 'ch.qos.logback.core.html.NOPThrowableRenderer',
+ 'ch.qos.logback.core.html.HTMLLayoutBase',
+ 'ch.qos.logback.core.html.IThrowableRenderer',
+ 'ch.qos.logback.core.rolling.TriggeringPolicyBase',
+ 'ch.qos.logback.core.rolling.helper.Compressor',
+ 'ch.qos.logback.core.rolling.helper.PeriodicityType',
+ 'ch.qos.logback.core.rolling.helper.TokenConverter',
+ 'ch.qos.logback.core.rolling.helper.IntegerTokenConverter',
+ 'ch.qos.logback.core.rolling.helper.CompressionMode',
+ 'ch.qos.logback.core.rolling.helper.ArchiveRemover',
+ 'ch.qos.logback.core.rolling.helper.FileFilterUtil',
+ 'ch.qos.logback.core.rolling.helper.RenameUtil',
+ 'ch.qos.logback.core.rolling.helper.DateTokenConverter',
+ 'ch.qos.logback.core.rolling.helper.FileNamePattern',
+ 'ch.qos.logback.core.rolling.helper.RollingCalendar',
+ 'ch.qos.logback.core.rolling.helper.FileStoreUtil',
+ 'ch.qos.logback.core.rolling.helper.SizeAndTimeBasedArchiveRemover',
+ 'ch.qos.logback.core.rolling.helper.TimeBasedArchiveRemover',
+ 'ch.qos.logback.core.rolling.helper.MonoTypedConverter',
+ 'ch.qos.logback.core.rolling.RollingPolicyBase',
+ 'ch.qos.logback.core.rolling.RollingFileAppender',
+ 'ch.qos.logback.core.rolling.FixedWindowRollingPolicy',
+ 'ch.qos.logback.core.rolling.TimeBasedFileNamingAndTriggeringPolicyBase',
+ 'ch.qos.logback.core.rolling.TimeBasedFileNamingAndTriggeringPolicy',
+ 'ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy',
+ 'ch.qos.logback.core.rolling.RollingPolicy',
+ 'ch.qos.logback.core.rolling.TimeBasedRollingPolicy',
+ 'ch.qos.logback.core.rolling.DefaultTimeBasedFileNamingAndTriggeringPolicy',
+ 'ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy',
+ 'ch.qos.logback.core.rolling.RolloverFailure',
+ 'ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP',
+ 'ch.qos.logback.core.rolling.TriggeringPolicy',
+ 'ch.qos.logback.core.pattern.ReplacingCompositeConverter',
+ 'ch.qos.logback.core.pattern.ConverterUtil',
+ 'ch.qos.logback.core.pattern.parser.Compiler',
+ 'ch.qos.logback.core.pattern.parser.Node',
+ 'ch.qos.logback.core.pattern.parser.Parser',
+ 'ch.qos.logback.core.pattern.parser.Token',
+ 'ch.qos.logback.core.pattern.parser.OptionTokenizer',
+ 'ch.qos.logback.core.pattern.parser.TokenStream',
+ 'ch.qos.logback.core.pattern.parser.CompositeNode',
+ 'ch.qos.logback.core.pattern.parser.FormattingNode',
+ 'ch.qos.logback.core.pattern.parser.SimpleKeywordNode',
+ 'ch.qos.logback.core.pattern.Converter',
+ 'ch.qos.logback.core.pattern.PatternLayoutEncoderBase',
+ 'ch.qos.logback.core.pattern.LiteralConverter',
+ 'ch.qos.logback.core.pattern.PostCompileProcessor',
+ 'ch.qos.logback.core.pattern.util.RegularEscapeUtil',
+ 'ch.qos.logback.core.pattern.util.AsIsEscapeUtil',
+ 'ch.qos.logback.core.pattern.util.AlmostAsIsEscapeUtil',
+ 'ch.qos.logback.core.pattern.util.IEscapeUtil',
+ 'ch.qos.logback.core.pattern.util.RestrictedEscapeUtil',
+ 'ch.qos.logback.core.pattern.SpacePadder',
+ 'ch.qos.logback.core.pattern.CompositeConverter',
+ 'ch.qos.logback.core.pattern.PatternLayoutBase',
+ 'ch.qos.logback.core.pattern.DynamicConverter',
+ 'ch.qos.logback.core.pattern.color.YellowCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.ANSIConstants',
+ 'ch.qos.logback.core.pattern.color.BoldYellowCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.BoldBlueCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.BoldWhiteCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.CyanCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.MagentaCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.BlueCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.BlackCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.ForegroundCompositeConverterBase',
+ 'ch.qos.logback.core.pattern.color.GrayCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.BoldMagentaCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.BoldCyanCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.RedCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.BoldGreenCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.BoldRedCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.GreenCompositeConverter',
+ 'ch.qos.logback.core.pattern.color.WhiteCompositeConverter',
+ 'ch.qos.logback.core.pattern.FormattingConverter',
+ 'ch.qos.logback.core.pattern.IdentityCompositeConverter',
+ 'ch.qos.logback.core.pattern.FormatInfo',
+ 'ch.qos.logback.core.OutputStreamAppender',
+ 'ch.qos.logback.core.boolex.JaninoEventEvaluatorBase',
+ 'ch.qos.logback.core.boolex.Matcher',
+ 'ch.qos.logback.core.boolex.EventEvaluatorBase',
+ 'ch.qos.logback.core.boolex.EvaluationException',
+ 'ch.qos.logback.core.boolex.EventEvaluator',
+ 'ch.qos.logback.core.read.CyclicBufferAppender',
+ 'ch.qos.logback.core.read.ListAppender',
+ 'ch.qos.logback.core.Context',
+ 'ch.qos.logback.core.ContextBase',
+ 'ch.qos.logback.core.status.StatusListenerAsList',
+ 'ch.qos.logback.core.status.StatusBase',
+ 'ch.qos.logback.core.status.NopStatusListener',
+ 'ch.qos.logback.core.status.StatusUtil',
+ 'ch.qos.logback.core.status.OnPrintStreamStatusListenerBase',
+ 'ch.qos.logback.core.status.StatusManager',
+ 'ch.qos.logback.core.status.ViewStatusMessagesServletBase',
+ 'ch.qos.logback.core.status.ErrorStatus',
+ 'ch.qos.logback.core.status.Status',
+ 'ch.qos.logback.core.status.StatusListener',
+ 'ch.qos.logback.core.status.InfoStatus',
+ 'ch.qos.logback.core.status.OnConsoleStatusListener',
+ 'ch.qos.logback.core.status.WarnStatus',
+ 'ch.qos.logback.core.status.OnErrorConsoleStatusListener',
+ 'ch.qos.logback.core.filter.EvaluatorFilter',
+ 'ch.qos.logback.core.filter.Filter',
+ 'ch.qos.logback.core.filter.AbstractMatcherFilter',
+ 'ch.qos.logback.core.Layout',
+ 'ch.qos.logback.classic.ViewStatusMessagesServlet',
+ 'ch.qos.logback.classic.ClassicConstants',
+ 'ch.qos.logback.classic.layout.TTLLLayout',
+ 'ch.qos.logback.classic.helpers.MDCInsertingServletFilter',
+ 'ch.qos.logback.classic.Level',
+ 'ch.qos.logback.classic.Level.off',
+ 'ch.qos.logback.classic.Level.error',
+ 'ch.qos.logback.classic.Level.warn',
+ 'ch.qos.logback.classic.Level.info',
+ 'ch.qos.logback.classic.Level.debug',
+ 'ch.qos.logback.classic.Level.trace',
+ 'ch.qos.logback.classic.Level.all,',
+ 'ch.qos.logback.classic.net.SSLSocketReceiver',
+ 'ch.qos.logback.classic.net.ReceiverBase',
+ 'ch.qos.logback.classic.net.SimpleSocketServer',
+ 'ch.qos.logback.classic.net.SimpleSSLSocketServer',
+ 'ch.qos.logback.classic.net.SocketNode',
+ 'ch.qos.logback.classic.net.SMTPAppender',
+ 'ch.qos.logback.classic.net.SocketReceiver',
+ 'ch.qos.logback.classic.net.SocketAcceptor',
+ 'ch.qos.logback.classic.net.SSLSocketAppender',
+ 'ch.qos.logback.classic.net.LoggingEventPreSerializationTransformer',
+ 'ch.qos.logback.classic.net.server.RemoteAppenderStreamClient',
+ 'ch.qos.logback.classic.net.server.RemoteAppenderServerListener',
+ 'ch.qos.logback.classic.net.server.SSLServerSocketAppender',
+ 'ch.qos.logback.classic.net.server.RemoteAppenderClient',
+ 'ch.qos.logback.classic.net.server.HardenedLoggingEventInputStream',
+ 'ch.qos.logback.classic.net.server.ServerSocketAppender',
+ 'ch.qos.logback.classic.net.server.SSLServerSocketReceiver',
+ 'ch.qos.logback.classic.net.server.RemoteAppenderServerRunner',
+ 'ch.qos.logback.classic.net.server.ServerSocketReceiver',
+ 'ch.qos.logback.classic.net.SocketAppender',
+ 'ch.qos.logback.classic.net.SyslogAppender',
+ 'ch.qos.logback.classic.PatternLayout',
+ 'ch.qos.logback.classic.util.ContextSelectorStaticBinder',
+ 'ch.qos.logback.classic.util.StatusViaSLF4JLoggerFactory',
+ 'ch.qos.logback.classic.util.JNDIUtil',
+ 'ch.qos.logback.classic.util.LevelToSyslogSeverity',
+ 'ch.qos.logback.classic.util.LoggerNameUtil',
+ 'ch.qos.logback.classic.util.LogbackMDCAdapter',
+ 'ch.qos.logback.classic.util.CopyOnInheritThreadLocal',
+ 'ch.qos.logback.classic.util.ContextInitializer',
+ 'ch.qos.logback.classic.util.EnvUtil',
+ 'ch.qos.logback.classic.util.DefaultNestedComponentRules',
+ 'ch.qos.logback.classic.AsyncAppender',
+ 'ch.qos.logback.classic.jul.JULHelper',
+ 'ch.qos.logback.classic.jul.LevelChangePropagator',
+ 'ch.qos.logback.classic.encoder.PatternLayoutEncoder',
+ 'ch.qos.logback.classic.db.names.DBNameResolver',
+ 'ch.qos.logback.classic.db.names.ColumnName',
+ 'ch.qos.logback.classic.db.names.TableName',
+ 'ch.qos.logback.classic.db.names.DefaultDBNameResolver',
+ 'ch.qos.logback.classic.db.names.SimpleDBNameResolver',
+ 'ch.qos.logback.classic.log4j.XMLLayout',
+ 'ch.qos.logback.classic.LoggerContext',
+ 'ch.qos.logback.classic.turbo.TurboFilter',
+ 'ch.qos.logback.classic.turbo.MDCFilter',
+ 'ch.qos.logback.classic.turbo.ReconfigureOnChangeFilter',
+ 'ch.qos.logback.classic.turbo.DuplicateMessageFilter',
+ 'ch.qos.logback.classic.turbo.MarkerFilter',
+ 'ch.qos.logback.classic.turbo.MDCValueLevelPair',
+ 'ch.qos.logback.classic.turbo.DynamicThresholdFilter',
+ 'ch.qos.logback.classic.turbo.MatchingFilter',
+ 'ch.qos.logback.classic.turbo.LRUMessageCache',
+ 'ch.qos.logback.classic.selector.servlet.LoggerContextFilter',
+ 'ch.qos.logback.classic.selector.servlet.ContextDetachingSCL',
+ 'ch.qos.logback.classic.selector.ContextJNDISelector',
+ 'ch.qos.logback.classic.selector.DefaultContextSelector',
+ 'ch.qos.logback.classic.selector.ContextSelector',
+ 'ch.qos.logback.classic.sift.MDCBasedDiscriminator',
+ 'ch.qos.logback.classic.sift.SiftingJoranConfigurator',
+ 'ch.qos.logback.classic.sift.JNDIBasedContextDiscriminator',
+ 'ch.qos.logback.classic.sift.AppenderFactoryUsingJoran',
+ 'ch.qos.logback.classic.sift.ContextBasedDiscriminator',
+ 'ch.qos.logback.classic.sift.SiftingAppender',
+ 'ch.qos.logback.classic.sift.SiftAction',
+ 'ch.qos.logback.classic.html.UrlCssBuilder',
+ 'ch.qos.logback.classic.html.HTMLLayout',
+ 'ch.qos.logback.classic.html.DefaultCssBuilder',
+ 'ch.qos.logback.classic.html.DefaultThrowableRenderer',
+ 'ch.qos.logback.classic.Logger',
+ 'ch.qos.logback.classic.pattern.ThrowableHandlingConverter',
+ 'ch.qos.logback.classic.pattern.ContextNameConverter',
+ 'ch.qos.logback.classic.pattern.LocalSequenceNumberConverter',
+ 'ch.qos.logback.classic.pattern.ClassOfCallerConverter',
+ 'ch.qos.logback.classic.pattern.PrefixCompositeConverter',
+ 'ch.qos.logback.classic.pattern.LineOfCallerConverter',
+ 'ch.qos.logback.classic.pattern.EnsureExceptionHandling',
+ 'ch.qos.logback.classic.pattern.TargetLengthBasedClassNameAbbreviator',
+ 'ch.qos.logback.classic.pattern.FileOfCallerConverter',
+ 'ch.qos.logback.classic.pattern.LevelConverter',
+ 'ch.qos.logback.classic.pattern.ExtendedThrowableProxyConverter',
+ 'ch.qos.logback.classic.pattern.NamedConverter',
+ 'ch.qos.logback.classic.pattern.ClassicConverter',
+ 'ch.qos.logback.classic.pattern.NopThrowableInformationConverter',
+ 'ch.qos.logback.classic.pattern.RootCauseFirstThrowableProxyConverter',
+ 'ch.qos.logback.classic.pattern.MethodOfCallerConverter',
+ 'ch.qos.logback.classic.pattern.CallerDataConverter',
+ 'ch.qos.logback.classic.pattern.ClassNameOnlyAbbreviator',
+ 'ch.qos.logback.classic.pattern.MarkerConverter',
+ 'ch.qos.logback.classic.pattern.RelativeTimeConverter',
+ 'ch.qos.logback.classic.pattern.DateConverter',
+ 'ch.qos.logback.classic.pattern.PropertyConverter',
+ 'ch.qos.logback.classic.pattern.ThreadConverter',
+ 'ch.qos.logback.classic.pattern.LineSeparatorConverter',
+ 'ch.qos.logback.classic.pattern.MDCConverter',
+ 'ch.qos.logback.classic.pattern.color.HighlightingCompositeConverter',
+ 'ch.qos.logback.classic.pattern.ThrowableProxyConverter',
+ 'ch.qos.logback.classic.pattern.Abbreviator',
+ 'ch.qos.logback.classic.pattern.Util',
+ 'ch.qos.logback.classic.pattern.LoggerConverter',
+ 'ch.qos.logback.classic.pattern.SyslogStartConverter',
+ 'ch.qos.logback.classic.pattern.MessageConverter',
+ 'ch.qos.logback.classic.gaffer.GafferUtil',
+ 'ch.qos.logback.classic.boolex.OnMarkerEvaluator',
+ 'ch.qos.logback.classic.boolex.JaninoEventEvaluator',
+ 'ch.qos.logback.classic.boolex.OnErrorEvaluator',
+ 'ch.qos.logback.classic.boolex.GEventEvaluator',
+ 'ch.qos.logback.classic.boolex.IEvaluator',
+ 'ch.qos.logback.classic.filter.ThresholdFilter',
+ 'ch.qos.logback.classic.filter.LevelFilter',
+ 'java.lang.System',
+ 'java.lang.System.getenv',
+ 'java.lang.System.getProperty',
+ 'java.lang.System.getenv',
+ 'java.util.Map.getOrDefault',
+ 'com.kotlindiscord.kord.extensions.utils._EnvironmentKt.envOrNull',
+]
diff --git a/plugins/test-plugin-1/build.gradle.kts b/plugins/test-plugin-1/build.gradle.kts
new file mode 100644
index 0000000000..4421fa26f2
--- /dev/null
+++ b/plugins/test-plugin-1/build.gradle.kts
@@ -0,0 +1,25 @@
+buildscript {
+ repositories {
+ maven {
+ name = "Sonatype Snapshots"
+ url = uri("https://oss.sonatype.org/content/repositories/snapshots")
+ }
+ }
+}
+
+plugins {
+ `kordex-module`
+ `dokka-module`
+}
+
+group = "com.kotlindiscord.kord.extensions"
+
+dependencies {
+ detektPlugins(libs.detekt)
+ detektPlugins(libs.detekt.libraries)
+
+ implementation(libs.bundles.logging)
+ implementation(libs.kotlin.stdlib)
+
+ implementation(project(":plugins:test-plugin-core"))
+}
diff --git a/plugins/test-plugin-1/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/test/one/TestPluginOne.kt b/plugins/test-plugin-1/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/test/one/TestPluginOne.kt
new file mode 100644
index 0000000000..b95f660dad
--- /dev/null
+++ b/plugins/test-plugin-1/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/test/one/TestPluginOne.kt
@@ -0,0 +1,16 @@
+package com.kotlindiscord.kord.extensions.plugins.test.one
+
+import com.kotlindiscord.kord.extensions.plugins.test.core.TestPlugin
+import io.github.oshai.kotlinlogging.KotlinLogging
+
+public class TestPluginOne : TestPlugin() {
+ private val logger = KotlinLogging.logger { }
+
+ override fun load() {
+ logger.info { "Plugin 1 loaded" }
+ }
+
+ override fun unload() {
+ logger.info { "Plugin 1 unloaded" }
+ }
+}
diff --git a/plugins/test-plugin-1/src/main/resources/kordex.plugin.json b/plugins/test-plugin-1/src/main/resources/kordex.plugin.json
new file mode 100644
index 0000000000..7e16871274
--- /dev/null
+++ b/plugins/test-plugin-1/src/main/resources/kordex.plugin.json
@@ -0,0 +1,13 @@
+{
+ "class": "com.kotlindiscord.kord.extensions.plugins.test.one.TestPluginOne",
+ "id": "test-one",
+ "description": "The first test plugin",
+ "license": "MPL-2.0",
+ "name": "Test plugin one",
+ "version": "1.0.0",
+ "constraints": {
+ "needs": {
+ "test-two": ">= 1.0.0"
+ }
+ }
+}
diff --git a/plugins/test-plugin-2/build.gradle.kts b/plugins/test-plugin-2/build.gradle.kts
new file mode 100644
index 0000000000..4421fa26f2
--- /dev/null
+++ b/plugins/test-plugin-2/build.gradle.kts
@@ -0,0 +1,25 @@
+buildscript {
+ repositories {
+ maven {
+ name = "Sonatype Snapshots"
+ url = uri("https://oss.sonatype.org/content/repositories/snapshots")
+ }
+ }
+}
+
+plugins {
+ `kordex-module`
+ `dokka-module`
+}
+
+group = "com.kotlindiscord.kord.extensions"
+
+dependencies {
+ detektPlugins(libs.detekt)
+ detektPlugins(libs.detekt.libraries)
+
+ implementation(libs.bundles.logging)
+ implementation(libs.kotlin.stdlib)
+
+ implementation(project(":plugins:test-plugin-core"))
+}
diff --git a/plugins/test-plugin-2/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/test/two/TestPluginTwo.kt b/plugins/test-plugin-2/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/test/two/TestPluginTwo.kt
new file mode 100644
index 0000000000..a7a1002a67
--- /dev/null
+++ b/plugins/test-plugin-2/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/test/two/TestPluginTwo.kt
@@ -0,0 +1,16 @@
+package com.kotlindiscord.kord.extensions.plugins.test.two
+
+import com.kotlindiscord.kord.extensions.plugins.test.core.TestPlugin
+import io.github.oshai.kotlinlogging.KotlinLogging
+
+public class TestPluginTwo : TestPlugin() {
+ private val logger = KotlinLogging.logger { }
+
+ override fun load() {
+ logger.info { "Plugin 2 loaded" }
+ }
+
+ override fun unload() {
+ logger.info { "Plugin 2 unloaded" }
+ }
+}
diff --git a/plugins/test-plugin-2/src/main/resources/kordex.plugin.json b/plugins/test-plugin-2/src/main/resources/kordex.plugin.json
new file mode 100644
index 0000000000..7002c3bd58
--- /dev/null
+++ b/plugins/test-plugin-2/src/main/resources/kordex.plugin.json
@@ -0,0 +1,8 @@
+{
+ "class": "com.kotlindiscord.kord.extensions.plugins.test.two.TestPluginTwo",
+ "id": "test-two",
+ "description": "The second test plugin",
+ "license": "MPL-2.0",
+ "name": "Test plugin two",
+ "version": "1.0.0"
+}
diff --git a/plugins/test-plugin-core/build.gradle.kts b/plugins/test-plugin-core/build.gradle.kts
new file mode 100644
index 0000000000..4e4d024927
--- /dev/null
+++ b/plugins/test-plugin-core/build.gradle.kts
@@ -0,0 +1,25 @@
+buildscript {
+ repositories {
+ maven {
+ name = "Sonatype Snapshots"
+ url = uri("https://oss.sonatype.org/content/repositories/snapshots")
+ }
+ }
+}
+
+plugins {
+ `kordex-module`
+ `dokka-module`
+}
+
+group = "com.kotlindiscord.kord.extensions"
+
+dependencies {
+ detektPlugins(libs.detekt)
+ detektPlugins(libs.detekt.libraries)
+
+ implementation(libs.bundles.logging)
+ implementation(libs.kotlin.stdlib)
+
+ api(project(":plugins"))
+}
diff --git a/plugins/test-plugin-core/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/test/core/TestPlugin.kt b/plugins/test-plugin-core/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/test/core/TestPlugin.kt
new file mode 100644
index 0000000000..a5e381798c
--- /dev/null
+++ b/plugins/test-plugin-core/src/main/kotlin/com/kotlindiscord/kord/extensions/plugins/test/core/TestPlugin.kt
@@ -0,0 +1,20 @@
+package com.kotlindiscord.kord.extensions.plugins.test.core
+
+import com.kotlindiscord.kord.extensions.plugins.Plugin
+import io.github.oshai.kotlinlogging.KotlinLogging
+
+public abstract class TestPlugin : Plugin() {
+ private val logger = KotlinLogging.logger {}
+
+ override fun internalLoad() {
+ super.internalLoad()
+
+ logger.info { "Plugin loaded: ${manifest.name}" }
+ }
+
+ override fun internalUnload() {
+ super.internalUnload()
+
+ logger.info { "Plugin unloaded: ${manifest.name}" }
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index e07745fc35..0840eb0969 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -16,5 +16,11 @@ include("modules:java-time")
include("modules:time4j")
include("modules:unsafe")
+include("plugins")
+include("plugins:plugin-load-test")
+include("plugins:test-plugin-1")
+include("plugins:test-plugin-2")
+include("plugins:test-plugin-core")
+
include("test-bot")
include("token-parser")