Compare commits

..

1 Commits

Author SHA1 Message Date
kageru 5d3a519036
wip: config reloading 2019-07-14 17:27:43 +02:00
46 changed files with 1063 additions and 1501 deletions

View File

@ -1,8 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true

View File

@ -1,7 +0,0 @@
## 1.1
- getConfig and setConfig to reload the config file at runtime via discord commands
- fixed wrong timestamps in some embeds (ty CommanderLook)
# 1.0
- feature parity with [the old bot](https://git.kageru.moe/kageru/discord-selphybot) (this does not include features that were broken in the old bot)
- proper config validation at startup time

View File

@ -1,21 +0,0 @@
# kagebot
Kinda dead.
This bot is a replacement for [my old one](https://git.kageru.moe/kageru/discord-selphybot) with a very simple premise:
As much as possible should be configurable in a human-readable way.
This will allow anyone to modify the config to host their own instance tailored to their own needs,
and it allows server moderators to make changes without any coding experience. Even at runtime.
I try to maintain a comprehensive default configuration as part of the repository
because the past has taught me that I’m not good at updating readmes.
## A few months after
The bot has become somewhat specialized at this point,
but I think it should still be generally reusable if a similar use case arises.
The implementation has kind of deteriorated into a playground for me
(adding arrow-kt and just generally trying out FP stuff)[1],
but it’s been running and moderating a 1000+ user server for over a year
with relatively little maintenance.
[1]: While arrow is great, adding it to a project after the fact leads to a very weird combination of FP and non-FP constructs.
Would not recommend in production. This was also built in an early version of arrow that still had `Kind` and other concepts that were scrapped later,
but I don’t plan to update that ever. The bot can keep running as-is until it breaks.

View File

@ -1,25 +1,22 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
apply {
plugin("kotlin-kapt")
}
plugins { plugins {
kotlin("jvm") version "1.9.0" kotlin("jvm") version "1.3.40"
id("com.github.johnrengelman.shadow") version "8.1.1" apply true id("com.github.johnrengelman.shadow") version "5.1.0" apply true
application application
} }
val botMainClass = "moe.kageru.kagebot.KagebotKt" val botMainClass = "moe.kageru.kagebot.KagebotKt"
application { application {
mainClass.set(botMainClass) mainClassName = botMainClass
} }
tasks.withType<Jar> { tasks.withType<Jar> {
manifest { manifest {
attributes( attributes(
mapOf( mapOf(
"Main-Class" to botMainClass, "Main-Class" to botMainClass
), )
) )
} }
} }
@ -30,35 +27,24 @@ version = "0.1"
repositories { repositories {
mavenCentral() mavenCentral()
jcenter() jcenter()
maven {
url = uri("https://dl.bintray.com/arrow-kt/arrow-kt/")
}
} }
val test by tasks.getting(Test::class) { val test by tasks.getting(Test::class) {
useJUnitPlatform { } useJUnitPlatform { }
} }
val arrowVersion = "0.11.0"
dependencies { dependencies {
implementation("com.uchuhimo:konf-core:0.23.0") implementation("com.moandjiezana.toml:toml4j:0.7.2")
implementation("com.uchuhimo:konf-toml:0.23.0") implementation(kotlin("stdlib-jdk8"))
implementation("org.javacord:javacord:3.8.0") implementation("org.javacord:javacord:3.0.4")
implementation("org.mapdb:mapdb:3.0.8")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9")
implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.0")
implementation("com.fasterxml.jackson.core:jackson-annotations:2.11.3")
implementation("io.arrow-kt:arrow-core:$arrowVersion") testImplementation("io.kotlintest:kotlintest-runner-junit5:3.3.2")
implementation("io.arrow-kt:arrow-syntax:$arrowVersion") testImplementation("io.mockk:mockk:1.9.3")
testImplementation("io.kotlintest:kotlintest-runner-junit5:3.4.2")
testImplementation("io.mockk:mockk:1.10.0")
// these two are needed to access javacord internals (such as reading from sent embeds during tests) // these two are needed to access javacord internals (such as reading from sent embeds during tests)
testImplementation("org.javacord:javacord-core:3.8.0") testImplementation("org.javacord:javacord-core:3.0.4")
testImplementation("com.fasterxml.jackson.core:jackson-databind:2.11.3") testImplementation("com.fasterxml.jackson.core:jackson-databind:2.9.9")
} }
tasks.withType<KotlinCompile> { tasks.withType<KotlinCompile> {
kotlinOptions.jvmTarget = "20" kotlinOptions.jvmTarget = "1.8"
} }

View File

@ -1,2 +0,0 @@
#!/bin/sh
ktlint --disabled_rules import-ordering,no-wildcard-imports && gradle test

View File

@ -1,4 +0,0 @@
#!/bin/sh
if ./check.sh; then
ssh lain sudo systemctl restart selphybot.service
fi

View File

@ -1,10 +1,9 @@
package moe.kageru.kagebot package moe.kageru.kagebot
import moe.kageru.kagebot.persistence.Dao
import org.javacord.api.DiscordApi import org.javacord.api.DiscordApi
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
object Globals { object Globals {
lateinit var api: DiscordApi lateinit var api: DiscordApi
val commandCounter: AtomicInteger = AtomicInteger(Dao.getCommandCounter()) val commandCounter: AtomicInteger = AtomicInteger(0)
} }

View File

@ -1,13 +1,13 @@
package moe.kageru.kagebot package moe.kageru.kagebot
import arrow.core.extensions.list.foldable.find import moe.kageru.kagebot.Log.log
import moe.kageru.kagebot.Util.checked import moe.kageru.kagebot.Util.checked
import moe.kageru.kagebot.config.Config import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.config.ConfigParser import moe.kageru.kagebot.config.ConfigParser
import moe.kageru.kagebot.cron.CronD import moe.kageru.kagebot.config.RawConfig
import moe.kageru.kagebot.persistence.Dao
import org.javacord.api.DiscordApiBuilder import org.javacord.api.DiscordApiBuilder
import org.javacord.api.event.message.MessageCreateEvent import org.javacord.api.event.message.MessageCreateEvent
import org.javacord.api.event.server.member.ServerMemberJoinEvent
import java.io.File import java.io.File
import kotlin.system.exitProcess import kotlin.system.exitProcess
@ -16,43 +16,59 @@ fun main() {
} }
object Kagebot { object Kagebot {
fun MessageCreateEvent.process() { fun processMessage(event: MessageCreateEvent) {
if (messageAuthor.isBotUser) { if (event.messageAuthor.isBotUser) {
handleOwn() if (event.messageAuthor.isYourself) {
log.info("<Self> ${event.readableMessageContent}")
}
return return
} }
Config.commands for (command in Config.commands) {
.find { it.matches(readableMessageContent) && it.isAllowed(this) } if (command.matches(event.readableMessageContent)) {
.map { it.execute(this) } command.execute(event)
break
}
}
} }
private fun MessageCreateEvent.handleOwn() { fun welcomeUser(event: ServerMemberJoinEvent) {
if (messageAuthor.isYourself) { Config.features.welcome!!.let { welcome ->
val loggedMessage = readableMessageContent.ifBlank { "[embed]" } val message = event.user.sendMessage(welcome.embed)
Log.info("<Self> $loggedMessage") // If the user disabled direct messages, try the fallback (if defined)
if (!Util.wasSuccessful(message) &&
welcome.fallbackChannel != null &&
welcome.fallbackMessage != null
) {
welcome.fallbackChannel.sendMessage(
welcome.fallbackMessage.replace(
"@@",
MessageUtil.mention(event.user)
)
)
} }
} }
}
private fun getSecret() = File("secret").readText().replace("\n", "")
fun init() { fun init() {
val secret = File("secret").readText().trim() Globals.api = DiscordApiBuilder().setToken(getSecret()).login().join()
val api = DiscordApiBuilder().setToken(secret).setAllIntents().login().join() try {
Globals.api = api ConfigParser.initialLoad(RawConfig.read())
ConfigParser.initialLoad(ConfigParser.DEFAULT_CONFIG_PATH).mapLeft { e -> } catch (e: IllegalArgumentException) {
println("Config parsing error:\n$e,\n${e.message},\n${e.stackTrace.joinToString("\n")}") println("Config error:\n$e,\n${e.message},\n${e.stackTrace.joinToString("\n")}")
println("Caused by: ${e.cause}\n${e.cause?.stackTrace?.joinToString("\n")}")
exitProcess(1) exitProcess(1)
} }
Runtime.getRuntime().addShutdownHook( Runtime.getRuntime().addShutdownHook(Thread {
Thread { log.info("Bot has been interrupted. Shutting down.")
Log.info("Bot has been interrupted. Shutting down.") Globals.api.disconnect()
Dao.setCommandCounter(Globals.commandCounter.get()) })
Dao.close() log.info("kagebot Mk II running")
api.disconnect() Globals.api.addMessageCreateListener { checked { processMessage(it) } }
}, Config.features.welcome?.let {
) Globals.api.addServerMemberJoinListener {
Log.info("kagebot Mk II running") checked { welcomeUser(it) }
api.addMessageCreateListener { checked { it.process() } } }
Config.features.eventFeatures().forEach { it.register(api) } }
CronD.startAll()
} }
} }

View File

@ -8,22 +8,13 @@ import java.util.logging.LogRecord
import java.util.logging.Logger import java.util.logging.Logger
object Log { object Log {
private val log: Logger by lazy { val log: Logger by lazy {
Logger.getGlobal().apply { val log = Logger.getGlobal()
addHandler( val fh = FileHandler("kagebot.log", true)
FileHandler("kagebot.log", true).apply { val formatter = LogFormatter()
formatter = LogFormatter() fh.formatter = formatter
}, log.addHandler(fh)
) return@lazy log
}
}
fun info(message: String) {
log.info(message)
}
fun warn(message: String) {
log.warning(message)
} }
} }

View File

@ -1,37 +1,37 @@
package moe.kageru.kagebot package moe.kageru.kagebot
import moe.kageru.kagebot.config.Config import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.config.SystemSpec import org.javacord.api.entity.channel.TextChannel
import org.javacord.api.entity.message.Message import org.javacord.api.entity.message.Message
import org.javacord.api.entity.message.MessageAuthor import org.javacord.api.entity.message.MessageAuthor
import org.javacord.api.entity.message.Messageable
import org.javacord.api.entity.message.embed.EmbedBuilder import org.javacord.api.entity.message.embed.EmbedBuilder
import org.javacord.api.entity.user.User
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
object MessageUtil { object MessageUtil {
fun MessageAuthor.mention() = "<@$id>" fun mention(user: MessageAuthor): String {
return "<@${user.id}>"
fun withEmbed(op: EmbedBuilder.() -> Unit): EmbedBuilder {
return EmbedBuilder().apply {
Config.server.icon.ifPresent { setThumbnail(it) }
setColor(SystemSpec.color)
op()
}
} }
fun Messageable.sendEmbed(op: EmbedBuilder.() -> Unit) { fun mention(user: User): String {
val embed = withEmbed { return "<@${user.id}>"
setTimestampToNow()
op()
} }
sendMessage(embed)
fun getEmbedBuilder(): EmbedBuilder {
val builder = EmbedBuilder()
Config.server.icon.ifPresent { builder.setThumbnail(it) }
return builder.setColor(Config.systemConfig.color)
} }
/** /**
* Send and embed and add the current time to it. * Send and embed and add the current time to it.
* The time is not set in [withEmbed] because of https://git.kageru.moe/kageru/discord-kagebot/issues/13. * The time is not set in [getEmbedBuilder] because of https://git.kageru.moe/kageru/discord-kagebot/issues/13.
*/ */
fun sendEmbed(target: Messageable, embed: EmbedBuilder): CompletableFuture<Message> { fun sendEmbed(target: TextChannel, embed: EmbedBuilder): CompletableFuture<Message> {
return target.sendMessage(embed.setTimestampToNow())
}
fun sendEmbed(target: User, embed: EmbedBuilder): CompletableFuture<Message> {
return target.sendMessage(embed.setTimestampToNow()) return target.sendMessage(embed.setTimestampToNow())
} }
@ -40,22 +40,15 @@ object MessageUtil {
* I tried LinkedHashMaps, but those dont seem to work either. * I tried LinkedHashMaps, but those dont seem to work either.
*/ */
fun listToEmbed(contents: List<String>): EmbedBuilder { fun listToEmbed(contents: List<String>): EmbedBuilder {
check(contents.size % 2 != 1) { "Embed must have even number of content strings (title/content pairs)" } if (contents.size % 2 == 1) {
return withEmbed { throw IllegalStateException("Embed must have even number of content strings (title/content pairs)")
contents.toPairs().forEach { (heading, content) -> }
addField(heading, content) val builder = getEmbedBuilder()
} contents.zip(1..contents.size).filter { it.second % 2 == 0 }
} for ((heading, content) in contents.withIndex().filter { it.index % 2 == 0 }
} zip contents.withIndex().filter { it.index % 2 == 1 }) {
builder.addField(heading.value, content.value)
/** }
* Convert a list of elements to pairs, retaining order. return builder
* The last element is dropped if the input size is odd.
* [1, 2, 3, 4, 5] -> [[1, 2], [3, 4]]
*/
private fun <T> Collection<T>.toPairs(): List<Pair<T, T>> = this.iterator().run {
(0 until size / 2).map {
Pair(next(), next())
}
} }
} }

View File

@ -1,76 +1,92 @@
package moe.kageru.kagebot package moe.kageru.kagebot
import arrow.core.* import moe.kageru.kagebot.Log.log
import arrow.core.extensions.either.monad.flatMap import moe.kageru.kagebot.config.Config
import arrow.core.extensions.list.foldable.find
import moe.kageru.kagebot.config.Config.server import moe.kageru.kagebot.config.Config.server
import moe.kageru.kagebot.extensions.*
import org.javacord.api.entity.channel.TextChannel import org.javacord.api.entity.channel.TextChannel
import org.javacord.api.entity.message.MessageAuthor import org.javacord.api.entity.message.MessageAuthor
import org.javacord.api.entity.message.embed.EmbedBuilder import org.javacord.api.entity.message.embed.EmbedBuilder
import org.javacord.api.entity.permission.Role import org.javacord.api.entity.permission.Role
import org.javacord.api.entity.user.User import org.javacord.api.entity.user.User
import org.javacord.api.event.message.MessageCreateEvent
import java.awt.Color import java.awt.Color
import java.util.* import java.util.*
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
import java.util.concurrent.CompletionException import java.util.concurrent.CompletionException
object Util { object Util {
inline fun <T> T.applyIf(condition: Boolean, op: (T) -> T): T { inline fun <T> T.doIf(condition: (T) -> Boolean, op: (T) -> T): T {
return if (condition) op(this) else this return if (condition(this)) op(this) else this
} }
fun hasOneOf(messageAuthor: MessageAuthor, roles: Set<String>): Boolean { /**
return messageAuthor.asUser().asOption().flatMap { user -> * Mimics the behavior of [Optional.ifPresent], but returns null if the optional is empty,
user.roles().find { it.name in roles } * allowing easier fallback behavior via Kotlins ?: operator.
}.nonEmpty() */
private inline fun <T, R> Optional<T>.ifNotEmpty(op: (T) -> R): R? {
if (this.isPresent) {
return op(this.get())
}
return null
}
fun hasOneOf(messageAuthor: MessageAuthor, roles: Set<Role>): Boolean {
return messageAuthor.asUser().ifNotEmpty { user ->
user.getRoles(server).toSet().intersect(roles).isNotEmpty()
} ?: false
} }
private val channelIdRegex = Regex("\\d{18}") private val channelIdRegex = Regex("\\d{18}")
private fun String.isEntityId() = channelIdRegex.matches(this) private fun String.isEntityId() = channelIdRegex.matches(this)
fun findRole(idOrName: String): Either<String, Role> { @Throws(IllegalArgumentException::class)
fun findRole(idOrName: String): Role {
return when { return when {
idOrName.isEntityId() -> server.getRoleById(idOrName).asOption().toEither { 0 } idOrName.isEntityId() -> server.getRoleById(idOrName).ifNotEmpty { it }
else -> server.rolesByName(idOrName).getOnly() ?: throw IllegalArgumentException("Role $idOrName not found.")
}.mapLeft { "Found $it results, expected 1" } else -> server.getRolesByNameIgnoreCase(idOrName).let {
when (it.size) {
0 -> throw IllegalArgumentException("Role $idOrName not found.")
1 -> it[0]
else -> throw IllegalArgumentException("More than one role found with name $idOrName. Please specify the role ID instead")
}
} }
private fun <T> ListK<T>.getOnly(): Either<Int, T> {
return when (size) {
1 -> Either.right(first())
else -> Either.left(size)
} }
} }
fun findUser(idOrName: String): Option<User> { fun <T> wasSuccessful(future: CompletableFuture<T>): Boolean {
return when { try {
idOrName.isEntityId() -> server.getMemberById(idOrName).asOption() future.join()
idOrName.contains('#') -> server.getMemberByDiscriminatedNameIgnoreCase(idOrName).asOption()
else -> server.membersByName(idOrName).firstOrNone()
}
}
fun <T> CompletableFuture<T>.asOption(): Option<T> {
return try {
val future = join()
if (isCompletedExceptionally) None else Option.just(future)
} catch (e: CompletionException) { } catch (e: CompletionException) {
Option.empty() // we don’t care about this error, but I don’t want to spam stdout
} }
return !future.isCompletedExceptionally
} }
fun <T> Optional<T>.asOption(): Option<T> = if (this.isPresent) Option.just(this.get()) else Option.empty() @Throws(IllegalArgumentException::class)
fun findChannel(idOrName: String): TextChannel {
fun findChannel(idOrName: String): Either<String, TextChannel> {
return when { return when {
idOrName.isEntityId() -> server.channelById(idOrName).toEither { "Channel $idOrName not found" } idOrName.isEntityId() -> server.getTextChannelById(idOrName).ifNotEmpty { it }
idOrName.startsWith('@') -> Globals.api.getUserById(idOrName.removePrefix("@")).asOption() ?: throw IllegalArgumentException("Channel ID $idOrName not found.")
.toEither { "User $idOrName not found" } else -> if (idOrName.startsWith('@')) {
.flatMap { user -> Globals.api.getCachedUserByDiscriminatedName(idOrName.removePrefix("@")).ifNotEmpty { user ->
user.openPrivateChannel().asOption().toEither { "Can’t DM user $idOrName" } val channelFuture = user.openPrivateChannel()
val channel = channelFuture.join()
if (channelFuture.isCompletedExceptionally) {
throw IllegalArgumentException("Could not open private channel with user $idOrName for redirection.")
}
channel
}
?: throw IllegalArgumentException("Can’t find user $idOrName for redirection.")
} else {
server.getTextChannelsByName(idOrName).let {
when (it.size) {
0 -> throw IllegalArgumentException("Channel $idOrName not found.")
1 -> it[0]
else -> throw IllegalArgumentException("More than one channel found with name $idOrName. Please specify the channel ID instead")
}
}
} }
else -> server.channelsByName(idOrName).getOnly().mapLeft { "Found $it channels for $idOrName, expected 1" }
} }
} }
@ -78,8 +94,24 @@ object Util {
try { try {
op() op()
} catch (e: Exception) { } catch (e: Exception) {
Log.warn("An uncaught exception occurred.\n$e") log.warning("An uncaught exception occurred.\n$e")
Log.warn(e.stackTrace.joinToString("\n")) MessageUtil.sendEmbed(Globals.api.owner.get(),
EmbedBuilder()
.setTimestampToNow()
.setColor(Color.RED)
.addField("Error", "kagebot has encountered an error")
.addField(
"$e", """```
${e.stackTrace.joinToString("\n")}
```""".trimIndent()
)
)
}
}
fun userFromMessage(message: MessageCreateEvent): User? {
return message.messageAuthor.id.let { id ->
Config.server.getMemberById(id).orElse(null)
} }
} }
} }

View File

@ -1,11 +1,11 @@
package moe.kageru.kagebot.command package moe.kageru.kagebot.command
import com.fasterxml.jackson.annotation.JsonProperty
import moe.kageru.kagebot.Globals import moe.kageru.kagebot.Globals
import moe.kageru.kagebot.Log import moe.kageru.kagebot.Log.log
import moe.kageru.kagebot.MessageUtil import moe.kageru.kagebot.MessageUtil
import moe.kageru.kagebot.MessageUtil.mention import moe.kageru.kagebot.Util.doIf
import moe.kageru.kagebot.config.Config import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.config.RawCommand
import moe.kageru.kagebot.features.MessageFeature import moe.kageru.kagebot.features.MessageFeature
import org.javacord.api.entity.message.MessageAuthor import org.javacord.api.entity.message.MessageAuthor
import org.javacord.api.entity.message.embed.EmbedBuilder import org.javacord.api.entity.message.embed.EmbedBuilder
@ -13,30 +13,41 @@ import org.javacord.api.event.message.MessageCreateEvent
private const val AUTHOR_PLACEHOLDER = "@@" private const val AUTHOR_PLACEHOLDER = "@@"
class Command( class Command(cmd: RawCommand) {
val trigger: String, val trigger: String
private val response: String? = null, private val response: String?
private val permissions: Permissions?, val matchType: MatchType
@JsonProperty("action") private val permissions: Permissions?
private val actions: MessageActions?, private val actions: MessageActions?
embed: List<String>?, val regex: Regex?
feature: String?, val embed: EmbedBuilder?
matchType: String?, val feature: MessageFeature?
) {
val matchType: MatchType = matchType?.let { type ->
MatchType.values().find { it.name.equals(type, ignoreCase = true) }
?: throw IllegalArgumentException("Invalid [command.matchType]: “$matchType")
} ?: MatchType.PREFIX
val regex: Regex? = if (this.matchType == MatchType.REGEX) Regex(trigger) else null
val embed: EmbedBuilder? = embed?.let(MessageUtil::listToEmbed)
private val feature: MessageFeature? = feature?.let { Config.features.findByString(it) }
fun matches(msg: String) = this.matchType.matches(msg, this) init {
trigger = cmd.trigger ?: throw IllegalArgumentException("Every command must have a trigger.")
response = cmd.response
matchType = cmd.matchType?.let { type ->
MatchType.values().find { it.name.equals(type, ignoreCase = true) }
?: throw IllegalArgumentException("Invalid [command.matchType]: “${cmd.matchType}")
} ?: MatchType.PREFIX
permissions = cmd.permissions?.let { Permissions(it) }
actions = cmd.actions?.let { MessageActions(it) }
regex = if (matchType == MatchType.REGEX) Regex(trigger) else null
embed = cmd.embed?.let(MessageUtil::listToEmbed)
feature = cmd.feature?.let { Config.features.findByString(it) }
}
fun isAllowed(message: MessageCreateEvent) = permissions?.isAllowed(message) ?: true fun isAllowed(message: MessageCreateEvent) = permissions?.isAllowed(message) ?: true
fun execute(message: MessageCreateEvent) { fun execute(message: MessageCreateEvent) {
Log.info("Executing command ${this.trigger} triggered by user ${message.messageAuthor.discriminatedName} (ID: ${message.messageAuthor.id})") if (permissions?.isAllowed(message) == false) {
if (Config.localization.permissionDenied.isNotBlank()) {
message.channel.sendMessage(Config.localization.permissionDenied)
}
log.info("Denying command ${this.trigger} to user ${message.messageAuthor.discriminatedName} (ID: ${message.messageAuthor.id})")
return
}
log.info("Executing command ${this.trigger} triggered by user ${message.messageAuthor.discriminatedName} (ID: ${message.messageAuthor.id})")
Globals.commandCounter.incrementAndGet() Globals.commandCounter.incrementAndGet()
this.actions?.run(message, this) this.actions?.run(message, this)
this.response?.let { this.response?.let {
@ -48,13 +59,23 @@ class Command(
this.feature?.handle(message) this.feature?.handle(message)
} }
private fun respond(author: MessageAuthor, response: String) = fun matches(msg: String) = this.matchType.matches(msg, this)
response.replace(AUTHOR_PLACEHOLDER, author.mention())
private fun respond(author: MessageAuthor, response: String) = response.doIf({ it.contains(AUTHOR_PLACEHOLDER) }) {
it.replace(AUTHOR_PLACEHOLDER, MessageUtil.mention(author))
}
} }
@Suppress("unused") enum class MatchType {
enum class MatchType(val matches: (String, Command) -> Boolean) { PREFIX {
PREFIX({ message, command -> message.startsWith(command.trigger, ignoreCase = true) }), override fun matches(message: String, command: Command) = message.startsWith(command.trigger)
CONTAINS({ message, command -> message.contains(command.trigger, ignoreCase = true) }), },
REGEX({ message, command -> command.regex!!.matches(message) }), CONTAINS {
override fun matches(message: String, command: Command) = message.contains(command.trigger)
},
REGEX {
override fun matches(message: String, command: Command) = command.regex!!.matches(message)
};
abstract fun matches(message: String, command: Command): Boolean
} }

View File

@ -1,18 +1,15 @@
package moe.kageru.kagebot.command package moe.kageru.kagebot.command
import com.fasterxml.jackson.annotation.JsonProperty import moe.kageru.kagebot.Log.log
import moe.kageru.kagebot.Log import moe.kageru.kagebot.MessageUtil
import moe.kageru.kagebot.MessageUtil.sendEmbed
import moe.kageru.kagebot.config.Config import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.config.LocalizationSpec import moe.kageru.kagebot.config.RawMessageActions
import org.javacord.api.event.message.MessageCreateEvent import org.javacord.api.event.message.MessageCreateEvent
class MessageActions( class MessageActions(rawActions: RawMessageActions) {
private val delete: Boolean = false, private val delete: Boolean = rawActions.delete
private val redirect: MessageRedirect?, private val redirect: MessageRedirect? = rawActions.redirect?.let(::MessageRedirect)
@JsonProperty("assign") private val assignment: RoleAssignment? = rawActions.assign?.let(::RoleAssignment)
private val assignment: RoleAssignment?,
) {
fun run(message: MessageCreateEvent, command: Command) { fun run(message: MessageCreateEvent, command: Command) {
if (delete) { if (delete) {
@ -26,13 +23,15 @@ class MessageActions(
if (message.message.canYouDelete()) { if (message.message.canYouDelete()) {
message.deleteMessage() message.deleteMessage()
message.messageAuthor.asUser().ifPresent { user -> message.messageAuthor.asUser().ifPresent { user ->
user.sendEmbed { MessageUtil.sendEmbed(
addField("__Blacklisted__", Config.localization[LocalizationSpec.messageDeleted]) user,
addField("Original:", "${message.readableMessageContent}") MessageUtil.getEmbedBuilder()
} .addField("Blacklisted", Config.localization.messageDeleted)
.addField("Original:", "${message.readableMessageContent}")
)
} }
} else { } else {
Log.info("Tried to delete a message without the necessary permissions. Channel: ${message.channel.id}") log.info("Tried to delete a message without the necessary permissions. Channel: ${message.channel.id}")
} }
} }
} }

View File

@ -1,28 +1,29 @@
package moe.kageru.kagebot.command package moe.kageru.kagebot.command
import moe.kageru.kagebot.Log import moe.kageru.kagebot.Log.log
import moe.kageru.kagebot.MessageUtil import moe.kageru.kagebot.MessageUtil
import moe.kageru.kagebot.Util import moe.kageru.kagebot.Util
import moe.kageru.kagebot.Util.applyIf
import moe.kageru.kagebot.Util.asOption
import moe.kageru.kagebot.config.Config import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.config.LocalizationSpec import moe.kageru.kagebot.config.RawRedirect
import moe.kageru.kagebot.extensions.unwrap
import org.javacord.api.entity.channel.TextChannel import org.javacord.api.entity.channel.TextChannel
import org.javacord.api.event.message.MessageCreateEvent import org.javacord.api.event.message.MessageCreateEvent
class MessageRedirect(target: String, private val anonymous: Boolean = false) { internal class MessageRedirect(rawRedirect: RawRedirect) {
private val targetChannel: TextChannel = Util.findChannel(target).unwrap() private val target: TextChannel = rawRedirect.target?.let(Util::findChannel)
?: throw IllegalArgumentException("Every redirect needs to have a target.")
private val anonymous: Boolean = rawRedirect.anonymous
fun execute(message: MessageCreateEvent, command: Command) { fun execute(message: MessageCreateEvent, command: Command) {
val embed = MessageUtil.withEmbed { val embed = MessageUtil.getEmbedBuilder()
val redirectedText = message.readableMessageContent .addField(
.applyIf(command.matchType == MatchType.PREFIX) { content -> Config.localization.redirectedMessage,
content.removePrefix(command.trigger).trim() message.readableMessageContent.let { content ->
when (command.matchType) {
MatchType.PREFIX -> content.removePrefix(command.trigger).trim()
else -> content
} }
addField(Config.localization[LocalizationSpec.redirectedMessage], redirectedText)
Log.info("Redirected message: $redirectedText")
} }
)
// No inlined if/else because the types are different. // No inlined if/else because the types are different.
// Passing the full message author will also include the avatar in the embed. // Passing the full message author will also include the avatar in the embed.
embed.apply { embed.apply {
@ -33,8 +34,8 @@ class MessageRedirect(target: String, private val anonymous: Boolean = false) {
} }
} }
if (MessageUtil.sendEmbed(targetChannel, embed).asOption().isEmpty()) { if (!Util.wasSuccessful(MessageUtil.sendEmbed(target, embed))) {
Log.warn("Could not redirect message to channel $targetChannel") log.warning("Could not redirect message to channel $target")
} }
} }
} }

View File

@ -1,23 +1,34 @@
package moe.kageru.kagebot.command package moe.kageru.kagebot.command
import arrow.core.Option
import arrow.core.toOption
import moe.kageru.kagebot.Util import moe.kageru.kagebot.Util
import moe.kageru.kagebot.config.RawPermissions
import org.javacord.api.entity.permission.Role
import org.javacord.api.event.message.MessageCreateEvent import org.javacord.api.event.message.MessageCreateEvent
class Permissions( class Permissions(perms: RawPermissions) {
hasOneOf: List<String>?, private val hasOneOf: Set<Role>?
hasNoneOf: List<String>?, private val hasNoneOf: Set<Role>?
private val onlyDM: Boolean = false, private val onlyDM: Boolean
) {
private val hasOneOf: Option<Set<String>> = hasOneOf?.toSet().toOption()
private val hasNoneOf: Option<Set<String>> = hasNoneOf?.toSet().toOption()
fun isAllowed(message: MessageCreateEvent): Boolean = when { init {
message.messageAuthor.isBotOwner -> true hasOneOf = perms.hasOneOf?.mapTo(mutableSetOf(), Util::findRole)
onlyDM && !message.isPrivateMessage -> false hasNoneOf = perms.hasNoneOf?.mapTo(mutableSetOf(), Util::findRole)
// returns true if the Option is empty (case for no restrictions) onlyDM = perms.onlyDM
else -> hasOneOf.forall { Util.hasOneOf(message.messageAuthor, it) } && }
hasNoneOf.forall { !Util.hasOneOf(message.messageAuthor, it) }
fun isAllowed(message: MessageCreateEvent): Boolean {
if (message.messageAuthor.isBotOwner) {
return true
}
if (onlyDM && !message.isPrivateMessage) {
return false
}
hasOneOf?.let { roles ->
if (!Util.hasOneOf(message.messageAuthor, roles)) return false
}
hasNoneOf?.let { roles ->
if (Util.hasOneOf(message.messageAuthor, roles)) return false
}
return true
} }
} }

View File

@ -1,17 +1,17 @@
package moe.kageru.kagebot.command package moe.kageru.kagebot.command
import com.fasterxml.jackson.annotation.JsonProperty import moe.kageru.kagebot.Log.log
import moe.kageru.kagebot.Log
import moe.kageru.kagebot.Util import moe.kageru.kagebot.Util
import moe.kageru.kagebot.extensions.getUser import moe.kageru.kagebot.config.RawAssignment
import moe.kageru.kagebot.extensions.unwrap
import org.javacord.api.event.message.MessageCreateEvent import org.javacord.api.event.message.MessageCreateEvent
class RoleAssignment(@JsonProperty("role") role: String) { internal class RoleAssignment(rawAssignment: RawAssignment) {
private val role = Util.findRole(role).unwrap() private val role = rawAssignment.role?.let { idOrName ->
Util.findRole(idOrName)
} ?: throw IllegalArgumentException("Can’t find role “${rawAssignment.role}")
fun assign(message: MessageCreateEvent) = message.getUser().fold( fun assign(message: MessageCreateEvent) {
{ Log.warn("Could not find user ${message.messageAuthor.name} for role assign") }, Util.userFromMessage(message)?.addRole(role, "Requested via command.")
{ it.addRole(role, "Requested via command.") }, ?: log.warning("Could not find user ${message.messageAuthor.name} for role assign")
) }
} }

View File

@ -1,26 +1,13 @@
package moe.kageru.kagebot.config package moe.kageru.kagebot.config
import arrow.core.ListK
import arrow.core.k
import com.uchuhimo.konf.Config
import com.uchuhimo.konf.source.toml
import moe.kageru.kagebot.command.Command import moe.kageru.kagebot.command.Command
import moe.kageru.kagebot.features.Features import moe.kageru.kagebot.features.Features
import org.javacord.api.entity.server.Server import org.javacord.api.entity.server.Server
object Config { object Config {
val systemSpec = Config { addSpec(SystemSpec) }.from.toml
val localeSpec = Config { addSpec(LocalizationSpec) }.from.toml
val commandSpec = Config { addSpec(CommandSpec) }.from.toml
val featureSpec = Config { addSpec(FeatureSpec) }.from.toml
lateinit var system: Config
lateinit var localization: Config
lateinit var commandConfig: Config
lateinit var featureConfig: Config
lateinit var server: Server lateinit var server: Server
lateinit var commands: List<Command>
// for easier access lateinit var systemConfig: SystemConfig
val features: Features get() = featureConfig[FeatureSpec.features] lateinit var features: Features
val commands: ListK<Command> get() = commandConfig[CommandSpec.command].k() lateinit var localization: Localization
} }

View File

@ -1,34 +1,54 @@
package moe.kageru.kagebot.config package moe.kageru.kagebot.config
import arrow.core.Either
import kotlinx.coroutines.runBlocking
import moe.kageru.kagebot.Globals import moe.kageru.kagebot.Globals
import moe.kageru.kagebot.config.SystemSpec.serverId import moe.kageru.kagebot.command.Command
import moe.kageru.kagebot.features.Features
import java.awt.Color
import java.io.File import java.io.File
object ConfigParser { object ConfigParser {
internal const val DEFAULT_CONFIG_PATH = "config.toml" val configFile: File = File(RawConfig.DEFAULT_CONFIG_PATH)
val configFile: File = File(DEFAULT_CONFIG_PATH)
fun initialLoad(file: String) = runBlocking { fun initialLoad(rawConfig: RawConfig) {
Either.catch { val systemConfig = rawConfig.system?.let(::SystemConfig)
val configFile = getFile(file) ?: throw IllegalArgumentException("No [system] block in config.")
val config = Config.systemSpec.file(configFile) Config.server = Globals.api.getServerById(systemConfig.serverId).orElseThrow { IllegalArgumentException("Invalid server configured.") }
Config.system = config Config.systemConfig = systemConfig
Config.server = Globals.api.getServerById(config[serverId]) reloadLocalization(rawConfig)
.orElseThrow { IllegalArgumentException("Invalid server configured.") } reloadFeatures(rawConfig)
Config.localization = Config.localeSpec.file(configFile) reloadCommands(rawConfig)
Config.featureConfig = Config.featureSpec.file(configFile) }
Config.commandConfig = Config.commandSpec.file(configFile)
fun reloadLocalization(rawConfig: RawConfig) {
Config.localization = rawConfig.localization?.let(::Localization)
?: throw IllegalArgumentException("No [localization] block in config.")
}
fun reloadCommands(rawConfig: RawConfig) {
Config.commands = rawConfig.commands?.map(::Command)?.toMutableList()
?: throw IllegalArgumentException("No commands found in config.")
}
fun reloadFeatures(rawConfig: RawConfig) {
Config.features = rawConfig.features?.let(::Features)
?: Features(RawFeatures(null))
} }
} }
private fun getFile(path: String): File { class SystemConfig(val serverId: String, val color: Color) {
val file = File(path) constructor(rawSystemConfig: RawSystemConfig) : this(
if (file.isFile) { rawSystemConfig.serverId ?: throw IllegalArgumentException("No [system.server] defined."),
return file Color.decode(rawSystemConfig.color ?: "#1793d0")
} )
println("Config not found, falling back to defaults...")
return File(this::class.java.classLoader.getResource(path)!!.toURI())
} }
class Localization(val permissionDenied: String, val redirectedMessage: String, val messageDeleted: String) {
constructor(rawLocalization: RawLocalization) : this(
permissionDenied = rawLocalization.permissionDenied
?: throw IllegalArgumentException("No [localization.permissionDenied] defined"),
redirectedMessage = rawLocalization.redirectedMessage
?: throw IllegalArgumentException("No [localization.redirectMessage] defined"),
messageDeleted = rawLocalization.messageDeleted
?: throw IllegalArgumentException("No [localization.messageDeleted] defined")
)
} }

View File

@ -1,27 +0,0 @@
package moe.kageru.kagebot.config
import com.uchuhimo.konf.ConfigSpec
import moe.kageru.kagebot.command.Command
import moe.kageru.kagebot.config.Config.system
import moe.kageru.kagebot.features.Features
import java.awt.Color
object SystemSpec : ConfigSpec() {
private val rawColor by optional("#1793d0", name = "color")
val serverId by required<String>()
val color by kotlin.lazy { Color.decode(system[rawColor])!! }
}
object LocalizationSpec : ConfigSpec() {
val redirectedMessage by optional("says")
val messageDeleted by optional("Your message was deleted.")
val timeout by optional("You have been timed out for @@ minutes.")
}
object CommandSpec : ConfigSpec(prefix = "") {
val command by optional(emptyList<Command>())
}
object FeatureSpec : ConfigSpec(prefix = "") {
val features by optional(Features(), name = "feature")
}

View File

@ -0,0 +1,19 @@
package moe.kageru.kagebot.config
import com.google.gson.annotations.SerializedName
class RawCommand(
val trigger: String?,
val response: String?,
val matchType: String?,
val permissions: RawPermissions?,
@SerializedName("action")
val actions: RawMessageActions?,
val embed: List<String>?,
val feature: String?
)
class RawPermissions(val hasOneOf: List<String>?, val hasNoneOf: List<String>?, val onlyDM: Boolean)
class RawMessageActions(val delete: Boolean, val redirect: RawRedirect?, val assign: RawAssignment?)
class RawRedirect(val target: String?, val anonymous: Boolean)
class RawAssignment(var role: String?)

View File

@ -0,0 +1,35 @@
package moe.kageru.kagebot.config
import com.google.gson.annotations.SerializedName
import com.moandjiezana.toml.Toml
import java.io.File
class RawConfig(
val system: RawSystemConfig?,
val localization: RawLocalization?,
@SerializedName("command")
val commands: List<RawCommand>?,
@SerializedName("feature")
val features: RawFeatures?
) {
companion object {
const val DEFAULT_CONFIG_PATH = "config.toml"
fun readFromString(tomlContent: String): RawConfig = Toml().read(tomlContent).to(RawConfig::class.java)
fun read(path: String = DEFAULT_CONFIG_PATH): RawConfig {
val toml: Toml = Toml().read(run {
val file = File(path)
if (file.isFile) {
return@run file
}
println("Config not found, falling back to defaults...")
File(this::class.java.classLoader.getResource(path)!!.toURI())
})
return toml.to(RawConfig::class.java)
}
}
}
class RawSystemConfig(val serverId: String?, val color: String?)
class RawLocalization(val permissionDenied: String?, val redirectedMessage: String?, val messageDeleted: String?)

View File

@ -0,0 +1,4 @@
package moe.kageru.kagebot.config
class RawFeatures(val welcome: RawWelcomeFeature?)
class RawWelcomeFeature(val content: List<String>?, val fallbackChannel: String?, val fallbackMessage: String?)

View File

@ -1,21 +0,0 @@
package moe.kageru.kagebot.cron
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import moe.kageru.kagebot.config.Config
object CronD {
fun startAll() {
GlobalScope.launch {
minutely()
}
}
private suspend fun minutely() {
while (true) {
Config.features.timeout?.checkAndRelease()
delay(60_000)
}
}
}

View File

@ -1,26 +0,0 @@
package moe.kageru.kagebot.extensions
import arrow.Kind
import arrow.core.Either
import arrow.core.Tuple3
import arrow.core.getOrElse
import arrow.typeclasses.Functor
import moe.kageru.kagebot.Log
fun <L, R> Either<L, R>.on(op: (R) -> Unit): Either<L, R> {
this.map { op(it) }
return this
}
fun <T> Either<*, T>.unwrap(): T = getOrElse {
Log.warn("Attempted to unwrap $this")
error("Attempted to unwrap Either.left")
}
inline fun <A, B, C, A2, F> Tuple3<A, B, C>.mapFirst(AP: Functor<F>, op: (A) -> Kind<F, A2>) = let { (a, b, c) ->
AP.run { op(a).map { Tuple3(it, b, c) } }
}
inline fun <A, B, C, B2, F> Tuple3<A, B, C>.mapSecond(AP: Functor<F>, op: (B) -> Kind<F, B2>) = let { (a, b, c) ->
AP.run { op(b).map { Tuple3(a, it, c) } }
}

View File

@ -1,24 +0,0 @@
package moe.kageru.kagebot.extensions
import arrow.core.ListK
import arrow.core.Option
import arrow.core.k
import moe.kageru.kagebot.Util.asOption
import moe.kageru.kagebot.config.Config
import org.javacord.api.entity.channel.ChannelCategory
import org.javacord.api.entity.channel.ServerTextChannel
import org.javacord.api.entity.permission.Role
import org.javacord.api.entity.server.Server
import org.javacord.api.entity.user.User
import org.javacord.api.event.message.MessageCreateEvent
fun Server.channelById(id: String): Option<ServerTextChannel> = getTextChannelById(id).asOption()
fun Server.channelsByName(name: String): ListK<ServerTextChannel> = getTextChannelsByName(name).k()
fun Server.rolesByName(name: String): ListK<Role> = getRolesByNameIgnoreCase(name).k()
fun Server.membersByName(name: String): ListK<User> = getMembersByName(name).toList().k()
fun Server.memberById(name: Long): Option<User> = getMemberById(name).asOption()
fun Server.categoriesByName(name: String): ListK<ChannelCategory> = getChannelCategoriesByNameIgnoreCase(name).k()
fun MessageCreateEvent.getUser(): Option<User> = Config.server.memberById(messageAuthor.id)
fun User.roles(): ListK<Role> = getRoles(Config.server).k()

View File

@ -9,9 +9,9 @@ import java.lang.management.ManagementFactory
import java.time.Duration import java.time.Duration
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
class DebugFeature : MessageFeature { class DebugFeature : MessageFeature() {
override fun handle(message: MessageCreateEvent) { override fun handleInternal(message: MessageCreateEvent) {
if (message.messageAuthor.isBotOwner) { if (message.messageAuthor.isBotOwner) {
MessageUtil.sendEmbed(message.channel, getPerformanceStats()) MessageUtil.sendEmbed(message.channel, getPerformanceStats())
} }
@ -29,8 +29,8 @@ class DebugFeature : MessageFeature {
"CPU:", "CPU:",
getCpuInfo(osBean), getCpuInfo(osBean),
"System:", "System:",
getOsInfo(), getOsInfo()
), )
) )
} }
@ -44,7 +44,7 @@ class DebugFeature : MessageFeature {
uptime.toDaysPart(), uptime.toDaysPart(),
uptime.toHoursPart(), uptime.toHoursPart(),
uptime.toMinutesPart(), uptime.toMinutesPart(),
uptime.toSecondsPart(), uptime.toSecondsPart()
) )
} }

View File

@ -1,12 +0,0 @@
package moe.kageru.kagebot.features
import org.javacord.api.DiscordApi
import org.javacord.api.event.message.MessageCreateEvent
interface MessageFeature {
fun handle(message: MessageCreateEvent)
}
interface EventFeature {
fun register(api: DiscordApi)
}

View File

@ -1,26 +1,26 @@
package moe.kageru.kagebot.features package moe.kageru.kagebot.features
class Features( import moe.kageru.kagebot.config.RawFeatures
val welcome: WelcomeFeature? = null,
val timeout: TimeoutFeature? = null, class Features(
vc: TempVCFeature = TempVCFeature(null), val welcome: WelcomeFeature?,
) { debug: DebugFeature,
private val debug = DebugFeature() help: HelpFeature,
private val help = HelpFeature() getConfig: GetConfigFeature
private val getConfig = GetConfigFeature() ) {
private val setConfig = SetConfigFeature() constructor(rawFeatures: RawFeatures) : this(
rawFeatures.welcome?.let(::WelcomeFeature),
DebugFeature(),
HelpFeature(),
GetConfigFeature()
)
private val all = listOf(welcome, debug, help, getConfig, setConfig, timeout, vc)
private val featureMap = mapOf( private val featureMap = mapOf(
"help" to help, "help" to help,
"debug" to debug, "debug" to debug,
"welcome" to welcome, "welcome" to welcome,
"getConfig" to getConfig, "getConfig" to getConfig
"setConfig" to setConfig,
"timeout" to timeout,
"vc" to vc,
) )
fun findByString(feature: String) = featureMap[feature] fun findByString(feature: String) = featureMap[feature]
fun eventFeatures() = all.filterIsInstance<EventFeature>()
} }

View File

@ -6,8 +6,8 @@ import org.javacord.api.event.message.MessageCreateEvent
/** /**
* Simple message handler to send the current config file via message attachment. * Simple message handler to send the current config file via message attachment.
*/ */
class GetConfigFeature : MessageFeature { class GetConfigFeature : MessageFeature() {
override fun handle(message: MessageCreateEvent) { override fun handleInternal(message: MessageCreateEvent) {
message.channel.sendMessage(ConfigParser.configFile) message.channel.sendMessage(ConfigParser.configFile)
} }
} }

View File

@ -1,19 +1,21 @@
package moe.kageru.kagebot.features package moe.kageru.kagebot.features
import arrow.core.extensions.listk.functorFilter.filter import moe.kageru.kagebot.MessageUtil
import moe.kageru.kagebot.MessageUtil.sendEmbed
import moe.kageru.kagebot.command.MatchType import moe.kageru.kagebot.command.MatchType
import moe.kageru.kagebot.config.Config import moe.kageru.kagebot.config.Config
import org.javacord.api.event.message.MessageCreateEvent import org.javacord.api.event.message.MessageCreateEvent
class HelpFeature : MessageFeature { class HelpFeature : MessageFeature() {
override fun handle(message: MessageCreateEvent) { override fun handleInternal(message: MessageCreateEvent) {
message.channel.sendEmbed { MessageUtil.sendEmbed(
addField("Commands:", listCommands(message)) message.channel,
} MessageUtil.getEmbedBuilder()
.addField("Commands:", listCommands(message))
)
} }
} }
private fun listCommands(message: MessageCreateEvent) = Config.commands private fun listCommands(message: MessageCreateEvent) = Config.commands
.filter { it.matchType == MatchType.PREFIX && it.isAllowed(message) } .filter { it.matchType == MatchType.PREFIX && it.isAllowed(message) }
.joinToString("\n") { it.trigger } .map { it.trigger }
.joinToString("\n")

View File

@ -0,0 +1,11 @@
package moe.kageru.kagebot.features
import org.javacord.api.event.message.MessageCreateEvent
abstract class MessageFeature {
fun handle(message: MessageCreateEvent) {
handleInternal(message)
}
internal abstract fun handleInternal(message: MessageCreateEvent)
}

View File

@ -1,28 +1,16 @@
package moe.kageru.kagebot.features package moe.kageru.kagebot.features
import moe.kageru.kagebot.MessageUtil.sendEmbed
import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.config.ConfigParser
import org.javacord.api.event.message.MessageCreateEvent import org.javacord.api.event.message.MessageCreateEvent
class SetConfigFeature : MessageFeature { class SetConfigFeature : MessageFeature() {
@ExperimentalStdlibApi override fun handleInternal(message: MessageCreateEvent) {
override fun handle(message: MessageCreateEvent) {
if (message.messageAttachments.size != 1) { if (message.messageAttachments.size != 1) {
message.channel.sendMessage("Error: please attach the new config to your message.") message.channel.sendMessage("Error: please attach the new config to your message.")
return return
} }
val newConfig = message.messageAttachments[0].url.openStream().readAllBytes().decodeToString() message.messageAttachments[0].let { newConfig ->
try {
Config.localization = Config.localeSpec.string(newConfig) newConfig.url
Config.featureConfig = Config.featureSpec.string(newConfig)
Config.commandConfig = Config.commandSpec.string(newConfig)
ConfigParser.configFile.writeText(newConfig)
message.channel.sendMessage("Config reloaded.")
} catch (e: Exception) {
message.channel.sendEmbed {
addField("Error", "```${e.message}```")
}
} }
} }
} }

View File

@ -1,66 +0,0 @@
package moe.kageru.kagebot.features
import arrow.core.Either
import arrow.core.filterOrElse
import arrow.core.flatMap
import arrow.core.rightIfNotNull
import com.fasterxml.jackson.annotation.JsonProperty
import moe.kageru.kagebot.Log
import moe.kageru.kagebot.Util.asOption
import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.extensions.categoriesByName
import moe.kageru.kagebot.persistence.Dao
import org.javacord.api.DiscordApi
import org.javacord.api.entity.channel.ChannelCategory
import org.javacord.api.entity.channel.ServerVoiceChannel
import org.javacord.api.event.message.MessageCreateEvent
class TempVCFeature(@JsonProperty("category") category: String? = null) : EventFeature, MessageFeature {
private val category: ChannelCategory? = category?.let { Config.server.categoriesByName(it).first() }
override fun handle(message: MessageCreateEvent): Unit = with(message) {
Either.cond(
' ' in readableMessageContent,
{ readableMessageContent.split(' ', limit = 2).last() },
{ "Invalid syntax, expected `<command> <userlimit>`" },
)
.flatMap { limit ->
limit.toIntOrNull().rightIfNotNull { "Invalid syntax, expected a number as limit, got $limit" }
}.filterOrElse({ it < 99 }, { "Error: can’t create a channel with that many users." })
.fold(
{ err -> channel.sendMessage(err) },
{ limit ->
createChannel(message, limit)
channel.sendMessage("Done")
},
)
}
override fun register(api: DiscordApi) {
api.addServerVoiceChannelMemberLeaveListener { event ->
if (event.channel.connectedUsers.isEmpty() && Dao.isTemporaryVC(event.channel.idAsString)) {
deleteChannel(event.channel)
}
}
}
private fun deleteChannel(channel: ServerVoiceChannel) =
channel.delete("Empty temporary channel").asOption().fold(
{ Log.warn("Attempted to delete temporary VC without the necessary permissions") },
{ Dao.removeTemporaryVC(channel.idAsString) },
)
private fun createChannel(message: MessageCreateEvent, limit: Int): Unit =
Config.server.createVoiceChannelBuilder().apply {
setUserlimit(limit)
setName(generateChannelName(message))
setAuditLogReason("Created temporary VC for user ${message.messageAuthor.discriminatedName}")
setCategory(category)
}.create().asOption().fold(
{ Log.warn("Attempted to create temporary VC without the necessary permissions") },
{ channel -> Dao.addTemporaryVC(channel.idAsString) },
)
private fun generateChannelName(message: MessageCreateEvent): String =
"${message.messageAuthor.name}’s volatile corner"
}

View File

@ -1,80 +0,0 @@
package moe.kageru.kagebot.features
import arrow.core.*
import arrow.core.extensions.either.applicative.applicative
import arrow.core.extensions.either.monad.flatMap
import arrow.core.extensions.list.monad.map
import arrow.core.extensions.listk.functorFilter.filter
import arrow.core.extensions.option.applicative.applicative
import arrow.syntax.collections.destructured
import com.fasterxml.jackson.annotation.JsonProperty
import moe.kageru.kagebot.Log
import moe.kageru.kagebot.MessageUtil.sendEmbed
import moe.kageru.kagebot.Util.findRole
import moe.kageru.kagebot.Util.findUser
import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.config.LocalizationSpec
import moe.kageru.kagebot.extensions.*
import moe.kageru.kagebot.persistence.Dao
import org.javacord.api.entity.permission.Role
import org.javacord.api.entity.user.User
import org.javacord.api.event.message.MessageCreateEvent
import java.time.Duration
import java.time.Instant
class TimeoutFeature(@JsonProperty("role") role: String) : MessageFeature {
private val timeoutRole: Role = findRole(role).unwrap()
override fun handle(message: MessageCreateEvent) {
message.readableMessageContent.split(' ', limit = 4).let { args ->
Either.cond(
args.size >= 3,
{ Tuple3(args[1], args[2], args.getOrNull(3)) },
{ "Error: expected “<command> <user> <time> [<reason>]”. If the name contains spaces, please use the user ID instead." },
).flatMap {
it.mapFirst(Option.applicative(), ::findUser).fix()
.toEither { "Error: User ${it.a} not found, consider using the user ID" }
}.flatMap {
it.mapSecond(Either.applicative()) { time ->
time.toLongOrNull().rightIfNotNull { "Error: malformed time “${it.b}" }
}.fix()
}.on { (user, time, _) ->
applyTimeout(user, time)
}.fold(
{ error -> message.channel.sendMessage(error) },
{ (user, time, reason) ->
user.sendEmbed {
addField("Timeout", Config.localization[LocalizationSpec.timeout].replace("@@", "$time"))
reason?.let { addField("Reason", it) }
}
},
)
}
}
private fun applyTimeout(user: User, time: Long) {
val oldRoles = user.roles()
.filter { !it.isManaged }
.onEach { user.removeRole(it) }
.map { it.id }
user.addRole(timeoutRole)
val releaseTime = Instant.now().plus(Duration.ofMinutes(time)).epochSecond
Dao.saveTimeout(releaseTime, user.id, oldRoles)
Log.info("Removed roles ${oldRoles.joinToString()} from user ${user.discriminatedName}")
}
fun checkAndRelease(): Unit = Dao.getAllTimeouts()
.filter { releaseTime -> Instant.now().epochSecond > releaseTime }
.map { Dao.deleteTimeout(it) }
.map { it.destructured() }
.forEach { (userId, roleIds) ->
Config.server.memberById(userId).fold(
{ Log.warn("Tried to free user $userId, but couldn’t find them on the server anymore") },
{ user ->
roleIds.forEach { findRole("$it").map(user::addRole) }
user.removeRole(timeoutRole)
Log.info("Lifted timeout from user ${user.discriminatedName}. Stored roles ${roleIds.joinToString()}")
},
)
}
}

View File

@ -1,53 +1,27 @@
package moe.kageru.kagebot.features package moe.kageru.kagebot.features
import moe.kageru.kagebot.Log
import moe.kageru.kagebot.MessageUtil import moe.kageru.kagebot.MessageUtil
import moe.kageru.kagebot.Util import moe.kageru.kagebot.Util
import moe.kageru.kagebot.Util.asOption import moe.kageru.kagebot.config.RawWelcomeFeature
import moe.kageru.kagebot.Util.checked
import moe.kageru.kagebot.extensions.unwrap
import org.javacord.api.DiscordApi
import org.javacord.api.entity.channel.TextChannel import org.javacord.api.entity.channel.TextChannel
import org.javacord.api.entity.message.embed.EmbedBuilder import org.javacord.api.entity.message.embed.EmbedBuilder
import org.javacord.api.event.message.MessageCreateEvent import org.javacord.api.event.message.MessageCreateEvent
import org.javacord.api.event.server.member.ServerMemberJoinEvent
class WelcomeFeature( class WelcomeFeature(rawWelcome: RawWelcomeFeature) : MessageFeature() {
content: List<String>?, override fun handleInternal(message: MessageCreateEvent) {
fallbackChannel: String?,
private val fallbackMessage: String?,
) : MessageFeature, EventFeature {
val embed: EmbedBuilder? by lazy { content?.let(MessageUtil::listToEmbed) }
override fun register(api: DiscordApi) {
api.addServerMemberJoinListener { event ->
checked { welcomeUser(event) }
}
}
fun welcomeUser(event: ServerMemberJoinEvent) {
Log.info("User ${event.user.discriminatedName} joined")
val message = event.user.sendMessage(embed)
// If the user disabled direct messages, try the fallback (if defined)
if (message.asOption().isEmpty() && hasFallback()) {
fallbackChannel!!.sendMessage(
fallbackMessage!!.replace("@@", event.user.mentionTag),
)
}
}
override fun handle(message: MessageCreateEvent) {
embed?.let { embed?.let {
MessageUtil.sendEmbed(message.channel, it) MessageUtil.sendEmbed(message.channel, embed!!)
} ?: Log.info("Welcome command was triggered, but no welcome embed defined.") }
} }
private fun hasFallback(): Boolean = fallbackChannel != null && fallbackMessage != null val embed: EmbedBuilder? by lazy {
rawWelcome.content?.let(MessageUtil::listToEmbed)
private val fallbackChannel: TextChannel? = fallbackChannel?.let { channel ->
requireNotNull(fallbackMessage) {
"[feature.welcome.fallbackMessage] must not be null if fallbackChannel is defined"
} }
Util.findChannel(channel).unwrap() val fallbackChannel: TextChannel? = rawWelcome.fallbackChannel?.let {
if (rawWelcome.fallbackMessage == null) {
throw IllegalArgumentException("[feature.welcome.fallbackMessage] must not be null if fallbackChannel is defined")
} }
Util.findChannel(it)
}
val fallbackMessage: String? = rawWelcome.fallbackMessage
} }

View File

@ -1,44 +0,0 @@
package moe.kageru.kagebot.persistence
import arrow.core.k
import org.mapdb.DBMaker
import org.mapdb.Serializer
object Dao {
private val db = DBMaker.fileDB("kagebot.db").fileMmapEnable().transactionEnable().make()
private val prisoners = db.hashMap("timeout", Serializer.LONG, Serializer.LONG_ARRAY).createOrOpen()
private val commands = db.hashMap("commands", Serializer.STRING, Serializer.INTEGER).createOrOpen()
private val tempVcs = db.hashSet("vcs", Serializer.STRING).createOrOpen()
fun saveTimeout(releaseTime: Long, user: Long, roles: List<Long>) {
prisoners[releaseTime] = (listOf(user) + roles).toLongArray()
}
fun setCommandCounter(count: Int) {
commands["total"] = count
}
fun getCommandCounter() = commands["total"] ?: 0
fun close() = db.close()
fun getAllTimeouts() = prisoners.keys.k()
fun deleteTimeout(releaseTime: Long): List<Long> {
val timeout = prisoners[releaseTime]!!
prisoners.remove(releaseTime)
return timeout.toList()
}
fun isTemporaryVC(channel: String): Boolean {
return channel in tempVcs
}
fun addTemporaryVC(channel: String) {
tempVcs.add(channel)
}
fun removeTemporaryVC(channel: String) {
tempVcs.remove(channel)
}
}

View File

@ -8,8 +8,6 @@ permissionDenied = "You do not have permission to use this command."
# results in <name> says <message> # results in <name> says <message>
redirectedMessage = "says" redirectedMessage = "says"
messageDeleted = "Your message was deleted because it contained a banned word or phrase." messageDeleted = "Your message was deleted because it contained a banned word or phrase."
# @@ will be replaced with the time
timeout = "You have been timed out for @@ minutes"
# If this is enable, every new user will receive a welcome message. # If this is enable, every new user will receive a welcome message.
# If the user has disabled their DMs, the fallbackMessage will be sent in the fallbackChannel instead. # If the user has disabled their DMs, the fallbackMessage will be sent in the fallbackChannel instead.
@ -27,9 +25,6 @@ content = [
"5th", "asdasd" "5th", "asdasd"
] ]
[feature.timeout]
role = "timeout"
[[command]] [[command]]
trigger = "!ping" trigger = "!ping"
response = "pong" response = "pong"
@ -64,15 +59,15 @@ trigger = "!restricted"
response = "access granted" response = "access granted"
[command.permissions] [command.permissions]
hasOneOf = [ hasOneOf = [
"new role", "452034011393425409",
"another new role" "446668543816106004"
] ]
[[command]] [[command]]
trigger = "!almostUnrestricted" trigger = "!almostUnrestricted"
response = "access granted" response = "access granted"
[command.permissions] [command.permissions]
hasNoneOf = ["new role"] hasNoneOf = ["452034011393425409"]
[[command]] [[command]]
trigger = "!private" trigger = "!private"
@ -80,7 +75,7 @@ response = "some long response that you don’t want in public channels"
[command.permissions] [command.permissions]
onlyDM = true onlyDM = true
# redirect every message that starts with !redirect to a channel called “testchannel” # redirect every message that starts with !redirect to channel 555097559023222825
[[command]] [[command]]
trigger = "!redirect" trigger = "!redirect"
response = "redirected" response = "redirected"
@ -92,7 +87,7 @@ target = "testchannel"
trigger = "!anonRedirect" trigger = "!anonRedirect"
response = "redirected" response = "redirected"
[command.action.redirect] [command.action.redirect]
target = "testchannel" target = "555097559023222825"
anonymous = true anonymous = true
[[command]] [[command]]
@ -115,15 +110,3 @@ feature = "help"
[[command]] [[command]]
trigger = "!getConfig" trigger = "!getConfig"
feature = "getConfig" feature = "getConfig"
[[command]]
trigger = "!setConfig"
feature = "setConfig"
[[command]]
trigger = "!prison"
feature = "timeout"
[[command]]
trigger = "!vc"
feature = "vc"

View File

@ -2,42 +2,15 @@ package moe.kageru.kagebot
import io.kotlintest.shouldBe import io.kotlintest.shouldBe
import io.kotlintest.shouldNotBe import io.kotlintest.shouldNotBe
import io.kotlintest.specs.StringSpec import io.kotlintest.specs.ShouldSpec
import io.mockk.every
import io.mockk.mockk
import moe.kageru.kagebot.config.Config import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.config.LocalizationSpec
import moe.kageru.kagebot.config.SystemSpec
import moe.kageru.kagebot.features.SetConfigFeature
import java.awt.Color
@ExperimentalStdlibApi class ConfigTest : ShouldSpec({
class ConfigTest : StringSpec() {
init {
"should properly parse test config" {
TestUtil.prepareTestEnvironment() TestUtil.prepareTestEnvironment()
Config.system[SystemSpec.serverId] shouldNotBe null "should properly parse test config" {
SystemSpec.color shouldBe Color.decode("#1793d0") Config.systemConfig shouldNotBe null
Config.features.welcome!!.embed shouldNotBe null Config.localization shouldNotBe null
Config.commands.size shouldBe 3 Config.features shouldNotBe null
} Config.commands.size shouldBe 2
"should parse test config via command" {
val redir = "says"
val testConfig = """
[localization]
redirectedMessage = "$redir"
messageDeleted = "dongered"
timeout = "timeout"
""".trimIndent()
val message = TestUtil.mockMessage("anything")
every { message.messageAttachments } returns listOf(
mockk {
every { url.openStream().readAllBytes() } returns testConfig.toByteArray()
},
)
SetConfigFeature().handle(message)
Config.localization[LocalizationSpec.redirectedMessage] shouldBe redir
}
}
} }
})

View File

@ -1,7 +1,5 @@
package moe.kageru.kagebot package moe.kageru.kagebot
import arrow.core.ListK
import arrow.core.Option
import io.kotlintest.matchers.string.shouldContain import io.kotlintest.matchers.string.shouldContain
import io.kotlintest.matchers.string.shouldNotContain import io.kotlintest.matchers.string.shouldNotContain
import io.kotlintest.shouldBe import io.kotlintest.shouldBe
@ -9,13 +7,12 @@ import io.mockk.Runs
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import moe.kageru.kagebot.Kagebot.process
import moe.kageru.kagebot.config.Config import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.config.ConfigParser import moe.kageru.kagebot.config.ConfigParser
import moe.kageru.kagebot.extensions.* import moe.kageru.kagebot.config.RawConfig
import org.javacord.api.DiscordApi
import org.javacord.api.entity.channel.ServerTextChannel import org.javacord.api.entity.channel.ServerTextChannel
import org.javacord.api.entity.message.embed.EmbedBuilder import org.javacord.api.entity.message.embed.EmbedBuilder
import org.javacord.api.entity.permission.Role
import org.javacord.api.entity.user.User import org.javacord.api.entity.user.User
import org.javacord.api.event.message.MessageCreateEvent import org.javacord.api.event.message.MessageCreateEvent
import org.javacord.core.entity.message.embed.EmbedBuilderDelegateImpl import org.javacord.core.entity.message.embed.EmbedBuilderDelegateImpl
@ -23,34 +20,19 @@ import java.io.File
import java.util.* import java.util.*
object TestUtil { object TestUtil {
private val TIMEOUT_ROLE = mockk<Role> {
every { id } returns 123
}
val TEST_ROLE: Role = mockk {
every { id } returns 1
every { isManaged } returns false
every { name } returns "testrole"
}
fun mockMessage( fun mockMessage(
content: String, content: String,
replies: MutableList<String> = mutableListOf(), replies: MutableList<String> = mutableListOf(),
replyEmbeds: MutableList<EmbedBuilder> = mutableListOf(), replyEmbeds: MutableList<EmbedBuilder> = mutableListOf(),
files: MutableList<File> = mutableListOf(), files: MutableList<File> = mutableListOf(),
isBot: Boolean = false, isBot: Boolean = false
): MessageCreateEvent { ): MessageCreateEvent {
return mockk { return mockk {
every { messageContent } returns content every { messageContent } returns content
every { readableMessageContent } returns content every { readableMessageContent } returns content
every { channel.sendMessage(capture(replies)) } returns mockk(relaxed = true) { every { channel.sendMessage(capture(replies)) } returns mockk()
every { isCompletedExceptionally } returns false every { channel.sendMessage(capture(replyEmbeds)) } returns mockk()
} every { channel.sendMessage(capture(files)) } returns mockk()
every { channel.sendMessage(capture(replyEmbeds)) } returns mockk(relaxed = true) {
every { isCompletedExceptionally } returns false
}
every { channel.sendMessage(capture(files)) } returns mockk(relaxed = true) {
every { isCompletedExceptionally } returns false
}
every { message.canYouDelete() } returns true every { message.canYouDelete() } returns true
every { isPrivateMessage } returns false every { isPrivateMessage } returns false
// We can’t use a nested mock here because other fields of messageAuthor might // We can’t use a nested mock here because other fields of messageAuthor might
@ -60,65 +42,48 @@ object TestUtil {
every { messageAuthor.isBotUser } returns isBot every { messageAuthor.isBotUser } returns isBot
every { messageAuthor.isYourself } returns isBot every { messageAuthor.isYourself } returns isBot
every { messageAuthor.isBotOwner } returns false every { messageAuthor.isBotOwner } returns false
every { messageAuthor.asUser() } returns Optional.of(messageableAuthor(replyEmbeds)) every { messageAuthor.asUser() } returns Optional.of(messageableAuthor())
every { messageAuthor.name } returns "kageru"
} }
} }
fun messageableAuthor(messages: MutableList<EmbedBuilder> = mutableListOf()): User { fun messageableAuthor(messages: MutableList<EmbedBuilder> = mutableListOf()): User {
return mockk { return mockk {
every { roles() } returns ListK.empty() every { getRoles(any()) } returns emptyList()
every { sendMessage(capture(messages)) } returns mockk(relaxed = true) every { sendMessage(capture(messages)) } returns mockk()
} }
} }
fun prepareTestEnvironment( fun prepareTestEnvironment(
sentEmbeds: MutableList<EmbedBuilder> = mutableListOf(), sentEmbeds: MutableList<EmbedBuilder> = mutableListOf(),
sentMessages: MutableList<String> = mutableListOf(), sentMessages: MutableList<String> = mutableListOf()
dmEmbeds: MutableList<EmbedBuilder> = mutableListOf(),
) { ) {
val channel = mockk<ServerTextChannel>(relaxed = true) { val channel = mockk<Optional<ServerTextChannel>> {
every { sendMessage(capture(sentEmbeds)) } returns mockk(relaxed = true) { every { isPresent } returns true
every { join() } returns mockk { every { get() } returns mockk {
every { sendMessage(capture(sentEmbeds)) } returns mockk {
every { join() } returns mockk()
every { isCompletedExceptionally } returns false every { isCompletedExceptionally } returns false
} }
every { isCompletedExceptionally } returns false every { sendMessage(capture(sentMessages)) } returns mockk()
} }
every { sendMessage(capture(sentMessages)) } returns mockk(relaxed = true)
} }
// mockk tries to access Config.server in the mocking block below, so we need to provide some kind of value val api = mockk<DiscordApi> {
Config.server = mockk() every { getServerById(any<String>()) } returns Optional.of(mockk {
Config.server = mockk(relaxed = true) {
every { icon.ifPresent(any()) } just Runs every { icon.ifPresent(any()) } just Runs
every { channelById(any()) } returns Option.just(channel) every { getTextChannelById(any<String>()) } returns channel
every { channelsByName(any()) } returns ListK.just(channel) every { getTextChannelsByName(any()) } returns listOf(channel.get())
every { rolesByName("testrole") } returns ListK.just(TEST_ROLE) every { getRolesByNameIgnoreCase("testrole") } returns listOf(mockk {
every { rolesByName("timeout") } returns ListK.just(TIMEOUT_ROLE) every { id } returns 1
every { categoriesByName(any()) } returns ListK.just(mockk()) })
every { createVoiceChannelBuilder().create() } returns mockk { })
every { isCompletedExceptionally } returns false
every { join().idAsString } returns "12345"
} }
every { getMembersByName(any()) } returns setOf( Globals.api = api
mockk(relaxed = true) { ConfigParser.initialLoad(RawConfig.read("testconfig.toml"))
every { id } returns 123
every { roles() } returns ListK.just(TEST_ROLE)
every { getRoles(any()) } returns ListK.just(TEST_ROLE)
every { sendMessage(capture(dmEmbeds)) } returns mockk(relaxed = true) {
every { isCompletedExceptionally } returns false
}
},
)
}
Globals.api = mockk(relaxed = true) {
every { getServerById(any<String>()) } returns Optional.of(Config.server)
}
ConfigParser.initialLoad("testconfig.toml")
} }
fun testMessageSuccess(content: String, result: String) { fun testMessageSuccess(content: String, result: String) {
val calls = mutableListOf<String>() val calls = mutableListOf<String>()
mockMessage(content, replies = calls).process() Kagebot.processMessage(mockMessage(content, replies = calls))
calls shouldBe mutableListOf(result) calls shouldBe mutableListOf(result)
} }
@ -126,17 +91,26 @@ object TestUtil {
return (embed.delegate as EmbedBuilderDelegateImpl).toJsonNode().toString() return (embed.delegate as EmbedBuilderDelegateImpl).toJsonNode().toString()
} }
fun withCommands(config: String, test: (() -> Unit)) { fun <R> withCommands(config: String, test: (() -> R)) {
val oldCmds = Config.commandConfig val oldCmds = Config.commands
Config.commandConfig = Config.commandSpec.string(config) val rawConfig = RawConfig.readFromString(config)
ConfigParser.reloadCommands(rawConfig)
test() test()
Config.commandConfig = oldCmds Config.commands = oldCmds
}
fun <R> withLocalization(config: String, test: (() -> R)) {
val oldLoc = Config.localization
val rawConfig = RawConfig.readFromString(config)
ConfigParser.reloadLocalization(rawConfig)
test()
Config.localization = oldLoc
} }
fun withReplyContents( fun withReplyContents(
expected: List<String> = emptyList(), expected: List<String> = emptyList(),
unexpected: List<String> = emptyList(), unexpected: List<String> = emptyList(),
op: (MutableList<EmbedBuilder>) -> Unit, op: (MutableList<EmbedBuilder>) -> Unit
) { ) {
val replies = mutableListOf<EmbedBuilder>() val replies = mutableListOf<EmbedBuilder>()
op(replies) op(replies)

View File

@ -1,13 +1,12 @@
package moe.kageru.kagebot.command package moe.kageru.kagebot.command
import arrow.core.ListK
import io.kotlintest.matchers.string.shouldContain import io.kotlintest.matchers.string.shouldContain
import io.kotlintest.shouldBe import io.kotlintest.shouldBe
import io.kotlintest.specs.StringSpec import io.kotlintest.specs.StringSpec
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import moe.kageru.kagebot.Globals import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.Kagebot.process import moe.kageru.kagebot.Kagebot
import moe.kageru.kagebot.TestUtil import moe.kageru.kagebot.TestUtil
import moe.kageru.kagebot.TestUtil.embedToString import moe.kageru.kagebot.TestUtil.embedToString
import moe.kageru.kagebot.TestUtil.messageableAuthor import moe.kageru.kagebot.TestUtil.messageableAuthor
@ -15,12 +14,8 @@ import moe.kageru.kagebot.TestUtil.mockMessage
import moe.kageru.kagebot.TestUtil.prepareTestEnvironment import moe.kageru.kagebot.TestUtil.prepareTestEnvironment
import moe.kageru.kagebot.TestUtil.testMessageSuccess import moe.kageru.kagebot.TestUtil.testMessageSuccess
import moe.kageru.kagebot.TestUtil.withCommands import moe.kageru.kagebot.TestUtil.withCommands
import moe.kageru.kagebot.TestUtil.withLocalization
import moe.kageru.kagebot.Util import moe.kageru.kagebot.Util
import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.extensions.roles
import moe.kageru.kagebot.extensions.rolesByName
import moe.kageru.kagebot.extensions.unwrap
import moe.kageru.kagebot.persistence.Dao
import org.javacord.api.entity.message.embed.EmbedBuilder import org.javacord.api.entity.message.embed.EmbedBuilder
import org.javacord.api.entity.permission.Role import org.javacord.api.entity.permission.Role
import org.javacord.api.entity.user.User import org.javacord.api.entity.user.User
@ -28,26 +23,13 @@ import java.util.*
class CommandTest : StringSpec({ class CommandTest : StringSpec({
prepareTestEnvironment() prepareTestEnvironment()
"should increment command counter" {
withCommands(
"""
[[command]]
trigger = "!ping"
response = "pong"
""".trimIndent(),
) {
val before = Globals.commandCounter.get()
testMessageSuccess("!ping", "pong")
Globals.commandCounter.get() shouldBe (before + 1)
}
}
"should match prefix command" { "should match prefix command" {
withCommands( withCommands(
""" """
[[command]] [[command]]
trigger = "!ping" trigger = "!ping"
response = "pong" response = "pong"
""".trimIndent(), """.trimIndent()
) { ) {
testMessageSuccess("!ping", "pong") testMessageSuccess("!ping", "pong")
} }
@ -62,10 +44,10 @@ class CommandTest : StringSpec({
[[command]] [[command]]
trigger = "!embed" trigger = "!embed"
embed = [ "$heading", "$content" ] embed = [ "$heading", "$content" ]
""".trimIndent(), """.trimIndent()
) { ) {
TestUtil.withReplyContents(expected = listOf(heading, content)) { TestUtil.withReplyContents(expected = listOf(heading, content)) {
mockMessage("!embed", replyEmbeds = it).process() Kagebot.processMessage(mockMessage("!embed", replyEmbeds = it))
} }
} }
} }
@ -76,7 +58,7 @@ class CommandTest : StringSpec({
trigger = "somewhere" trigger = "somewhere"
response = "found it" response = "found it"
matchType = "CONTAINS" matchType = "CONTAINS"
""".trimIndent(), """.trimIndent()
) { ) {
testMessageSuccess("the trigger is somewhere in this message", "found it") testMessageSuccess("the trigger is somewhere in this message", "found it")
} }
@ -88,7 +70,7 @@ class CommandTest : StringSpec({
trigger = "A.+B" trigger = "A.+B"
response = "regex matched" response = "regex matched"
matchType = "REGEX" matchType = "REGEX"
""".trimIndent(), """.trimIndent()
) { ) {
testMessageSuccess("AcsdB", "regex matched") testMessageSuccess("AcsdB", "regex matched")
} }
@ -99,7 +81,7 @@ class CommandTest : StringSpec({
[[command]] [[command]]
trigger = "answer me" trigger = "answer me"
response = "@@ there you go" response = "@@ there you go"
""".trimIndent(), """.trimIndent()
) { ) {
testMessageSuccess("answer me", "<@1> there you go") testMessageSuccess("answer me", "<@1> there you go")
} }
@ -110,10 +92,10 @@ class CommandTest : StringSpec({
[[command]] [[command]]
trigger = "!ping" trigger = "!ping"
response = "pong" response = "pong"
""".trimIndent(), """.trimIndent()
) { ) {
val calls = mutableListOf<String>() val calls = mutableListOf<String>()
mockMessage("!ping", replies = calls, isBot = true).process() Kagebot.processMessage(mockMessage("!ping", replies = calls, isBot = true))
calls shouldBe mutableListOf() calls shouldBe mutableListOf()
} }
} }
@ -124,14 +106,14 @@ class CommandTest : StringSpec({
trigger = "delet this" trigger = "delet this"
[command.action] [command.action]
delete = true delete = true
""".trimIndent(), """.trimIndent()
) { ) {
val messageContent = "delet this" val messageContent = "delet this"
TestUtil.withReplyContents(expected = listOf(messageContent)) { TestUtil.withReplyContents(expected = listOf(messageContent)) {
val mockMessage = mockMessage(messageContent) val mockMessage = mockMessage(messageContent)
every { mockMessage.deleteMessage() } returns mockk() every { mockMessage.deleteMessage() } returns mockk()
every { mockMessage.messageAuthor.asUser() } returns Optional.of(messageableAuthor(it)) every { mockMessage.messageAuthor.asUser() } returns Optional.of(messageableAuthor(it))
mockMessage.process() Kagebot.processMessage(mockMessage)
} }
} }
} }
@ -145,12 +127,24 @@ class CommandTest : StringSpec({
hasOneOf = [ hasOneOf = [
"testrole", "testrole",
] ]
""".trimIndent(), """.trimIndent()
) { ) {
val replies = mutableListOf<String>() val replies = mutableListOf<String>()
val mockMessage = mockMessage("!restricted", replies = replies) val mockMessage = mockMessage("!restricted", replies = replies)
mockMessage.process() Kagebot.processMessage(mockMessage)
replies shouldBe mutableListOf() replies shouldBe mutableListOf(Config.localization.permissionDenied)
withLocalization(
"""
[localization]
permissionDenied = ""
messageDeleted = "whatever"
redirectedMessage = "asdja"
""".trimIndent()
) {
Kagebot.processMessage(mockMessage)
// still one string in there from earlier, nothing new was added
replies.size shouldBe 1
}
} }
} }
"should accept restricted command for owner" { "should accept restricted command for owner" {
@ -163,12 +157,12 @@ class CommandTest : StringSpec({
hasOneOf = [ hasOneOf = [
"testrole" "testrole"
] ]
""".trimIndent(), """.trimIndent()
) { ) {
val calls = mutableListOf<String>() val calls = mutableListOf<String>()
val mockMessage = mockMessage("!restricted", replies = calls) val mockMessage = mockMessage("!restricted", replies = calls)
every { mockMessage.messageAuthor.isBotOwner } returns true every { mockMessage.messageAuthor.isBotOwner } returns true
mockMessage.process() Kagebot.processMessage(mockMessage)
calls shouldBe mutableListOf("access granted") calls shouldBe mutableListOf("access granted")
} }
} }
@ -182,18 +176,16 @@ class CommandTest : StringSpec({
hasOneOf = [ hasOneOf = [
"testrole" "testrole"
] ]
""".trimIndent(), """.trimIndent()
) { ) {
val calls = mutableListOf<String>() val calls = mutableListOf<String>()
val mockMessage = mockMessage("!restricted", replies = calls) val mockMessage = mockMessage("!restricted", replies = calls)
every { mockMessage.messageAuthor.asUser() } returns Optional.of( every { mockMessage.messageAuthor.asUser() } returns Optional.of(mockk {
mockk { every { getRoles(any()) } returns listOf(
every { roles() } returns ListK.just( Config.server.getRolesByNameIgnoreCase("testrole")[0]
Config.server.rolesByName("testrole").first(),
) )
}, })
) Kagebot.processMessage(mockMessage)
mockMessage.process()
calls shouldBe mutableListOf("access granted") calls shouldBe mutableListOf("access granted")
} }
} }
@ -205,7 +197,7 @@ class CommandTest : StringSpec({
response = "access granted" response = "access granted"
[command.permissions] [command.permissions]
hasNoneOf = ["testrole"] hasNoneOf = ["testrole"]
""".trimIndent(), """.trimIndent()
) { ) {
val calls = mutableListOf<String>() val calls = mutableListOf<String>()
val mockMessage = mockMessage("!almostUnrestricted", replies = calls) val mockMessage = mockMessage("!almostUnrestricted", replies = calls)
@ -213,19 +205,18 @@ class CommandTest : StringSpec({
every { mockMessage.messageAuthor.asUser() } returns mockk { every { mockMessage.messageAuthor.asUser() } returns mockk {
every { isPresent } returns true every { isPresent } returns true
every { get().getRoles(any()) } returns listOf( every { get().getRoles(any()) } returns listOf(
Config.server.rolesByName("testrole").first(), Config.server.getRolesByNameIgnoreCase("testrole")[0]
) )
} }
mockMessage.process() Kagebot.processMessage(mockMessage)
// without the role // without the role
every { mockMessage.messageAuthor.asUser() } returns mockk { every { mockMessage.messageAuthor.asUser() } returns mockk {
every { isPresent } returns true every { isPresent } returns true
every { get().getRoles(any()) } returns emptyList() every { get().getRoles(any()) } returns emptyList()
} }
mockMessage.process() Kagebot.processMessage(mockMessage)
// first message didn’t answer anything calls shouldBe mutableListOf(Config.localization.permissionDenied, "access granted")
calls shouldBe mutableListOf("access granted")
} }
} }
"should refuse DM only message in server channel" { "should refuse DM only message in server channel" {
@ -236,11 +227,11 @@ class CommandTest : StringSpec({
response = "access granted" response = "access granted"
[command.permissions] [command.permissions]
onlyDM = true onlyDM = true
""".trimIndent(), """.trimIndent()
) { ) {
val calls = mutableListOf<String>() val calls = mutableListOf<String>()
mockMessage("!dm", replies = calls).process() Kagebot.processMessage(mockMessage("!dm", replies = calls))
calls shouldBe mutableListOf() calls shouldBe listOf(Config.localization.permissionDenied)
} }
} }
/* /*
@ -258,10 +249,10 @@ class CommandTest : StringSpec({
[command.action.redirect] [command.action.redirect]
target = "testchannel" target = "testchannel"
anonymous = true anonymous = true
""".trimIndent(), """.trimIndent()
) { ) {
val message = "this is a message" val message = "this is a message"
mockMessage("!redirect $message").process() Kagebot.processMessage(mockMessage("!redirect $message"))
calls.size shouldBe 1 calls.size shouldBe 1
embedToString(calls[0]) shouldContain "\"$message\"" embedToString(calls[0]) shouldContain "\"$message\""
} }
@ -273,40 +264,15 @@ class CommandTest : StringSpec({
trigger = "!assign" trigger = "!assign"
[command.action.assign] [command.action.assign]
role = "testrole" role = "testrole"
""".trimIndent(), """.trimIndent()
) { ) {
val roles = mutableListOf<Role>() val roles = mutableListOf<Role>()
val user = mockk<User> { val user = mockk<User> {
every { addRole(capture(roles), "Requested via command.") } returns mockk() every { addRole(capture(roles), "Requested via command.") } returns mockk()
} }
every { Config.server.getMemberById(1) } returns Optional.of(user) every { Config.server.getMemberById(1) } returns Optional.of(user)
mockMessage("!assign").process() Kagebot.processMessage(mockMessage("!assign"))
roles shouldBe mutableListOf(Util.findRole("testrole").unwrap()) roles shouldBe mutableListOf(Util.findRole("testrole"))
}
}
"should create VC" {
withCommands(
"""
[[command]]
trigger = "!vc"
feature = "vc"
""".trimIndent(),
) {
testMessageSuccess("!vc 2", "Done")
Dao.isTemporaryVC("12345") shouldBe true
Dao.removeTemporaryVC("12345")
}
}
"should reject invalid vc command" {
withCommands(
"""
[[command]]
trigger = "!vc"
feature = "vc"
""".trimIndent(),
) {
testMessageSuccess("!vc asd", "Invalid syntax, expected a number as limit, got asd")
Dao.isTemporaryVC("12345") shouldBe false
} }
} }
}) })

View File

@ -2,7 +2,7 @@ package moe.kageru.kagebot.features
import io.kotlintest.shouldBe import io.kotlintest.shouldBe
import io.kotlintest.specs.ShouldSpec import io.kotlintest.specs.ShouldSpec
import moe.kageru.kagebot.Kagebot.process import moe.kageru.kagebot.Kagebot
import moe.kageru.kagebot.TestUtil import moe.kageru.kagebot.TestUtil
import moe.kageru.kagebot.TestUtil.mockMessage import moe.kageru.kagebot.TestUtil.mockMessage
import moe.kageru.kagebot.TestUtil.withCommands import moe.kageru.kagebot.TestUtil.withCommands
@ -11,15 +11,13 @@ import java.io.File
class ConfigFeatureTest : ShouldSpec({ class ConfigFeatureTest : ShouldSpec({
TestUtil.prepareTestEnvironment() TestUtil.prepareTestEnvironment()
"getConfig should sent message with attachment" { "getConfig should sent message with attachment" {
withCommands( withCommands("""
"""
[[command]] [[command]]
trigger = "!getConfig" trigger = "!getConfig"
feature = "getConfig" feature = "getConfig"
""".trimIndent(), """.trimIndent()) {
) {
val calls = mutableListOf<File>() val calls = mutableListOf<File>()
mockMessage("!getConfig", files = calls).process() Kagebot.processMessage(mockMessage("!getConfig", files = calls))
calls.size shouldBe 1 calls.size shouldBe 1
} }
} }

View File

@ -4,7 +4,7 @@ import io.kotlintest.specs.StringSpec
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import moe.kageru.kagebot.Kagebot.process import moe.kageru.kagebot.Kagebot
import moe.kageru.kagebot.TestUtil import moe.kageru.kagebot.TestUtil
import org.javacord.api.entity.message.embed.EmbedBuilder import org.javacord.api.entity.message.embed.EmbedBuilder
import org.javacord.api.event.message.MessageCreateEvent import org.javacord.api.event.message.MessageCreateEvent
@ -16,7 +16,7 @@ class DebugFeatureTest : StringSpec({
"should ignore regular users" { "should ignore regular users" {
val message = TestUtil.mockMessage("!debug") val message = TestUtil.mockMessage("!debug")
every { message.messageAuthor.isBotOwner } returns false every { message.messageAuthor.isBotOwner } returns false
message.process() Kagebot.processMessage(message)
DebugFeature().handle(message) DebugFeature().handle(message)
verify(exactly = 0) { message.channel.sendMessage(any<EmbedBuilder>()) } verify(exactly = 0) { message.channel.sendMessage(any<EmbedBuilder>()) }
} }

View File

@ -3,13 +3,12 @@ package moe.kageru.kagebot.features
import io.kotlintest.specs.StringSpec import io.kotlintest.specs.StringSpec
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import moe.kageru.kagebot.Kagebot.process import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.Kagebot
import moe.kageru.kagebot.TestUtil import moe.kageru.kagebot.TestUtil
import moe.kageru.kagebot.TestUtil.mockMessage import moe.kageru.kagebot.TestUtil.mockMessage
import moe.kageru.kagebot.TestUtil.withCommands import moe.kageru.kagebot.TestUtil.withCommands
import moe.kageru.kagebot.TestUtil.withReplyContents import moe.kageru.kagebot.TestUtil.withReplyContents
import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.extensions.rolesByName
import org.javacord.api.entity.message.embed.EmbedBuilder import org.javacord.api.entity.message.embed.EmbedBuilder
import java.util.* import java.util.*
@ -37,7 +36,7 @@ class HelpFeatureTest : StringSpec({
val expected = listOf("!ping", "!something") val expected = listOf("!ping", "!something")
val unexpected = listOf("not a prefix", "!prison") val unexpected = listOf("not a prefix", "!prison")
withReplyContents(expected = expected, unexpected = unexpected) { replies -> withReplyContents(expected = expected, unexpected = unexpected) { replies ->
mockMessage("!help", replyEmbeds = replies).process() Kagebot.processMessage(mockMessage("!help", replyEmbeds = replies))
} }
} }
} }
@ -47,14 +46,12 @@ class HelpFeatureTest : StringSpec({
val unexpected = listOf("not a prefix") val unexpected = listOf("not a prefix")
withReplyContents(expected = expected, unexpected = unexpected) { replies -> withReplyContents(expected = expected, unexpected = unexpected) { replies ->
val message = mockMessage("!help", replyEmbeds = replies) val message = mockMessage("!help", replyEmbeds = replies)
every { message.messageAuthor.asUser() } returns Optional.of( every { message.messageAuthor.asUser() } returns Optional.of(mockk {
mockk {
every { getRoles(any()) } returns listOf( every { getRoles(any()) } returns listOf(
Config.server.rolesByName("testrole").first(), Config.server.getRolesByNameIgnoreCase("testrole")[0]
) )
}, })
) Kagebot.processMessage(message)
message.process()
} }
} }
} }

View File

@ -1,62 +0,0 @@
package moe.kageru.kagebot.features
import io.kotlintest.matchers.string.shouldContain
import io.kotlintest.shouldBe
import io.kotlintest.specs.StringSpec
import moe.kageru.kagebot.Kagebot.process
import moe.kageru.kagebot.TestUtil
import moe.kageru.kagebot.TestUtil.TEST_ROLE
import moe.kageru.kagebot.persistence.Dao
import org.javacord.api.entity.message.embed.EmbedBuilder
class TimeoutFeatureTest : StringSpec({
TestUtil.prepareTestEnvironment()
"should remove and store roles" {
clearTimeouts()
TestUtil.mockMessage("!timeout kageru 99999999").process()
Dao.getAllTimeouts().let {
it.size shouldBe 1
val user = Dao.deleteTimeout(it.first())
user shouldBe arrayOf(123, TEST_ROLE.id)
}
clearTimeouts()
}
"should announce timeout via DM" {
val dms = mutableListOf<EmbedBuilder>()
TestUtil.prepareTestEnvironment(dmEmbeds = dms)
val time = "1235436"
TestUtil.mockMessage("!timeout kageru $time").process()
dms.size shouldBe 1
TestUtil.embedToString(dms[0]) shouldContain time
clearTimeouts()
}
"should return error for invalid input" {
val replies = mutableListOf<String>()
TestUtil.mockMessage("!timeout kageruWithoutATime", replies = replies).process()
replies.size shouldBe 1
replies[0] shouldContain "Error"
}
"should catch malformed time" {
val replies = mutableListOf<String>()
TestUtil.mockMessage("!timeout kageru this is not a time", replies = replies).process()
replies.size shouldBe 1
replies[0] shouldContain "Error"
}
"should print optional reason" {
val dms = mutableListOf<EmbedBuilder>()
TestUtil.prepareTestEnvironment(dmEmbeds = dms)
val reason = "because I don’t like you"
TestUtil.mockMessage("!timeout kageru 1 $reason").process()
dms.size shouldBe 1
TestUtil.embedToString(dms[0]) shouldContain reason
clearTimeouts()
}
}) {
companion object {
private fun clearTimeouts() {
Dao.getAllTimeouts().forEach { to ->
Dao.deleteTimeout(to)
}
}
}
}

View File

@ -4,45 +4,41 @@ import io.kotlintest.shouldBe
import io.kotlintest.specs.StringSpec import io.kotlintest.specs.StringSpec
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify
import moe.kageru.kagebot.TestUtil
import moe.kageru.kagebot.config.Config import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.extensions.channelsByName import moe.kageru.kagebot.Kagebot
import moe.kageru.kagebot.TestUtil
import org.javacord.api.entity.message.embed.EmbedBuilder import org.javacord.api.entity.message.embed.EmbedBuilder
@ExperimentalStdlibApi
class WelcomeFeatureTest : StringSpec({ class WelcomeFeatureTest : StringSpec({
TestUtil.prepareTestEnvironment() TestUtil.prepareTestEnvironment()
"should send welcome" { "should send welcome" {
val sentMessages = mutableListOf<EmbedBuilder>() val sentMessages = mutableListOf<EmbedBuilder>()
Config.features.welcome!!.welcomeUser( Kagebot.welcomeUser(
mockk { mockk {
every { user } returns mockk { every { user } returns mockk {
every { discriminatedName } returns "testuser#1234"
every { sendMessage(capture(sentMessages)) } returns mockk { every { sendMessage(capture(sentMessages)) } returns mockk {
every { join() } returns mockk() every { join() } returns mockk()
every { isCompletedExceptionally } returns false every { isCompletedExceptionally } returns false
} }
} }
}, }
) )
sentMessages shouldBe mutableListOf(Config.features.welcome!!.embed) sentMessages shouldBe mutableListOf(Config.features.welcome!!.embed)
} }
"should send welcome fallback if DMs are disabled" { "should send welcome fallback if DMs are disabled" {
Config.features.welcome!!.welcomeUser( val message = mutableListOf<String>()
TestUtil.prepareTestEnvironment(sentMessages = message)
Kagebot.welcomeUser(
mockk { mockk {
every { user } returns mockk { every { user } returns mockk {
every { discriminatedName } returns "testuser#1234"
every { id } returns 123 every { id } returns 123
every { sendMessage(any<EmbedBuilder>()) } returns mockk { every { sendMessage(any<EmbedBuilder>()) } returns mockk {
every { join() } returns mockk() every { join() } returns mockk()
every { isCompletedExceptionally } returns true every { isCompletedExceptionally } returns true
} }
every { mentionTag } returns "<@123>"
} }
}, }
) )
val channel = Config.server.channelsByName("").first() message shouldBe mutableListOf("<@123> welcome")
verify(exactly = 1) { channel.sendMessage("<@123> welcome") }
} }
}) })

View File

@ -3,9 +3,9 @@ serverId = "356414885292277771"
color = "#1793d0" color = "#1793d0"
[localization] [localization]
permissionDenied = "no permissions"
redirectedMessage = "says" redirectedMessage = "says"
messageDeleted = "message dongered" messageDeleted = "message dongered"
timeout = "timeout @@ minutes"
[feature.welcome] [feature.welcome]
fallbackChannel = "123" fallbackChannel = "123"
@ -17,12 +17,6 @@ content = [
"Second paragraph heading", "Second paragraph content" "Second paragraph heading", "Second paragraph content"
] ]
[feature.timeout]
role = "timeout"
[feature.vc]
category = "testcategory"
[[command]] [[command]]
trigger = "!debug" trigger = "!debug"
feature = "debug" feature = "debug"
@ -30,7 +24,3 @@ feature = "debug"
[[command]] [[command]]
trigger = "!welcome" trigger = "!welcome"
feature = "welcome" feature = "welcome"
[[command]]
trigger = "!timeout"
feature = "timeout"