diff --git a/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/Main.kt b/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/Main.kt index d0518a5..0b1b878 100644 --- a/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/Main.kt +++ b/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/Main.kt @@ -2,24 +2,19 @@ package io.github.null2264.tsukumogami.bot import co.touchlab.kermit.Logger import io.github.null2264.tsukumogami.bot.core.di.appModule -import io.github.null2264.tsukumogami.bot.core.module.DeveloperModule -import io.github.null2264.tsukumogami.bot.core.module.GeneralModule -import io.github.null2264.tsukumogami.bot.core.Bot +import io.github.null2264.tsukumogami.bot.core.module.generalModule +import io.github.null2264.tsukumogami.core.bot import org.koin.core.context.GlobalContext.startKoin suspend fun main() { - startKoin { - modules(appModule) - } - - Bot { + bot { Logger.setTag("Tsukumogami") token = System.getenv("DISCORD_TOKEN") prefixes("src!", "mm!") // mm! for backwards compatibility - extensions(::DeveloperModule, ::GeneralModule) + modules(generalModule) }.start() } diff --git a/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/core/Bot.kt b/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/core/Bot.kt deleted file mode 100644 index 0645b7a..0000000 --- a/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/core/Bot.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.github.null2264.tsukumogami.bot.core - -import io.github.null2264.tsukumogami.core.AbstractBot -import io.github.null2264.tsukumogami.core.BotConfigurator - -class Bot(block: BotConfigurator.() -> Unit = {}) : AbstractBot(block) diff --git a/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/core/module/DeveloperModule.kt b/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/core/module/DeveloperModule.kt index 95f82f9..b8952fd 100644 --- a/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/core/module/DeveloperModule.kt +++ b/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/core/module/DeveloperModule.kt @@ -1,17 +1,13 @@ package io.github.null2264.tsukumogami.bot.core.module -import io.github.null2264.tsukumogami.core.module.annotation.Command -import io.github.null2264.tsukumogami.core.Context -import io.github.null2264.tsukumogami.core.module.BotModule - -class DeveloperModule : BotModule("Developer", "Only for developers") { - - @Command( - name="poweroff", - description="Turn the bot off", - ) - private suspend fun shutdown(ctx: Context) { - ctx.reply("Shutting Down...", mentionsAuthor = true) - bot?.stop() - } -} +//class DeveloperModule : BotModule("Developer", "Only for developers") { +// +// @Command( +// name="poweroff", +// description="Turn the bot off", +// ) +// private suspend fun shutdown(ctx: Context) { +// ctx.reply("Shutting Down...", mentionsAuthor = true) +// bot?.stop() +// } +//} diff --git a/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/core/module/GeneralModule.kt b/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/core/module/GeneralModule.kt index ce39762..3b0176e 100644 --- a/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/core/module/GeneralModule.kt +++ b/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/core/module/GeneralModule.kt @@ -1,23 +1,26 @@ package io.github.null2264.tsukumogami.bot.core.module -import dev.kord.core.entity.effectiveName -import io.github.null2264.tsukumogami.core.module.annotation.Command -import io.github.null2264.tsukumogami.core.Context -import io.github.null2264.tsukumogami.core.module.BotModule +import io.github.null2264.tsukumogami.bot.core.module.arguments.Test2Arguments +import io.github.null2264.tsukumogami.bot.core.module.arguments.TestArguments +import io.github.null2264.tsukumogami.core.module.api.botModules import kotlinx.datetime.Clock -class GeneralModule : BotModule("General", "idk") { - - @Command(description = "Ping the bot!") - private suspend fun ping(ctx: Context) { +val generalModule = botModules("General") { + commands("ping", alias = setOf("p"), description = "Ping the bot!") { ctx -> val startTime = Clock.System.now() ctx.typing() val endTime = Clock.System.now() ctx.send("Pong! ${endTime.toEpochMilliseconds() - startTime.toEpochMilliseconds()}ms") } - @Command("test") - private suspend fun differentName(ctx: Context) { - ctx.send("Hello World! ${ctx.author?.effectiveName}") + groups("group") { + commands("test", arguments = ::Test2Arguments) { ctx, args -> ctx.reply("Hello world ${args.user}") } + groups("group") { + commands("test", alias = setOf("t")) { ctx -> + ctx.reply("Hello world ${ctx.command?.module?.name}") + } + } } + + commands("test", arguments = ::TestArguments) { ctx, args -> ctx.send("Hello world ${args.test}") } } diff --git a/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/core/module/arguments/TestArguments.kt b/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/core/module/arguments/TestArguments.kt new file mode 100644 index 0000000..abc529f --- /dev/null +++ b/bot/src/main/kotlin/io/github/null2264/tsukumogami/bot/core/module/arguments/TestArguments.kt @@ -0,0 +1,15 @@ +package io.github.null2264.tsukumogami.bot.core.module.arguments + +import io.github.null2264.tsukumogami.core.commands.Arguments +import io.github.null2264.tsukumogami.core.commands.ext.string +import io.github.null2264.tsukumogami.core.commands.ext.user + +class TestArguments : Arguments() { + val test by string("Test") { + default("Lmao") + } +} + +class Test2Arguments : Arguments() { + val user by user("User") +} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/AbstractBot.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/AbstractBot.kt deleted file mode 100644 index 604b054..0000000 --- a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/AbstractBot.kt +++ /dev/null @@ -1,108 +0,0 @@ -package io.github.null2264.tsukumogami.core - -import co.touchlab.kermit.Logger -import dev.kord.core.Kord -import dev.kord.core.entity.Message -import dev.kord.core.event.gateway.ReadyEvent -import dev.kord.core.event.message.MessageCreateEvent -import dev.kord.core.on -import dev.kord.gateway.Intent -import dev.kord.gateway.PrivilegedIntent -import io.github.null2264.tsukumogami.core.exceptions.CommandException -import io.github.null2264.tsukumogami.core.exceptions.CommandNotFound -import io.github.null2264.tsukumogami.core.module.BotModule -import io.github.null2264.tsukumogami.core.module.Command -import kotlin.reflect.full.callSuspend -import kotlinx.coroutines.runBlocking - -abstract class AbstractBot(configurator: BotConfigurator.() -> Unit) { - - private val commands: Map - private val extensions: Map - private val prefixes: List - private val client: Kord - - init { - val currentConfig = BotConfigurator() - currentConfig.apply(configurator) - - extensions = mutableMapOf() - currentConfig.extensions.forEach { - val module = it.call() - module.setup() - module.install(this, currentConfig) - extensions[module.name] = module - } - commands = currentConfig.commands - prefixes = currentConfig.prefixes - - client = runBlocking { - Kord(currentConfig.token).apply { - on { onReady() } - - on { onMessage(this.message, this) } - } - } - } - - open suspend fun start() { - client.login { - @OptIn(PrivilegedIntent::class) - intents += Intent.MessageContent - } - } - - open suspend fun stop() { - client.shutdown() - } - - fun getCommand(name: String) = commands[name] - - private fun getContext(message: Message): Context { - val candidate = message.content.hasPrefix() - return Context(this, message, candidate?.first, candidate?.second) - } - - open suspend fun onCommandError(context: Context, error: CommandException) { - context.send(error.message!!) - } - - open suspend fun processCommand(message: Message) { - val ctx = getContext(message) - - try { - ctx.command?.let { - it.callback.callSuspend(extensions[it.extension], ctx) - } ?: throw CommandNotFound() - } catch (e: CommandException) { - onCommandError(ctx, e) - } catch (e: Exception) { - Logger.e(e) { "Something went wrong while trying to process command '${ctx.command?.name}'" } - } - } - - open suspend fun onMessage(message: Message, event: MessageCreateEvent) { - if (message.author?.isBot != false) return - - processCommand(message) - } - - open suspend fun onReady() { - Logger.i { "Online! ${client.getSelf().username}" } - } - - fun String.hasPrefix(): Pair? { - if (this.isBlank()) return null - - var ret: Pair? = null - - prefixes.forEach { - if (this.substring(0, it.length) == it) { - ret = Pair(this.substring(0, it.length), this.substring(it.length).split(" ").first()) - return@forEach - } - } - - return ret - } -} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/Bot.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/Bot.kt new file mode 100644 index 0000000..82c03b8 --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/Bot.kt @@ -0,0 +1,127 @@ +package io.github.null2264.tsukumogami.core + +import co.touchlab.kermit.Logger +import dev.kord.core.Kord +import dev.kord.core.entity.Message +import dev.kord.core.event.gateway.ReadyEvent +import dev.kord.core.event.message.MessageCreateEvent +import dev.kord.core.on +import dev.kord.gateway.Intent +import dev.kord.gateway.PrivilegedIntent +import io.github.null2264.tsukumogami.core.commands.Command +import io.github.null2264.tsukumogami.core.exceptions.CommandException +import io.github.null2264.tsukumogami.core.exceptions.CommandNotFound +import io.github.null2264.tsukumogami.core.module.BotModule +import io.github.null2264.tsukumogami.core.commands.IGroup +import io.github.null2264.tsukumogami.core.ext.parseCommandAndArguments +import io.github.null2264.tsukumogami.core.koin.TsukumogamiKoinComponent +import kotlin.reflect.full.callSuspend +import kotlinx.coroutines.runBlocking +import org.koin.core.component.inject + +open class Bot internal constructor(): IGroup, TsukumogamiKoinComponent { + + val client: Kord by inject() + private val modules = mutableMapOf() + private val _prefixes = mutableListOf() + val prefixes: List get() = _prefixes.toList() + internal lateinit var token: String + override val allCommands: MutableMap = mutableMapOf() + + fun addModule(module: BotModule) { + modules[module.name] = module.install(this) + } + + fun addPrefix(prefix: String) { + _prefixes.add(prefix) + } + + private fun addCommand(name: String, command: Command) { + if (allCommands.containsKey(name)) { + throw IllegalStateException("Duplicate command: '${command.name}'") + } + allCommands[command.name] = command + } + + fun addCommand(command: Command) { + addCommand(command.name, command) + command.alias.forEach { addCommand(it, command) } + } + + private fun removeCommand(name: String) { + if (!allCommands.containsKey(name)) { + throw IllegalStateException("Command not found: '${name}'") + } + allCommands.remove(name) + } + + fun removeCommand(command: Command) { + removeCommand(command.name) + command.alias.forEach { removeCommand(it) } + } + + open suspend fun start() { + client.login { + @OptIn(PrivilegedIntent::class) + intents += Intent.MessageContent + } + } + + open suspend fun stop() { + client.shutdown() + } + + fun getCommand(name: String?) = allCommands[name] + + private fun getContext(message: Message): Context { + val parsed = message.content.parsePrefixAndCommand() + val context = Context(this, message, parsed?.first) + context.command = getCommand(parsed?.second) + if (context.command != null) context.invokedWith = parsed?.second + return context + } + + open suspend fun onCommandError(context: Context, error: CommandException) { + context.send(error.message!!) + } + + open suspend fun processCommand(message: Message) { + val ctx = getContext(message) + + try { + ctx.command?.invoke(ctx) ?: throw CommandNotFound() + } catch (e: CommandException) { + onCommandError(ctx, e) + } catch (e: Exception) { + Logger.e(e) { "Something went wrong while trying to process command '${ctx.command?.name}'" } + } + } + + open suspend fun onMessage(message: Message, event: MessageCreateEvent) { + if (message.author?.isBot != false) return + + processCommand(message) + } + + open suspend fun onReady() { + Logger.i { "Online! ${client.getSelf().username}" } + } + + private fun String.parsePrefixAndCommand(): Pair? { + if (this.isBlank()) return null + + var ret: Pair? = null + + run { + prefixes.forEach { candidate -> + val prefix = this.substring(0, candidate.length) + if (prefix != candidate) { return@forEach } + val command = this.drop(candidate.length).substringBefore(' ') + ret = prefix to command + return@run + } + } + + return ret + } +} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/BotBuilder.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/BotBuilder.kt new file mode 100644 index 0000000..51fc4f7 --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/BotBuilder.kt @@ -0,0 +1,52 @@ +package io.github.null2264.tsukumogami.core + +import dev.kord.core.Kord +import dev.kord.core.event.gateway.ReadyEvent +import dev.kord.core.event.message.MessageCreateEvent +import dev.kord.core.on +import io.github.null2264.tsukumogami.core.ext.loadModule +import io.github.null2264.tsukumogami.core.koin.TsukumogamiKoinContext +import io.github.null2264.tsukumogami.core.module.BotModule +import kotlin.reflect.KFunction +import kotlinx.coroutines.runBlocking +import org.koin.dsl.bind + +class BotBuilder internal constructor( + val bot: Bot +) { + + var token: String = "" + + var kordBuilder: suspend (String) -> Kord = { token -> + Kord(token).apply { + on { bot.onReady() } + on { bot.onMessage(this.message, this) } + } + } + + fun modules(vararg modules: BotModule) { + modules.forEach(bot::addModule) + } + + fun prefixes(vararg prefixes: String) { + prefixes.forEach(bot::addPrefix) + } +} + +fun bot(clazz: KFunction = ::Bot, declaration: BotBuilder.() -> Unit): Bot { + if (TsukumogamiKoinContext.getOrNull() == null) + TsukumogamiKoinContext.startKoin { + } + + val bot = clazz.call() + val holder = BotBuilder(bot) + declaration(holder) + + val kord = runBlocking { + holder.kordBuilder(holder.token) + } + loadModule { single { kord } bind Kord::class } + loadModule { single { bot } bind Bot::class } + + return holder.bot +} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/BotConfigurator.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/BotConfigurator.kt deleted file mode 100644 index de98f03..0000000 --- a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/BotConfigurator.kt +++ /dev/null @@ -1,39 +0,0 @@ -package io.github.null2264.tsukumogami.core - -import io.github.null2264.tsukumogami.core.module.BotModule -import io.github.null2264.tsukumogami.core.module.Command -import kotlin.reflect.KClass -import kotlin.reflect.KFunction -import kotlin.reflect.full.isSubclassOf - -class BotConfigurator internal constructor() { - - internal val commands = mutableMapOf() - internal val extensions = mutableListOf>() - internal val prefixes = mutableListOf() - var token: String = "" - - internal fun isExists(name: String?) = this.commands.containsKey(name) - - fun commands(command: Command, name: String? = null) { - this.commands[if (name.isNullOrEmpty()) command.name else name] = command - } - - fun extensions(vararg extensions: KFunction) { - extensions.forEach { - val kClass = it.returnType.classifier as KClass<*> - if (!kClass.isSubclassOf(BotModule::class)) - return - - this.extensions.add(it) - } - } - - fun prefixes(vararg prefixes: String) { - prefixes(prefixes.toList()) - } - - fun prefixes(prefixes: List) { - this.prefixes.addAll(prefixes) - } -} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/Context.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/Context.kt index baed03d..bb71633 100644 --- a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/Context.kt +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/Context.kt @@ -4,11 +4,33 @@ import dev.kord.core.behavior.channel.createMessage import dev.kord.core.entity.Message import dev.kord.rest.builder.message.AllowedMentionsBuilder import dev.kord.rest.builder.message.allowedMentions +import io.github.null2264.tsukumogami.core.commands.Command -class Context(private val bot: AbstractBot, private val message: Message, val prefix: String?, private val commandName: String?) { +class Context( + val bot: Bot, + val message: Message, + /** + * The prefix that used to invoke the command + */ + val prefix: String?, +) { + /** + * The user that invoked the command + * + * Alias to [Message.author] + */ val author get() = message.author - val command get() = commandName?.let { bot.getCommand(it) } + + /** + * The text that invoked the command + */ + var invokedWith: String? = null + + /** + * The command that currently being invoked + */ + var command: Command? = null suspend fun send(content: String) = message.channel.createMessage(content) @@ -22,4 +44,15 @@ class Context(private val bot: AbstractBot, private val message: Message, val pr } suspend fun typing() = message.channel.type() + + fun parseArguments(): MutableList? = + if (prefix != null && invokedWith != null) { + message.content + .substringAfter("$prefix$invokedWith") + .trim() + .split(' ') + .toMutableList() + } else { + null + } } diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/annotation/TsukumogamiAnnotations.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/annotation/TsukumogamiAnnotations.kt new file mode 100644 index 0000000..c0b0b38 --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/annotation/TsukumogamiAnnotations.kt @@ -0,0 +1,13 @@ +package io.github.null2264.tsukumogami.core.annotation + +@RequiresOptIn(message = "Intended for internal usage. External usage is strongly discouraged.", level = RequiresOptIn.Level.ERROR) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.TYPEALIAS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.FIELD, + AnnotationTarget.CONSTRUCTOR, +) +annotation class TsukumogamiInternalApi diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/Argument.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/Argument.kt new file mode 100644 index 0000000..bc85b9b --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/Argument.kt @@ -0,0 +1,12 @@ +package io.github.null2264.tsukumogami.core.commands + +import io.github.null2264.tsukumogami.core.commands.converters.Converter + +data class Argument( + val name: String, + val converter: Converter, +) { + init { + converter.argumentObj = this + } +} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/Arguments.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/Arguments.kt new file mode 100644 index 0000000..860e23e --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/Arguments.kt @@ -0,0 +1,29 @@ +package io.github.null2264.tsukumogami.core.commands + +import io.github.null2264.tsukumogami.core.Context +import io.github.null2264.tsukumogami.core.commands.converters.Converter + +abstract class Arguments { + + val args = mutableListOf>() + + fun args( + name: String, + converter: Converter + ): Converter { + args.add(Argument(name, converter)) + + return converter + } + + suspend fun parse(context: Context) { + val currentValues = context.parseArguments() + + run { + args.forEach { arg -> + val value = currentValues?.removeFirstOrNull() ?: return@run + arg.converter.parse(context, value) + } + } + } +} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/Command.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/Command.kt new file mode 100644 index 0000000..d0e66dd --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/Command.kt @@ -0,0 +1,36 @@ +package io.github.null2264.tsukumogami.core.commands + +import io.github.null2264.tsukumogami.core.Context +import io.github.null2264.tsukumogami.core.module.BotModule +import kotlin.reflect.KFunction + +open class Command( + val name: String, + val alias: Set, + val description: String, + private val arguments: KFunction, + private val handler: suspend (Context, Arguments) -> Unit, +) { + + /** + * Return parent command if this command is a subcommand otherwise it returns null + */ + var parent: Command? = null + + /** + * The module this command belong to + */ + var module: BotModule? = null + get() { + if (parent != null) { + field = parent?.module + } + return field + } + + open suspend fun invoke(context: Context) { + val parsedArguments = arguments.call() + parsedArguments.parse(context) + handler.invoke(context, parsedArguments) + } +} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/EmptyArguments.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/EmptyArguments.kt new file mode 100644 index 0000000..92140a3 --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/EmptyArguments.kt @@ -0,0 +1,3 @@ +package io.github.null2264.tsukumogami.core.commands + +class EmptyArguments : Arguments() diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/Group.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/Group.kt new file mode 100644 index 0000000..30cde51 --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/Group.kt @@ -0,0 +1,38 @@ +package io.github.null2264.tsukumogami.core.commands + +import io.github.null2264.tsukumogami.core.Context +import io.github.null2264.tsukumogami.core.annotation.TsukumogamiInternalApi + +@OptIn(TsukumogamiInternalApi::class) +class Group( + name: String, + alias: Set, + description: String, +) : + IGroup, + Command( + name, + alias, + description, + ::EmptyArguments, + { _, _ -> /* No handler for group to match the behaviour of Discord's slash command */ }, + ) { + + override val allCommands: MutableMap = mutableMapOf() + + override fun _addCommand(command: Command) { + command.parent = this + super._addCommand(command) + } + + override suspend fun invoke(context: Context) { + val subcommandName = context.message.content + .substringAfter("${context.prefix}${context.invokedWith}") + .trim() + .substringBefore(' ') + val command = allCommands[subcommandName] ?: return + context.invokedWith += " $subcommandName" + context.command = command + command.invoke(context) + } +} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/IGroup.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/IGroup.kt new file mode 100644 index 0000000..78d06c7 --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/IGroup.kt @@ -0,0 +1,49 @@ +package io.github.null2264.tsukumogami.core.commands + +import io.github.null2264.tsukumogami.core.Context +import io.github.null2264.tsukumogami.core.annotation.TsukumogamiInternalApi +import kotlin.reflect.KFunction + +@OptIn(TsukumogamiInternalApi::class) +interface IGroup { + + val allCommands: MutableMap + + @TsukumogamiInternalApi + fun _addCommand(command: Command) { + allCommands[command.name] = command + command.alias.forEach { allCommands[it] = command } + } + + fun commands( + name: String, + alias: Set = setOf(), + description: String = "", + handler: suspend (Context) -> Unit, + ) { + val command = Command(name, alias, description, ::EmptyArguments) { ctx, _ -> handler(ctx) } + _addCommand(command) + } + + fun commands( + name: String, + alias: Set = setOf(), + description: String = "", + arguments: KFunction, + handler: suspend (Context, Args) -> Unit, + ) { + val command = Command(name, alias, description, arguments) { ctx, args -> handler(ctx, args as Args) } + _addCommand(command) + } + + fun groups( + name: String, + alias: Set = setOf(), + description: String = "", + declaration: IGroup.() -> Unit, + ) { + val group = Group(name, alias, description) + declaration(group) + _addCommand(group) + } +} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/module/annotation/Command.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/annotation/Command.kt similarity index 85% rename from core/src/main/kotlin/io/github/null2264/tsukumogami/core/module/annotation/Command.kt rename to core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/annotation/Command.kt index 9d6bf50..9756178 100644 --- a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/module/annotation/Command.kt +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/annotation/Command.kt @@ -1,5 +1,4 @@ -package io.github.null2264.tsukumogami.core.module.annotation - +package io.github.null2264.tsukumogami.core.commands.annotation /** * Annotation to tag a function as command diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/converters/Converter.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/converters/Converter.kt new file mode 100644 index 0000000..2824df3 --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/converters/Converter.kt @@ -0,0 +1,24 @@ +package io.github.null2264.tsukumogami.core.commands.converters + +import io.github.null2264.tsukumogami.core.Context +import io.github.null2264.tsukumogami.core.commands.Argument +import io.github.null2264.tsukumogami.core.commands.Arguments +import kotlin.reflect.KProperty + +abstract class Converter { + + lateinit var argumentObj: Argument + + abstract var parsed: OutputType + + abstract suspend fun parse(context: Context, input: String): OutputType + + operator fun getValue(thisRef: Arguments, property: KProperty<*>): OutputType { + return this.parsed + } + + fun default(defaultValue: OutputType): OutputType { + this.parsed = defaultValue + return this.parsed + } +} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/converters/impl/StringConverter.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/converters/impl/StringConverter.kt new file mode 100644 index 0000000..2a8a40d --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/converters/impl/StringConverter.kt @@ -0,0 +1,14 @@ +package io.github.null2264.tsukumogami.core.commands.converters.impl + +import io.github.null2264.tsukumogami.core.Context +import io.github.null2264.tsukumogami.core.commands.converters.Converter + +class StringConverter : Converter() { + + override var parsed: String = "" + + override suspend fun parse(context: Context, input: String): String { + this.parsed = input + return input + } +} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/converters/impl/UserConverter.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/converters/impl/UserConverter.kt new file mode 100644 index 0000000..b88d27e --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/converters/impl/UserConverter.kt @@ -0,0 +1,57 @@ +package io.github.null2264.tsukumogami.core.commands.converters.impl + +import dev.kord.common.entity.Snowflake +import dev.kord.core.entity.User +import io.github.null2264.tsukumogami.core.Context +import io.github.null2264.tsukumogami.core.annotation.TsukumogamiInternalApi +import io.github.null2264.tsukumogami.core.commands.converters.Converter +import io.github.null2264.tsukumogami.core.exceptions.CommandException +import io.github.null2264.tsukumogami.core.ext.users +import kotlinx.coroutines.flow.firstOrNull + +class UserConverter : Converter() { + + override lateinit var parsed: User + + override suspend fun parse(context: Context, input: String): User { + if (input.equals("me", true)) { + val user = context.author + if (user != null) { + this.parsed = user + return this.parsed + } + } + if (input.equals("you", true)) { + this.parsed = context.bot.client.getSelf() + return this.parsed + } + + this.parsed = context.findUser(input) ?: + throw CommandException("User not found") + + return this.parsed + } + + private suspend fun Context.findUser(arg: String): User? = + if (arg.startsWith("<@") && arg.endsWith(">")) { + val id: String = arg.substring(2, arg.length - 1).replace("!", "") + + try { + bot.client.getUser(Snowflake(id)) + } catch (_: NumberFormatException) { + throw CommandException("Invalid user ID") + } + } else { + try { + bot.client.getUser(Snowflake(arg)) + } catch (_: NumberFormatException) { + if (!arg.contains("#")) { + null + } else { + bot.client.users.firstOrNull { user -> + user.tag.equals(arg, true) + } + } + } + } +} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/ext/ArgumentsExtensions.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/ext/ArgumentsExtensions.kt new file mode 100644 index 0000000..c603b45 --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/commands/ext/ArgumentsExtensions.kt @@ -0,0 +1,19 @@ +package io.github.null2264.tsukumogami.core.commands.ext + +import dev.kord.core.entity.User +import io.github.null2264.tsukumogami.core.commands.Arguments +import io.github.null2264.tsukumogami.core.commands.converters.Converter +import io.github.null2264.tsukumogami.core.commands.converters.impl.StringConverter +import io.github.null2264.tsukumogami.core.commands.converters.impl.UserConverter + +fun Arguments.string(name: String, declaration: Converter.() -> Unit = {}): Converter { + val converter = StringConverter() + declaration(converter) + return args(name, converter) +} + +fun Arguments.user(name: String, declaration: Converter.() -> Unit = {}): Converter { + val converter = UserConverter() + declaration(converter) + return args(name, converter) +} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/ext/KoinExtensions.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/ext/KoinExtensions.kt new file mode 100644 index 0000000..b509a2b --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/ext/KoinExtensions.kt @@ -0,0 +1,17 @@ +package io.github.null2264.tsukumogami.core.ext + +import io.github.null2264.tsukumogami.core.koin.TsukumogamiKoinContext +import org.koin.core.module.Module +import org.koin.dsl.ModuleDeclaration +import org.koin.dsl.module + +fun loadModule( + createdAtStart: Boolean = false, + moduleDeclaration: ModuleDeclaration, +): Module { + val moduleObj = module(createdAtStart, moduleDeclaration) + + TsukumogamiKoinContext.loadKoinModules(moduleObj) + + return moduleObj +} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/ext/KordExtensions.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/ext/KordExtensions.kt new file mode 100644 index 0000000..728b106 --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/ext/KordExtensions.kt @@ -0,0 +1,6 @@ +package io.github.null2264.tsukumogami.core.ext + +import dev.kord.core.Kord +import dev.kord.core.supplier.EntitySupplyStrategy + +val Kord.users get() = with(EntitySupplyStrategy.cache).users diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/ext/StringExtensions.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/ext/StringExtensions.kt new file mode 100644 index 0000000..2bc9b79 --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/ext/StringExtensions.kt @@ -0,0 +1,56 @@ +package io.github.null2264.tsukumogami.core.ext + +fun String.parseCommandAndArguments(): List { + if (isBlank()) { + return emptyList() + } + + val result = mutableListOf() + val currentToken = StringBuilder() + var inQuotes = false + + for (char in this) { + when (char) { + '"' -> { + if (inQuotes) { + // Closing quote: add the accumulated token + result.add(currentToken.toString()) + currentToken.clear() + inQuotes = false + } else { + // Opening quote: + // If there's an existing token (e.g., word"another"), add it first + if (currentToken.isNotEmpty()) { + result.add(currentToken.toString()) + currentToken.clear() + } + inQuotes = true + } + } + ' ' -> { + if (inQuotes) { + // Space inside quotes: append it + currentToken.append(char) + } else { + // Space outside quotes: separator + if (currentToken.isNotEmpty()) { + result.add(currentToken.toString()) + currentToken.clear() + } + // Ignore multiple spaces between tokens + } + } + else -> { + // Any other character: append it + currentToken.append(char) + } + } + } + + // Add any remaining token after the loop (e.g., if the string doesn't end with a quote or space) + if (currentToken.isNotEmpty()) { + result.add(currentToken.toString()) + } + + return result +} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/koin/TsukumogamiKoinComponent.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/koin/TsukumogamiKoinComponent.kt new file mode 100644 index 0000000..525d75c --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/koin/TsukumogamiKoinComponent.kt @@ -0,0 +1,8 @@ +package io.github.null2264.tsukumogami.core.koin + +import org.koin.core.Koin +import org.koin.core.component.KoinComponent + +interface TsukumogamiKoinComponent : KoinComponent { + override fun getKoin(): Koin = TsukumogamiKoinContext.get() +} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/koin/TsukumogamiKoinContext.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/koin/TsukumogamiKoinContext.kt new file mode 100644 index 0000000..4ff3ad1 --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/koin/TsukumogamiKoinContext.kt @@ -0,0 +1,75 @@ +package io.github.null2264.tsukumogami.core.koin + +import org.koin.core.Koin +import org.koin.core.KoinApplication +import org.koin.core.context.KoinContext +import org.koin.core.error.KoinApplicationAlreadyStartedException +import org.koin.core.module.Module +import org.koin.dsl.KoinAppDeclaration + +/** + * The [KoinContext] for Tsukumogami. + * + * To use this context, implement [TsukumogamiKoinComponent]. + * + * @see org.koin.core.context.GlobalContext + */ +object TsukumogamiKoinContext : KoinContext { + /** The current [Koin] instance. */ + private var koin: Koin? = null + + /** The current [KoinApplication]. */ + private var koinApp: KoinApplication? = null + + override fun get(): Koin = koin ?: error("KoinApplication has not been started") + override fun getOrNull(): Koin? = koin + public fun getKoinApplicationOrNull(): KoinApplication? = koinApp + + private fun register(koinApplication: KoinApplication) { + if (koin != null) { + throw KoinApplicationAlreadyStartedException("KoinApplication has already been started") + } + + koinApp = koinApplication + koin = koinApplication.koin + } + + override fun loadKoinModules(module: Module, createEagerInstances: Boolean) { + get().loadModules(listOf(module), createEagerInstances = createEagerInstances) + } + + override fun loadKoinModules(modules: List, createEagerInstances: Boolean) { + get().loadModules(modules, createEagerInstances = createEagerInstances) + } + + override fun startKoin(koinApplication: KoinApplication): KoinApplication = synchronized(this) { + register(koinApplication) + koinApplication.createEagerInstances() + koinApplication.allowOverride(true) + + return koinApplication + } + + override fun startKoin(appDeclaration: KoinAppDeclaration): KoinApplication = synchronized(this) { + val koinApplication = KoinApplication.init() + + register(koinApplication) + appDeclaration(koinApplication) + koinApplication.createEagerInstances() + + return koinApplication + } + + override fun stopKoin() { + koin?.close() + koin = null + } + + override fun unloadKoinModules(module: Module) { + get().unloadModules(listOf(module)) + } + + override fun unloadKoinModules(modules: List) { + get().unloadModules(modules) + } +} diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/module/BotModule.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/module/BotModule.kt index 26fe1d5..bdf2b85 100644 --- a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/module/BotModule.kt +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/module/BotModule.kt @@ -1,47 +1,27 @@ package io.github.null2264.tsukumogami.core.module -import co.touchlab.kermit.Logger -import io.github.null2264.tsukumogami.core.AbstractBot -import io.github.null2264.tsukumogami.core.module.annotation.Command as CommandAnnotation -import io.github.null2264.tsukumogami.core.BotConfigurator -import kotlin.reflect.jvm.isAccessible -import kotlin.reflect.jvm.kotlinFunction +import io.github.null2264.tsukumogami.core.Bot +import io.github.null2264.tsukumogami.core.annotation.TsukumogamiInternalApi +import io.github.null2264.tsukumogami.core.commands.Command +import io.github.null2264.tsukumogami.core.commands.IGroup -abstract class BotModule(val name: String, val description: String? = null) { +@OptIn(TsukumogamiInternalApi::class) +open class BotModule constructor(val name: String) : IGroup { - var bot: AbstractBot? = null - internal set + override val allCommands: MutableMap = mutableMapOf() - open fun setup() {} + override fun _addCommand(command: Command) { + command.module = this + super._addCommand(command) + } - internal fun install(bot: AbstractBot, configurator: BotConfigurator) { - this.bot = bot + internal fun install(bot: Bot): BotModule { + allCommands.values.distinctBy { it.name }.forEach(bot::addCommand) + return this + } - val methods = this::class.java.declaredMethods - for (method in methods) { - for (annotation in method.annotations) { - if (annotation !is CommandAnnotation) - continue - - configurator.apply { - val kMethod = method.kotlinFunction - if (isExists(kMethod?.name)) { - Logger.e { "Command already exists" } - return - } - kMethod?.let { - it.isAccessible = true - commands( - Command( - annotation.name.ifEmpty { it.name }, - name, - it, - annotation.description.ifEmpty { description }, - ) - ) - } - } - } - } + internal fun uninstall(bot: Bot): BotModule { + allCommands.values.distinctBy { it.name }.forEach(bot::removeCommand) + return this } } diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/module/Command.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/module/Command.kt deleted file mode 100644 index c5d64ed..0000000 --- a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/module/Command.kt +++ /dev/null @@ -1,13 +0,0 @@ -package io.github.null2264.tsukumogami.core.module - -import kotlin.reflect.KFunction - -/** - * Class holding information about a command - */ -data class Command( - val name: String, - val extension: String, - val callback: KFunction<*>, - val description: String? = null, -) diff --git a/core/src/main/kotlin/io/github/null2264/tsukumogami/core/module/api/BotModuleApi.kt b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/module/api/BotModuleApi.kt new file mode 100644 index 0000000..20f5f4e --- /dev/null +++ b/core/src/main/kotlin/io/github/null2264/tsukumogami/core/module/api/BotModuleApi.kt @@ -0,0 +1,11 @@ +package io.github.null2264.tsukumogami.core.module.api + +import io.github.null2264.tsukumogami.core.module.BotModule + +typealias BotModuleDeclaration = BotModule.() -> Unit + +fun botModules(name: String, declaration: BotModuleDeclaration): BotModule { + val module = BotModule(name) + declaration(module) + return module +}