feat: add plugin loader

This commit is contained in:
Project_IO 2024-09-19 16:27:42 +09:00
parent 49afa9f2a6
commit 0e8fdee886
18 changed files with 309 additions and 31 deletions

3
.gitignore vendored
View file

@ -10,6 +10,9 @@ build/
.env
!.env.example
deploy.sh
plugins/
### IntelliJ IDEA ###
.idea/
*.iws

View file

@ -1,5 +1,6 @@
plugins {
kotlin("jvm") version "2.0.20"
kotlin("plugin.serialization") version "2.0.20"
}
group = property("group")!!
@ -11,6 +12,7 @@ val exposed_version: String by project
allprojects {
apply(plugin = "org.jetbrains.kotlin.jvm")
apply(plugin = "org.jetbrains.kotlin.plugin.serialization")
java {
toolchain {
@ -39,6 +41,8 @@ subprojects {
implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version")
implementation("org.apache.logging.log4j:log4j-slf4j2-impl:$log4j_version")
implementation("io.ktor:ktor-client-okhttp-jvm:2.3.12")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
}

View file

@ -0,0 +1,99 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
`maven-publish`
signing
}
group = rootProject.group
version = rootProject.version
repositories {
mavenCentral()
}
dependencies {
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
}
tasks {
withType<Javadoc> {
options.encoding = "UTF-8"
}
withType<KotlinCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
create<Jar>("sourcesJar") {
archiveClassifier.set("sources")
from(sourceSets["main"].allSource)
}
create<Jar>("javadocJar") {
archiveClassifier.set("javadoc")
dependsOn("dokkaHtml")
from("${projectDir}/build/dokka/html")
}
test {
useJUnitPlatform()
}
}
publishing {
publications {
create<MavenPublication>("${rootProject.name}-api") {
from(components["java"])
artifacts {
tasks["sourcesJar"]
tasks["javadocJar"]
}
repositories {
maven {
name = "ProjectCentral"
val releasesRepoUrl = "https://repo.wh64.net/repository/maven-releases"
val snapshotsRepoUrl = "https://repo.wh64.net/repository/maven-snapshots"
url = uri(if (version.toString().endsWith("SNAPSHOT")) snapshotsRepoUrl else releasesRepoUrl)
credentials.runCatching {
val nexusUsername: String by project
val nexusPassword: String by project
username = nexusUsername
password = nexusPassword
}
}
}
pom {
name.set(rootProject.name)
description.set("Px32 Discord Bot framework api for Kotlin")
licenses {
license {
name.set("MIT License")
}
}
developers {
developer {
id.set("devproje")
name.set("Project_IO")
email.set("me@projecttl.net")
}
}
}
}
}
}
signing {
isRequired = true
sign(publishing.publications["${rootProject.name}-api"])
}

View file

@ -0,0 +1,42 @@
package net.projecttl.p.x32.api
import kotlinx.serialization.json.Json
import net.dv8tion.jda.api.hooks.ListenerAdapter
import net.projecttl.p.x32.api.command.CommandHandler
import net.projecttl.p.x32.api.model.PluginConfig
import org.slf4j.Logger
import org.slf4j.LoggerFactory
abstract class Plugin {
private val globalCommandHandler = CommandHandler()
private val handlerContainer = mutableListOf<ListenerAdapter>(globalCommandHandler)
private val config = this.javaClass.getResourceAsStream("/plugin.json")?.let {
val raw = it.bufferedReader().readText()
val obj = Json.decodeFromString<PluginConfig>(raw)
return@let obj
}
fun getLogger(): Logger {
return LoggerFactory.getLogger(config?.name)
}
fun getHandlers(): List<ListenerAdapter> {
return handlerContainer
}
fun addHandler(listener: ListenerAdapter) {
handlerContainer.add(listener)
}
fun delHandler(listener: ListenerAdapter) {
handlerContainer.remove(listener)
}
fun getCommandContainer(): CommandHandler {
return globalCommandHandler
}
abstract fun onLoad()
abstract fun destroy()
}

View file

@ -1,4 +1,4 @@
package net.projecttl.p.x32.handler
package net.projecttl.p.x32.api.command
import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent

View file

@ -1,4 +1,4 @@
package net.projecttl.p.x32.handler
package net.projecttl.p.x32.api.command
import kotlinx.coroutines.runBlocking
import net.dv8tion.jda.api.JDA
@ -8,7 +8,6 @@ import net.dv8tion.jda.api.events.interaction.command.UserContextInteractionEven
import net.dv8tion.jda.api.hooks.ListenerAdapter
import net.dv8tion.jda.api.interactions.commands.Command
import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.projecttl.p.x32.logger
class CommandHandler(val guildId: Long = 0L) : ListenerAdapter() {
private val commands = mutableListOf<CommandExecutor>()
@ -73,7 +72,7 @@ class CommandHandler(val guildId: Long = 0L) : ListenerAdapter() {
val guild = jda.getGuildById(guildId)
if (guildId != 0L) {
if (guild == null) {
logger.info("'${guildId}' guild is not exists!")
println("'${guildId}' guild is not exists!")
return
}
}
@ -84,10 +83,10 @@ class CommandHandler(val guildId: Long = 0L) : ListenerAdapter() {
if (command is GlobalCommand) {
if (guild == null) {
jda.upsertCommand(data).queue()
logger.info("Register Global Command: /${data.name}")
println("Register Global Command: /${data.name}")
} else {
guild.upsertCommand(data).queue()
logger.info("Register '${guild.id}' Guild's Command: /${data.name}")
println("Register '${guild.id}' Guild's Command: /${data.name}")
}
}
@ -98,13 +97,13 @@ class CommandHandler(val guildId: Long = 0L) : ListenerAdapter() {
Commands.message(data.name)
).queue()
logger.info("Register User Context Command: /${data.name}")
println("Register User Context Command: /${data.name}")
} else {
guild.updateCommands().addCommands(
Commands.context(Command.Type.USER, data.name),
Commands.message(data.name)
).queue()
logger.info("Register '${guild.id}' Guild's User Context Command: /${data.name}")
println("Register '${guild.id}' Guild's User Context Command: /${data.name}")
}
}
@ -115,14 +114,14 @@ class CommandHandler(val guildId: Long = 0L) : ListenerAdapter() {
Commands.message(data.name)
)
logger.info("Register Message Context Command: /${data.name}")
println("Register Message Context Command: /${data.name}")
} else {
guild.updateCommands().addCommands(
Commands.context(Command.Type.MESSAGE, data.name),
Commands.message(data.name)
)
logger.info("Register '${guild.id}' Guild's Message Context Command: /${data.name}")
println("Register '${guild.id}' Guild's Message Context Command: /${data.name}")
}
}
}

View file

@ -0,0 +1,10 @@
package net.projecttl.p.x32.api.model
import kotlinx.serialization.Serializable
@Serializable
data class PluginConfig(
val name: String,
val version: String,
val main: String
)

View file

@ -1,4 +1,4 @@
package net.projecttl.p.x32.util
package net.projecttl.p.x32.api.util
import net.dv8tion.jda.api.EmbedBuilder
import kotlin.random.Random

View file

@ -5,14 +5,16 @@ plugins {
id("com.gradleup.shadow") version "8.3.0"
}
group = "net.projecttl"
version = "0.1.0-SNAPSHOT"
group = rootProject.group
version = rootProject.version
repositories {
mavenCentral()
}
dependencies {
implementation(project(":${rootProject.name}-api"))
implementation(project(":${rootProject.name}-func"))
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
}

View file

@ -1,9 +1,8 @@
package net.projecttl.p.x32
import net.dv8tion.jda.api.JDA
import net.projecttl.p.x32.command.Avatar
import net.projecttl.p.x32.command.Ping
import net.projecttl.p.x32.config.DefaultConfig
import net.projecttl.p.x32.func.loadDefault
import net.projecttl.p.x32.handler.Ready
import net.projecttl.p.x32.kernel.CoreKernel
import org.slf4j.Logger
@ -18,10 +17,9 @@ fun main() {
kernel.addHandler(Ready)
val handler = kernel.getGlobalCommandHandler()
handler.addCommand(Avatar)
handler.addCommand(Ping)
loadDefault(handler)
jda = kernel.build()
}
class Px32
object Px32

View file

@ -3,12 +3,12 @@ package net.projecttl.p.x32.kernel
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.JDABuilder
import net.dv8tion.jda.api.hooks.ListenerAdapter
import net.projecttl.p.x32.handler.CommandHandler
import net.projecttl.p.x32.api.command.CommandHandler
import net.projecttl.p.x32.logger
class CoreKernel(token: String) {
private val builder = JDABuilder.createDefault(token)
private val handlers = mutableListOf<ListenerAdapter>()
private val handler = CommandHandler()
fun getGlobalCommandHandler(): CommandHandler {
@ -28,6 +28,17 @@ class CoreKernel(token: String) {
builder.addEventListeners(it)
}
builder.addEventListeners(handler)
PluginLoader.load()
val plugins = PluginLoader.getPlugins()
plugins.forEach { (c, p) ->
logger.info("Load plugin ${c.name} v${c.version}")
p.onLoad()
p.getHandlers().map { handler ->
builder.addEventListeners(handler)
}
}
val jda = builder.build()
handler.register(jda)
@ -37,6 +48,10 @@ class CoreKernel(token: String) {
}
}
Runtime.getRuntime().addShutdownHook(Thread {
PluginLoader.destroy()
})
return jda
}
}

View file

@ -0,0 +1,64 @@
package net.projecttl.p.x32.kernel
import kotlinx.serialization.json.Json
import net.projecttl.p.x32.api.Plugin
import net.projecttl.p.x32.api.model.PluginConfig
import net.projecttl.p.x32.logger
import java.io.File
import java.net.URLClassLoader
import java.nio.charset.Charset
import java.util.jar.JarFile
object PluginLoader {
private val plugins = mutableMapOf<PluginConfig, Plugin>()
private val parentDir = File("./plugins").apply {
if (!exists()) {
mkdirs()
}
}
fun load() {
parentDir.listFiles()?.forEach { file ->
if (file.name.endsWith(".jar")) {
val jar = JarFile(file)
val cnf = jar.entries().toList().singleOrNull { jarEntry -> jarEntry.name == "plugin.json" }
if (cnf != null) {
val stream = jar.getInputStream(cnf)
val raw = stream.use {
return@use it.readBytes().toString(Charset.forName("UTF-8"))
}
val config = Json.decodeFromString<PluginConfig>(raw)
val cl = URLClassLoader(arrayOf(file.toPath().toUri().toURL()))
val clazz = cl.loadClass(config.main)
val obj = clazz.getDeclaredConstructor().newInstance()
if (obj is Plugin) {
plugins[config] = obj
} else {
logger.error("${config.name} is not valid main class. aborted")
}
} else {
logger.error("${file.name} is not a plugin. aborted")
}
}
}
}
fun getPlugins(): Map<PluginConfig, Plugin> {
return plugins.toMap()
}
fun destroy() {
plugins.forEach { (config, plugin) ->
logger.info("disable ${config.name} plugin...")
try {
plugin.destroy()
} catch (ex: Exception) {
logger.error("failed to destroy ${config.name} plugin")
ex.printStackTrace()
}
}
}
}

View file

@ -0,0 +1,20 @@
plugins {
id("java")
}
group = "net.projecttl"
version = "0.1.0-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
compileOnly(project(":px32-bot-api"))
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
}
tasks.test {
useJUnitPlatform()
}

View file

@ -0,0 +1,10 @@
package net.projecttl.p.x32.func
import net.projecttl.p.x32.api.command.CommandHandler
import net.projecttl.p.x32.func.command.Avatar
import net.projecttl.p.x32.func.command.Ping
fun loadDefault(handler: CommandHandler) = with(handler) {
addCommand(Avatar)
addCommand(Ping)
}

View file

@ -1,11 +1,11 @@
package net.projecttl.p.x32.command
package net.projecttl.p.x32.func.command
import net.dv8tion.jda.api.EmbedBuilder
import net.dv8tion.jda.api.events.interaction.command.UserContextInteractionEvent
import net.dv8tion.jda.api.interactions.commands.build.CommandData
import net.dv8tion.jda.internal.interactions.CommandDataImpl
import net.projecttl.p.x32.handler.UserContext
import net.projecttl.p.x32.util.colour
import net.projecttl.p.x32.api.command.UserContext
import net.projecttl.p.x32.api.util.colour
object Avatar : UserContext {
override val data = CommandData.fromData(CommandDataImpl("avatar", "유저의 프로필 이미지를 가져 옵니다").toData())

View file

@ -1,4 +1,4 @@
package net.projecttl.p.x32.command
package net.projecttl.p.x32.func.command
import net.dv8tion.jda.api.EmbedBuilder
import net.dv8tion.jda.api.JDA
@ -6,9 +6,8 @@ import net.dv8tion.jda.api.entities.MessageEmbed
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.interactions.commands.build.CommandData
import net.dv8tion.jda.internal.interactions.CommandDataImpl
import net.projecttl.p.x32.handler.GlobalCommand
import net.projecttl.p.x32.util.colour
import kotlin.random.Random
import net.projecttl.p.x32.api.command.GlobalCommand
import net.projecttl.p.x32.api.util.colour
object Ping : GlobalCommand {
override val data: CommandData = CommandData.fromData(CommandDataImpl(
@ -17,14 +16,25 @@ object Ping : GlobalCommand {
).toData())
override suspend fun execute(ev: SlashCommandInteractionEvent) {
val embed = measure(ev.jda)
ev.replyEmbeds(embed).queue()
val started = System.currentTimeMillis()
var embed = EmbedBuilder().let {
it.setTitle(":hourglass: Just wait a sec...")
it.colour()
it
}.build()
ev.replyEmbeds(embed).queue {
embed = measure(started, ev.jda)
it.editOriginalEmbeds(embed).queue()
}
}
private fun measure(jda: JDA): MessageEmbed {
private fun measure(started: Long, jda: JDA): MessageEmbed {
val embed = EmbedBuilder()
embed.setTitle(":ping_pong: **Pong!**")
embed.addField(":electric_plug: **API**", "`${jda.gatewayPing}ms`", true)
embed.addField(":robot: **BOT**", "`${System.currentTimeMillis() - started}ms`", true)
embed.colour()
return embed.build()

View file

@ -1,2 +1,4 @@
rootProject.name = "px32-bot"
include("px32-bot-core")
include("px32-bot-api")
include("px32-bot-func")