Summary
Provide a usable and extensible command DSL that facilitates the creation of bots.
Goals
Support a command DSL with the following features:
- type safe (user-made) Arguments
- grouping of commands
- conditional execution of commands (preconditions, 'turning commands on and off')
- command aliases
- command autocorrect/suggest
- command search
Non-Goals
- This feature shouldn't include an implementation of dependency injection,
there are better and more mature libraries than what we could come up for this feature.
Motivation
Every bot quickly reaches a point in which it becomes beneficial to separate the functionality of different invocations into a structure that cuts down on boilerplate. As such, we should introduce such a framework for Kord users.
Description
Command structure
Conceptually, discord command libraries follow a similar pattern in how commands are structured: [prefix][command name] [arguments...]
.
- The prefix is usually short as to not bother the user too much and allows the bot the easily check if a certain message is intended to be a command invocation.
- The command name usually consists of a single word, which allows for easy and fast look-up, increasing performance.
- Arguments are usually fixed length (save for the last argument, which is often allowed to be of variable length), this allows for fast matching and failing since there is only one possible combination of solutions.
Arguments
Arguments
are a way for commands to apply restrictions on what arguments are acceptable for invocation, this saves the user from parsing them in the execution of the command (and having to return an error message). They allow for custom mapping from text to a desired type and contain some self-documenting features for help
-like commands.
interface Argument<T, in CONTEXT> {
val name: String
val example: String
suspend fun parse(words: List<String>, fromIndex: Int, context: CONTEXT): Result<T>
}
-
T: the type of value the argument generates (e.g. an IntArgument
would generate Int
and thus be an Argument<T, CONTEXT>
).
-
CONTEXT: the context for which this argument was invoked, for Kord arguments the context will be a MessageCreateEvent
.
-
name: the name of an argument, used for self-documenting features
-
example: an example of a valid value, used for self-documenting features
-
parse: a function that converts a set of Strings into a possible Result
, a Result
can either be a Success
(the requirements for the argument were met) or a Failure
(the requirements couldn't be met and an error-like message is given).
sealed class Result<T> {
class Success<T>(val item: T, val wordsTaken: Int) : Result<T>()
class Failure<T>(val reason: String, val atWord: Int) : Result<T>()
}
-
item: The generated value from the Argument.
-
wordsTaken: The amount of words taken to generate the result, this number will be added to the fromIndex
and then be passed to the next command. (e.g. an IntArgument will convert the first String word to an Int, on success it'll report a Success(number, 1)
).
-
reason: The reason for failure.
-
atWord: At which word the argument failed, this is in relation to the fromIndex
. (e.g. an IntArgument will report a Failure("expected an integer number", 0)
).
Extensions
While the types of input are often very limited (mostly: text, number, mention), The rules of those values often aren't.
Arguments
and Results
should be easily extensible so that rules can be easily applied on top of existing Arguments
.
for example:
fun <T: Any> Result<T>.optional(): Result.Success<T?> = when(this) {
is Result.Success -> this as Result.Success<T?>
else -> Result.Success(null, 0)
}
fun <T : Any, CONTEXT> Argument<T, CONTEXT>.optional(): Argument<T?, CONTEXT> = object :
Argument<T?, CONTEXT> by this as Argument<T?, CONTEXT> {
override suspend fun parse(words: List<String>, fromIndex: Int, context: CONTEXT): Result<T?> {
return this@optional.parse(words, fromIndex, context).optional()
}
}
The steps of command creation
To allow for a configurable and extensible design, we should discover all steps in the process of making commands, and allow users to hook into those steps;
- CommandBuilder modification
- CommandBuilder building
CommandBuilder modification
Module builders are implicitly created as a 'blank slate' the first time they are requested and are returned for every subsequent get,
this ensure that there is no collision between two modules with the same name (they will be the same module) and allows easy modification of modules you do not own.
interface ModuleModifier {
suspend fun apply(container: ModuleContainer)
}
class ModuleContainer() {
operator fun get(name: String): ModuleBuilder<*, *, *>
operator fun String.unaryMinus()
fun remove(module: String)
inline fun apply(name: String, consumer: (ModuleBuilder<*, *, *>) -> Unit)
fun forEach(consumer: suspend (ModuleBuilder<*, *, *>) -> Unit)
}
CommandBuilder building
The builder needs to be build into a Command
. There's no real room for configuration here since the behaviour of a command is fixed.
The steps of command invocation
To allow for a configurable and extensible design, we should discover all steps in the process of invoking commands, and allow users to hook into those steps;
- before parsing
- after parsing/before invoke
- on invoke
- after invoke
before parsing
By introducing control flow before any work is being done, we can efficiently reject events with minimal overhead. (used for ignoring certain users, bots, etc). Filters that operate on this level will fail silently.
after parsing/before invoke
A second kind of control flow will operate right before a command is invoked. Filters on this level will have access to the parsed arguments (depending on where the filter was created) and should mostly be used to verify internal state that's required over multiple commands. Filters that operate on this level won't fail silently.
on invoke
Most useful for logging, this event won't be allowed to influence the control flow of the command.
after invoke
After a command is finished, there might be some cleanup left to do, the most common application for these kinds of events would be to delete the caller's message.
Making extendable commands
Creating ways to interact with commands is one step of the process, the other will be to create a way to introduce data to be processed in the steps of command creation. Since inserting properties on an instance level is impossible in Kotlin, we're required to go about this slightly differently.
Metadata
Similarly to how JSON libraries represent properties as key->value maps, Kord could introduce a key->value map to introduce data about the command, for later processing or runtime usage:
interface Metadata {
interface Key<T>
operator fun<T> get(key: Key<T>) : T?
}
during the building and modifying of commands:
interface MutableMetadata : MetaData {
operator fun<T> set(key: Metadata.Key<T>, value: T)
}
A simple example of inserting a flag in a command:
object EnsureGuild: Metadata.Key<Boolean>
fun CommandConfiguration.ensureGuild(enable: Boolean = true) {
metaData[EnsureGuild] = enable
}
//mocked code example
fun myCommand() = command("test") {
ensureGuild()
execute {
//....
}
}
Then during the processing of commands, an extra precondition can be added that will reject events if they weren't executed in a guild.
Kord could modify all its behaviour in commands to be build upon metadata. The execution of commands could be written as follows:
object Execute : Metadata.Key<Pair<ArgumentCollection<*>, suspend CommandEvent<*>.() -> Unit>>
fun <T> CommandConfiguration.invoke(collection: ArgumentCollection<T>, block: suspend CommandEvent<T>.() -> Unit) {
val pair = collection to block
metaData[Execute] = pair as Pair<ArgumentCollection<*>, suspend CommandEvent<*>.() -> Unit>
}
//mocked code example
fun command() = command("test") {
invoke(arguments(IntArgument, IntArgument)) {
//....
}
}
Issue originally made by @BartArys