Compare commits

...

83 Commits

Author SHA1 Message Date
e3e0c07fa2 find redirect target by user ID 2023-08-14 10:29:27 +02:00
80111b2bbf update javacord and run ktlint 2023-08-14 10:14:19 +02:00
997284fb54
Set all intents (javacord 3.1 migration) 2020-10-30 10:57:48 +01:00
540095f677
Update dependencies 2020-10-30 10:27:43 +01:00
a49fec2fcf
update Readme 2020-10-02 08:05:04 +02:00
7c08bab460
Bump dependencies
Mainly because discord is moving to the new domain
2020-09-25 21:06:44 +02:00
2b40e0578f
add logging for unwrap operations 2020-07-22 13:52:20 +02:00
3838cde65e
Revert "Further improve stacktrace logging"
This reverts commit 6b21625f19ab35934298f7286c384791cd9b97dd.

It was a bad idea because konf-stacktraces are apparently infinitely
recursive
2020-07-22 13:47:30 +02:00
6b21625f19
Further improve stacktrace logging 2020-07-22 13:45:06 +02:00
6b52db8324
Improve config parser errors 2020-07-22 13:38:59 +02:00
75588b357c
update dependencies 2020-07-22 13:29:41 +02:00
d7594c29e5
update dependencies 2020-05-13 17:59:36 +02:00
91259094ed
Permissions: match role purely by name 2020-04-12 00:58:34 +02:00
5679d5d8dd
Update Kotlin and Arrow 2020-03-10 13:01:20 +01:00
83a7b2cbdf
Fix tests 2020-03-10 13:00:08 +01:00
a5bc947d27
Update javacord to 3.05 2019-11-26 14:58:09 +01:00
4185ee50fa
Remove useless newline 2019-11-24 11:40:38 +01:00
d2027a2cdc
Remove unnecessary arrow subpackages 2019-11-24 11:40:10 +01:00
d354821881
Minor changes to deployment process 2019-11-14 15:30:52 +01:00
39083d8248
Indent with 2 spaces instead of 4 2019-11-14 15:10:30 +01:00
5c7efcd10e
Add ktlint to deployment process 2019-11-14 14:59:35 +01:00
cd9ee0e881
Simplifications 2019-11-13 23:18:28 +01:00
f742383f38
Reimplement !vc parsing with Either 2019-11-13 22:45:34 +01:00
07c45d84d0
Generify mapFirst/Second on Tuples 2019-11-13 22:23:03 +01:00
a05f2e558b
Add extensions for mapping tuple values 2019-11-13 16:59:44 +01:00
f1b727662a
replace CompletableFuture.failed with Option folding 2019-11-12 22:22:32 +01:00
ccf76e7c64
Sort extensions by target 2019-11-12 22:10:26 +01:00
69c3ae80b8
Consistently use Javacord extensions 2019-11-12 22:02:32 +01:00
2c56e1959a
Use Either monad for timeout parsing 2019-11-12 21:13:01 +01:00
50b97fdec7
No longer throw exceptions for role/channel queries 2019-11-12 19:25:42 +01:00
3d813384e2
Start migrating to Arrow Options 2019-11-11 23:52:14 +01:00
7eadb2d67d
Shorten command permission checks 2019-11-11 20:00:53 +01:00
288be7e4f5
Remove permission denied message 2019-11-11 19:49:45 +01:00
a8ed168122
Simplify Optionals in TestUtil 2019-11-11 18:54:43 +01:00
5cc34c9e29
Add arrow-kt 2019-11-11 18:10:28 +01:00
231b27176a
Remove now-fixed shutdown hook of mapdb
We’ve had our own for a while now. Looks like their finally started
working which actually caused some issues (see last two commits).
2019-11-06 07:39:22 +01:00
1bc26951f7
Enable transactions for persistent storage 2019-11-06 07:35:52 +01:00
58307c9743
Allow starting the bot even after the persistence was improperly closed 2019-11-06 07:33:27 +01:00
2be20880b8
Remove debug print 2019-10-19 12:44:55 +02:00
af65dcc06b
Add temporary VCs 2019-10-19 12:25:23 +02:00
5a95138861
Fix link in readme 2019-10-18 22:17:14 +02:00
56475ef685
Fix indentation 2019-10-18 22:15:22 +02:00
d6492bae8f
Rewrite config to use Konf (4): Features
The entire config parsing is now rewritten. This entirely removes toml4j
in favor of Konf. It also removes all remaining RawConfig logic.
2019-10-18 22:03:57 +02:00
bb03474bf5
Rewrite config to use Konf (3): Commands 2019-10-18 22:03:56 +02:00
17c7120796
Rewrite config to use Konf (2): Localization 2019-10-18 22:03:56 +02:00
e31d46ceb5
Rewrite config to use Konf (1): SystemConfig 2019-10-18 22:03:18 +02:00
87cb943712
Update dependencies 2019-10-18 22:03:18 +02:00
897457336d
Persist command counter between restarts 2019-09-20 19:01:47 +02:00
1672b4c62a
More simplifications 2019-09-17 23:24:49 +02:00
828845f230
Simplify message handling 2019-09-17 23:22:22 +02:00
ba913f2bed
Fix lateinit property initialization order 2019-09-17 23:01:38 +02:00
e7f47b7420
Clean up Util.kt 2019-09-17 22:59:55 +02:00
c49a122622
Update dependencies 2019-09-17 22:04:52 +02:00
684926f1c5
Update default config to use role names instead of IDs 2019-09-17 20:42:03 +02:00
f314c20dd8
Make command matching case-insensitive 2019-08-30 15:27:11 +02:00
f871d2a381
Only break from command match loop after successfully executing a command
This means you can have multiple commands with the same or overlappings triggers,
and the bot will execute the first the user has access to.
2019-08-07 15:13:16 +02:00
e3219c7800
Revert "Remove toPairs helper in favor of stdlib functionality"
This reverts commit 60797c21d09ff70f465745b2a9764d78a8a14fe4.
2019-08-05 22:14:07 +02:00
0ae5e83641
Add logging for errors while sending embeds
Not that it’s worth much because the discord API is a useless piece of
trash when it comes to error messages. Like, seriously. Might as well
throw no error at all.
Received a 400 response from Discord with body {"embed": ["fields"]}!
Thanks for nothing. Fix in next commit.
2019-08-05 22:12:45 +02:00
60797c21d0
Remove toPairs helper in favor of stdlib functionality 2019-08-01 13:15:05 +02:00
35bd570c93
Add optional reason to timeout command (closes #14) 2019-07-28 18:50:53 +02:00
b4c2275670
Send DM to user after timeout 2019-07-25 21:27:25 +02:00
8560c3d82e
Add tests to timeout feature (closes #4) 2019-07-25 21:04:13 +02:00
c299406d69
Add logging and error handling to timeouts 2019-07-25 20:19:01 +02:00
7bbcaba49c
Close DB on shutdown
Apparently, we can’t trust the library to handle this, as their hook
seems to fail.
2019-07-23 23:31:23 +02:00
3944b07ec0
Don’t remove managed roles for timeout 2019-07-23 23:27:04 +02:00
93a6e92191
Add minimal implementation of timeout feature 2019-07-23 21:50:55 +02:00
74c2f643f9
Add simple Dao to persistently store key, value pairs 2019-07-23 19:22:07 +02:00
7efeb9bace
Add logging for welcome messages 2019-07-23 11:26:48 +02:00
39ceaffa6c
Pull event handling into interface 2019-07-17 23:47:12 +02:00
b9f1c6e540
Make message processing an extension function 2019-07-17 23:22:18 +02:00
d425687fb5
Cleanup in main classes 2019-07-17 23:17:31 +02:00
e6f73bb178
Minor cleanup in SetConfigFeature 2019-07-17 22:56:31 +02:00
31c5cd7135
Linefeeds in DebugFeature 2019-07-17 22:52:47 +02:00
d6320bb5de
Simplify config file loading 2019-07-17 22:51:36 +02:00
c400ab7369
Reduce complexity of redirect text handling 2019-07-17 22:47:47 +02:00
691bc408c0
Refactor embed handling 2019-07-17 22:40:44 +02:00
8be98f161f
Make sending embeds nicer 2019-07-17 22:06:20 +02:00
cdc053a443
Added test for SetConfigFeature
Closes #10
2019-07-17 21:42:02 +02:00
c8123cca4e
Make all log methods static 2019-07-17 21:16:17 +02:00
6069795caa
Add deployment script 2019-07-17 21:10:50 +02:00
f82ed7be56
Actually persist config after reloading it 2019-07-14 21:07:19 +02:00
d0b3bb608b
Reload config at runtime via command + attachment
This implements #10, but I want to write tests before closing that.
2019-07-14 20:40:23 +02:00
8a1308f98d
Added readme and changelog 2019-07-14 18:36:36 +02:00
46 changed files with 1501 additions and 1047 deletions

8
.editorconfig Normal file

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

7
CHANGELOG.md Normal file

@ -0,0 +1,7 @@
## 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

21
README.md Normal file

@ -0,0 +1,21 @@
# 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.

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

2
check.sh Executable file

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

4
deploy.sh Executable file

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

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

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

@ -8,21 +8,30 @@ import java.util.logging.LogRecord
import java.util.logging.Logger
object Log {
val log: Logger by lazy {
val log = Logger.getGlobal()
val fh = FileHandler("kagebot.log", true)
val formatter = LogFormatter()
fh.formatter = formatter
log.addHandler(fh)
return@lazy log
private val log: Logger by lazy {
Logger.getGlobal().apply {
addHandler(
FileHandler("kagebot.log", true).apply {
formatter = LogFormatter()
},
)
}
}
fun info(message: String) {
log.info(message)
}
fun warn(message: String) {
log.warning(message)
}
}
private class LogFormatter : Formatter() {
private val timeFormatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault())
private val timeFormatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault())
override fun format(record: LogRecord): String {
return "[${record.level}] ${timeFormatter.format(record.instant)}: ${record.message}\n"
}
override fun format(record: LogRecord): String {
return "[${record.level}] ${timeFormatter.format(record.instant)}: ${record.message}\n"
}
}

@ -1,54 +1,61 @@
package moe.kageru.kagebot
import moe.kageru.kagebot.config.Config
import org.javacord.api.entity.channel.TextChannel
import moe.kageru.kagebot.config.SystemSpec
import org.javacord.api.entity.message.Message
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.user.User
import java.util.concurrent.CompletableFuture
object MessageUtil {
fun mention(user: MessageAuthor): String {
return "<@${user.id}>"
}
fun MessageAuthor.mention() = "<@$id>"
fun mention(user: User): String {
return "<@${user.id}>"
fun withEmbed(op: EmbedBuilder.() -> Unit): EmbedBuilder {
return EmbedBuilder().apply {
Config.server.icon.ifPresent { setThumbnail(it) }
setColor(SystemSpec.color)
op()
}
}
fun getEmbedBuilder(): EmbedBuilder {
val builder = EmbedBuilder()
Config.server.icon.ifPresent { builder.setThumbnail(it) }
return builder.setColor(Config.systemConfig.color)
fun Messageable.sendEmbed(op: EmbedBuilder.() -> Unit) {
val embed = withEmbed {
setTimestampToNow()
op()
}
sendMessage(embed)
}
/**
* Send and embed and add the current time to it.
* The time is not set in [getEmbedBuilder] because of https://git.kageru.moe/kageru/discord-kagebot/issues/13.
*/
fun sendEmbed(target: TextChannel, embed: EmbedBuilder): CompletableFuture<Message> {
return target.sendMessage(embed.setTimestampToNow())
}
/**
* 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.
*/
fun sendEmbed(target: Messageable, embed: EmbedBuilder): CompletableFuture<Message> {
return target.sendMessage(embed.setTimestampToNow())
}
fun sendEmbed(target: User, embed: EmbedBuilder): CompletableFuture<Message> {
return target.sendMessage(embed.setTimestampToNow())
/**
* The reason we use a list here (rather than a map) is that maps would not retain the order specified in the config.
* I tried LinkedHashMaps, but those dont seem to work either.
*/
fun listToEmbed(contents: List<String>): EmbedBuilder {
check(contents.size % 2 != 1) { "Embed must have even number of content strings (title/content pairs)" }
return withEmbed {
contents.toPairs().forEach { (heading, content) ->
addField(heading, content)
}
}
}
/**
* The reason we use a list here (rather than a map) is that maps would not retain the order specified in the config.
* I tried LinkedHashMaps, but those dont seem to work either.
*/
fun listToEmbed(contents: List<String>): EmbedBuilder {
if (contents.size % 2 == 1) {
throw IllegalStateException("Embed must have even number of content strings (title/content pairs)")
}
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)
}
return builder
/**
* Convert a list of elements to pairs, retaining order.
* 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())
}
}
}

@ -1,117 +1,85 @@
package moe.kageru.kagebot
import moe.kageru.kagebot.Log.log
import moe.kageru.kagebot.config.Config
import arrow.core.*
import arrow.core.extensions.either.monad.flatMap
import arrow.core.extensions.list.foldable.find
import moe.kageru.kagebot.config.Config.server
import moe.kageru.kagebot.extensions.*
import org.javacord.api.entity.channel.TextChannel
import org.javacord.api.entity.message.MessageAuthor
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.event.message.MessageCreateEvent
import java.awt.Color
import java.util.*
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CompletionException
object Util {
inline fun <T> T.doIf(condition: (T) -> Boolean, op: (T) -> T): T {
return if (condition(this)) op(this) else this
}
inline fun <T> T.applyIf(condition: Boolean, op: (T) -> T): T {
return if (condition) op(this) else this
}
/**
* Mimics the behavior of [Optional.ifPresent], but returns null if the optional is empty,
* allowing easier fallback behavior via Kotlins ?: operator.
*/
private inline fun <T, R> Optional<T>.ifNotEmpty(op: (T) -> R): R? {
if (this.isPresent) {
return op(this.get())
fun hasOneOf(messageAuthor: MessageAuthor, roles: Set<String>): Boolean {
return messageAuthor.asUser().asOption().flatMap { user ->
user.roles().find { it.name in roles }
}.nonEmpty()
}
private val channelIdRegex = Regex("\\d{18}")
private fun String.isEntityId() = channelIdRegex.matches(this)
fun findRole(idOrName: String): Either<String, Role> {
return when {
idOrName.isEntityId() -> server.getRoleById(idOrName).asOption().toEither { 0 }
else -> server.rolesByName(idOrName).getOnly()
}.mapLeft { "Found $it results, expected 1" }
}
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> {
return when {
idOrName.isEntityId() -> server.getMemberById(idOrName).asOption()
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) {
Option.empty()
}
}
fun <T> Optional<T>.asOption(): Option<T> = if (this.isPresent) Option.just(this.get()) else Option.empty()
fun findChannel(idOrName: String): Either<String, TextChannel> {
return when {
idOrName.isEntityId() -> server.channelById(idOrName).toEither { "Channel $idOrName not found" }
idOrName.startsWith('@') -> Globals.api.getUserById(idOrName.removePrefix("@")).asOption()
.toEither { "User $idOrName not found" }
.flatMap { user ->
user.openPrivateChannel().asOption().toEither { "Can’t DM user $idOrName" }
}
return null
else -> server.channelsByName(idOrName).getOnly().mapLeft { "Found $it channels for $idOrName, expected 1" }
}
}
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 fun String.isEntityId() = channelIdRegex.matches(this)
@Throws(IllegalArgumentException::class)
fun findRole(idOrName: String): Role {
return when {
idOrName.isEntityId() -> server.getRoleById(idOrName).ifNotEmpty { it }
?: throw IllegalArgumentException("Role $idOrName not found.")
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")
}
}
}
}
fun <T> wasSuccessful(future: CompletableFuture<T>): Boolean {
try {
future.join()
} catch (e: CompletionException) {
// we don’t care about this error, but I don’t want to spam stdout
}
return !future.isCompletedExceptionally
}
@Throws(IllegalArgumentException::class)
fun findChannel(idOrName: String): TextChannel {
return when {
idOrName.isEntityId() -> server.getTextChannelById(idOrName).ifNotEmpty { it }
?: throw IllegalArgumentException("Channel ID $idOrName not found.")
else -> if (idOrName.startsWith('@')) {
Globals.api.getCachedUserByDiscriminatedName(idOrName.removePrefix("@")).ifNotEmpty { user ->
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")
}
}
}
}
}
inline fun checked(op: (() -> Unit)) {
try {
op()
} catch (e: Exception) {
log.warning("An uncaught exception occurred.\n$e")
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)
}
inline fun checked(op: (() -> Unit)) {
try {
op()
} catch (e: Exception) {
Log.warn("An uncaught exception occurred.\n$e")
Log.warn(e.stackTrace.joinToString("\n"))
}
}
}

@ -1,11 +1,11 @@
package moe.kageru.kagebot.command
import com.fasterxml.jackson.annotation.JsonProperty
import moe.kageru.kagebot.Globals
import moe.kageru.kagebot.Log.log
import moe.kageru.kagebot.Log
import moe.kageru.kagebot.MessageUtil
import moe.kageru.kagebot.Util.doIf
import moe.kageru.kagebot.MessageUtil.mention
import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.config.RawCommand
import moe.kageru.kagebot.features.MessageFeature
import org.javacord.api.entity.message.MessageAuthor
import org.javacord.api.entity.message.embed.EmbedBuilder
@ -13,69 +13,48 @@ import org.javacord.api.event.message.MessageCreateEvent
private const val AUTHOR_PLACEHOLDER = "@@"
class Command(cmd: RawCommand) {
val trigger: String
private val response: String?
val matchType: MatchType
private val permissions: Permissions?
private val actions: MessageActions?
val regex: Regex?
val embed: EmbedBuilder?
val feature: MessageFeature?
class Command(
val trigger: String,
private val response: String? = null,
private val permissions: Permissions?,
@JsonProperty("action")
private val actions: MessageActions?,
embed: List<String>?,
feature: String?,
matchType: String?,
) {
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) }
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 matches(msg: String) = this.matchType.matches(msg, this)
fun isAllowed(message: MessageCreateEvent) = permissions?.isAllowed(message) ?: true
fun execute(message: MessageCreateEvent) {
Log.info("Executing command ${this.trigger} triggered by user ${message.messageAuthor.discriminatedName} (ID: ${message.messageAuthor.id})")
Globals.commandCounter.incrementAndGet()
this.actions?.run(message, this)
this.response?.let {
message.channel.sendMessage(respond(message.messageAuthor, it))
}
fun isAllowed(message: MessageCreateEvent) = permissions?.isAllowed(message) ?: true
fun execute(message: MessageCreateEvent) {
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()
this.actions?.run(message, this)
this.response?.let {
message.channel.sendMessage(respond(message.messageAuthor, it))
}
this.embed?.let {
MessageUtil.sendEmbed(message.channel, embed)
}
this.feature?.handle(message)
this.embed?.let {
MessageUtil.sendEmbed(message.channel, embed)
}
this.feature?.handle(message)
}
fun matches(msg: String) = this.matchType.matches(msg, this)
private fun respond(author: MessageAuthor, response: String) = response.doIf({ it.contains(AUTHOR_PLACEHOLDER) }) {
it.replace(AUTHOR_PLACEHOLDER, MessageUtil.mention(author))
}
private fun respond(author: MessageAuthor, response: String) =
response.replace(AUTHOR_PLACEHOLDER, author.mention())
}
enum class MatchType {
PREFIX {
override fun matches(message: String, command: Command) = message.startsWith(command.trigger)
},
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
@Suppress("unused")
enum class MatchType(val matches: (String, Command) -> Boolean) {
PREFIX({ message, command -> message.startsWith(command.trigger, ignoreCase = true) }),
CONTAINS({ message, command -> message.contains(command.trigger, ignoreCase = true) }),
REGEX({ message, command -> command.regex!!.matches(message) }),
}

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

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

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

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

@ -1,13 +1,26 @@
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.features.Features
import org.javacord.api.entity.server.Server
object Config {
lateinit var server: Server
lateinit var commands: List<Command>
lateinit var systemConfig: SystemConfig
lateinit var features: Features
lateinit var localization: Localization
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
// for easier access
val features: Features get() = featureConfig[FeatureSpec.features]
val commands: ListK<Command> get() = commandConfig[CommandSpec.command].k()
}

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

@ -0,0 +1,27 @@
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")
}

@ -1,19 +0,0 @@
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?)

@ -1,35 +0,0 @@
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?)

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

@ -0,0 +1,21 @@
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)
}
}
}

@ -0,0 +1,26 @@
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) } }
}

@ -0,0 +1,24 @@
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()

@ -9,57 +9,57 @@ import java.lang.management.ManagementFactory
import java.time.Duration
import java.time.temporal.ChronoUnit
class DebugFeature : MessageFeature() {
class DebugFeature : MessageFeature {
override fun handleInternal(message: MessageCreateEvent) {
if (message.messageAuthor.isBotOwner) {
MessageUtil.sendEmbed(message.channel, getPerformanceStats())
}
override fun handle(message: MessageCreateEvent) {
if (message.messageAuthor.isBotOwner) {
MessageUtil.sendEmbed(message.channel, getPerformanceStats())
}
}
private fun getPerformanceStats(): EmbedBuilder {
val osBean = ManagementFactory.getPlatformMXBean(OperatingSystemMXBean::class.java)
val runtime = Runtime.getRuntime()
return MessageUtil.listToEmbed(
listOf(
"Bot:",
getBotStats(),
"Memory:",
getMemoryInfo(runtime, osBean),
"CPU:",
getCpuInfo(osBean),
"System:",
getOsInfo()
)
)
}
private fun getPerformanceStats(): EmbedBuilder {
val osBean = ManagementFactory.getPlatformMXBean(OperatingSystemMXBean::class.java)
val runtime = Runtime.getRuntime()
return MessageUtil.listToEmbed(
listOf(
"Bot:",
getBotStats(),
"Memory:",
getMemoryInfo(runtime, osBean),
"CPU:",
getCpuInfo(osBean),
"System:",
getOsInfo(),
),
)
}
private fun getBotStats() = "kagebot has been running for ${getBotUptime()}.\n" +
"During this time, ${Globals.commandCounter.get()} commands have been executed."
private fun getBotStats() = "kagebot has been running for ${getBotUptime()}.\n" +
"During this time, ${Globals.commandCounter.get()} commands have been executed."
private fun getBotUptime(): String {
val uptime = Duration.of(ManagementFactory.getRuntimeMXBean().uptime, ChronoUnit.MILLIS)
return String.format(
"%d days, %d hours, %d minutes, %d seconds",
uptime.toDaysPart(),
uptime.toHoursPart(),
uptime.toMinutesPart(),
uptime.toSecondsPart()
)
}
private fun getBotUptime(): String {
val uptime = Duration.of(ManagementFactory.getRuntimeMXBean().uptime, ChronoUnit.MILLIS)
return String.format(
"%d days, %d hours, %d minutes, %d seconds",
uptime.toDaysPart(),
uptime.toHoursPart(),
uptime.toMinutesPart(),
uptime.toSecondsPart(),
)
}
private fun getMemoryInfo(runtime: Runtime, osBean: OperatingSystemMXBean): String {
val mb = 1024 * 1024
return "Memory usage: ${(runtime.totalMemory() - runtime.freeMemory()) / mb} MB.\n" +
"Total system memory: ${osBean.committedVirtualMemorySize / mb}/" +
"${osBean.totalPhysicalMemorySize / mb} MB."
}
private fun getMemoryInfo(runtime: Runtime, osBean: OperatingSystemMXBean): String {
val mb = 1024 * 1024
return "Memory usage: ${(runtime.totalMemory() - runtime.freeMemory()) / mb} MB.\n" +
"Total system memory: ${osBean.committedVirtualMemorySize / mb}/" +
"${osBean.totalPhysicalMemorySize / mb} MB."
}
private fun getCpuInfo(osBean: OperatingSystemMXBean) =
"The bot is currently using ${String.format("%.4f", osBean.processCpuLoad * 100)}% of the CPU with " +
"${Thread.activeCount()} active threads.\n" +
"Total system load is ${String.format("%.2f", osBean.systemCpuLoad * 100)}%."
private fun getCpuInfo(osBean: OperatingSystemMXBean) =
"The bot is currently using ${String.format("%.4f", osBean.processCpuLoad * 100)}% of the CPU with " +
"${Thread.activeCount()} active threads.\n" +
"Total system load is ${String.format("%.2f", osBean.systemCpuLoad * 100)}%."
private fun getOsInfo() = "Running on ${System.getProperty("os.name")} " +
"${System.getProperty("os.version")}-${System.getProperty("os.arch")}.\n"
private fun getOsInfo() = "Running on ${System.getProperty("os.name")} " +
"${System.getProperty("os.version")}-${System.getProperty("os.arch")}.\n"
}

@ -0,0 +1,12 @@
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)
}

@ -1,26 +1,26 @@
package moe.kageru.kagebot.features
import moe.kageru.kagebot.config.RawFeatures
class Features(
val welcome: WelcomeFeature?,
debug: DebugFeature,
help: HelpFeature,
getConfig: GetConfigFeature
val welcome: WelcomeFeature? = null,
val timeout: TimeoutFeature? = null,
vc: TempVCFeature = TempVCFeature(null),
) {
constructor(rawFeatures: RawFeatures) : this(
rawFeatures.welcome?.let(::WelcomeFeature),
DebugFeature(),
HelpFeature(),
GetConfigFeature()
)
private val debug = DebugFeature()
private val help = HelpFeature()
private val getConfig = GetConfigFeature()
private val setConfig = SetConfigFeature()
private val featureMap = mapOf(
"help" to help,
"debug" to debug,
"welcome" to welcome,
"getConfig" to getConfig
)
private val all = listOf(welcome, debug, help, getConfig, setConfig, timeout, vc)
private val featureMap = mapOf(
"help" to help,
"debug" to debug,
"welcome" to welcome,
"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>()
}

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

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

@ -1,11 +0,0 @@
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)
}

@ -0,0 +1,28 @@
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
class SetConfigFeature : MessageFeature {
@ExperimentalStdlibApi
override fun handle(message: MessageCreateEvent) {
if (message.messageAttachments.size != 1) {
message.channel.sendMessage("Error: please attach the new config to your message.")
return
}
val newConfig = message.messageAttachments[0].url.openStream().readAllBytes().decodeToString()
try {
Config.localization = Config.localeSpec.string(newConfig)
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}```")
}
}
}
}

@ -0,0 +1,66 @@
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"
}

@ -0,0 +1,80 @@
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()}")
},
)
}
}

@ -1,27 +1,53 @@
package moe.kageru.kagebot.features
import moe.kageru.kagebot.Log
import moe.kageru.kagebot.MessageUtil
import moe.kageru.kagebot.Util
import moe.kageru.kagebot.config.RawWelcomeFeature
import moe.kageru.kagebot.Util.asOption
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.message.embed.EmbedBuilder
import org.javacord.api.event.message.MessageCreateEvent
import org.javacord.api.event.server.member.ServerMemberJoinEvent
class WelcomeFeature(rawWelcome: RawWelcomeFeature) : MessageFeature() {
override fun handleInternal(message: MessageCreateEvent) {
embed?.let {
MessageUtil.sendEmbed(message.channel, embed!!)
}
}
class WelcomeFeature(
content: List<String>?,
fallbackChannel: String?,
private val fallbackMessage: String?,
) : MessageFeature, EventFeature {
val embed: EmbedBuilder? by lazy { content?.let(MessageUtil::listToEmbed) }
val embed: EmbedBuilder? by lazy {
rawWelcome.content?.let(MessageUtil::listToEmbed)
override fun register(api: DiscordApi) {
api.addServerMemberJoinListener { event ->
checked { welcomeUser(event) }
}
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)
}
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),
)
}
val fallbackMessage: String? = rawWelcome.fallbackMessage
}
override fun handle(message: MessageCreateEvent) {
embed?.let {
MessageUtil.sendEmbed(message.channel, it)
} ?: Log.info("Welcome command was triggered, but no welcome embed defined.")
}
private fun hasFallback(): Boolean = fallbackChannel != null && fallbackMessage != null
private val fallbackChannel: TextChannel? = fallbackChannel?.let { channel ->
requireNotNull(fallbackMessage) {
"[feature.welcome.fallbackMessage] must not be null if fallbackChannel is defined"
}
Util.findChannel(channel).unwrap()
}
}

@ -0,0 +1,44 @@
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)
}
}

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

@ -2,15 +2,42 @@ package moe.kageru.kagebot
import io.kotlintest.shouldBe
import io.kotlintest.shouldNotBe
import io.kotlintest.specs.ShouldSpec
import io.kotlintest.specs.StringSpec
import io.mockk.every
import io.mockk.mockk
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
class ConfigTest : ShouldSpec({
TestUtil.prepareTestEnvironment()
@ExperimentalStdlibApi
class ConfigTest : StringSpec() {
init {
"should properly parse test config" {
Config.systemConfig shouldNotBe null
Config.localization shouldNotBe null
Config.features shouldNotBe null
Config.commands.size shouldBe 2
TestUtil.prepareTestEnvironment()
Config.system[SystemSpec.serverId] shouldNotBe null
SystemSpec.color shouldBe Color.decode("#1793d0")
Config.features.welcome!!.embed shouldNotBe null
Config.commands.size shouldBe 3
}
})
"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
}
}
}

@ -1,5 +1,7 @@
package moe.kageru.kagebot
import arrow.core.ListK
import arrow.core.Option
import io.kotlintest.matchers.string.shouldContain
import io.kotlintest.matchers.string.shouldNotContain
import io.kotlintest.shouldBe
@ -7,12 +9,13 @@ import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import moe.kageru.kagebot.Kagebot.process
import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.config.ConfigParser
import moe.kageru.kagebot.config.RawConfig
import org.javacord.api.DiscordApi
import moe.kageru.kagebot.extensions.*
import org.javacord.api.entity.channel.ServerTextChannel
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.event.message.MessageCreateEvent
import org.javacord.core.entity.message.embed.EmbedBuilderDelegateImpl
@ -20,107 +23,130 @@ import java.io.File
import java.util.*
object TestUtil {
fun mockMessage(
content: String,
replies: MutableList<String> = mutableListOf(),
replyEmbeds: MutableList<EmbedBuilder> = mutableListOf(),
files: MutableList<File> = mutableListOf(),
isBot: Boolean = false
): MessageCreateEvent {
return mockk {
every { messageContent } returns content
every { readableMessageContent } returns content
every { channel.sendMessage(capture(replies)) } returns mockk()
every { channel.sendMessage(capture(replyEmbeds)) } returns mockk()
every { channel.sendMessage(capture(files)) } returns mockk()
every { message.canYouDelete() } returns true
every { isPrivateMessage } returns false
// We can’t use a nested mock here because other fields of messageAuthor might
// get overwritten by other tests, which would delete a nested mock.
every { messageAuthor.id } returns 1
every { messageAuthor.discriminatedName } returns "testuser#1234"
every { messageAuthor.isBotUser } returns isBot
every { messageAuthor.isYourself } returns isBot
every { messageAuthor.isBotOwner } returns false
every { messageAuthor.asUser() } returns Optional.of(messageableAuthor())
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(
content: String,
replies: MutableList<String> = mutableListOf(),
replyEmbeds: MutableList<EmbedBuilder> = mutableListOf(),
files: MutableList<File> = mutableListOf(),
isBot: Boolean = false,
): MessageCreateEvent {
return mockk {
every { messageContent } returns content
every { readableMessageContent } returns content
every { channel.sendMessage(capture(replies)) } returns mockk(relaxed = true) {
every { isCompletedExceptionally } returns false
}
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 { isPrivateMessage } returns false
// We can’t use a nested mock here because other fields of messageAuthor might
// get overwritten by other tests, which would delete a nested mock.
every { messageAuthor.id } returns 1
every { messageAuthor.discriminatedName } returns "testuser#1234"
every { messageAuthor.isBotUser } returns isBot
every { messageAuthor.isYourself } returns isBot
every { messageAuthor.isBotOwner } returns false
every { messageAuthor.asUser() } returns Optional.of(messageableAuthor(replyEmbeds))
every { messageAuthor.name } returns "kageru"
}
}
fun messageableAuthor(messages: MutableList<EmbedBuilder> = mutableListOf()): User {
return mockk {
every { roles() } returns ListK.empty()
every { sendMessage(capture(messages)) } returns mockk(relaxed = true)
}
}
fun prepareTestEnvironment(
sentEmbeds: MutableList<EmbedBuilder> = mutableListOf(),
sentMessages: MutableList<String> = mutableListOf(),
dmEmbeds: MutableList<EmbedBuilder> = mutableListOf(),
) {
val channel = mockk<ServerTextChannel>(relaxed = true) {
every { sendMessage(capture(sentEmbeds)) } returns mockk(relaxed = true) {
every { join() } returns mockk {
every { isCompletedExceptionally } returns false
}
every { isCompletedExceptionally } returns false
}
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
Config.server = mockk()
Config.server = mockk(relaxed = true) {
every { icon.ifPresent(any()) } just Runs
every { channelById(any()) } returns Option.just(channel)
every { channelsByName(any()) } returns ListK.just(channel)
every { rolesByName("testrole") } returns ListK.just(TEST_ROLE)
every { rolesByName("timeout") } returns ListK.just(TIMEOUT_ROLE)
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(
mockk(relaxed = true) {
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 messageableAuthor(messages: MutableList<EmbedBuilder> = mutableListOf()): User {
return mockk {
every { getRoles(any()) } returns emptyList()
every { sendMessage(capture(messages)) } returns mockk()
}
}
fun testMessageSuccess(content: String, result: String) {
val calls = mutableListOf<String>()
mockMessage(content, replies = calls).process()
calls shouldBe mutableListOf(result)
}
fun prepareTestEnvironment(
sentEmbeds: MutableList<EmbedBuilder> = mutableListOf(),
sentMessages: MutableList<String> = mutableListOf()
) {
val channel = mockk<Optional<ServerTextChannel>> {
every { isPresent } returns true
every { get() } returns mockk {
every { sendMessage(capture(sentEmbeds)) } returns mockk {
every { join() } returns mockk()
every { isCompletedExceptionally } returns false
}
every { sendMessage(capture(sentMessages)) } returns mockk()
}
}
val api = mockk<DiscordApi> {
every { getServerById(any<String>()) } returns Optional.of(mockk {
every { icon.ifPresent(any()) } just Runs
every { getTextChannelById(any<String>()) } returns channel
every { getTextChannelsByName(any()) } returns listOf(channel.get())
every { getRolesByNameIgnoreCase("testrole") } returns listOf(mockk {
every { id } returns 1
})
})
}
Globals.api = api
ConfigParser.initialLoad(RawConfig.read("testconfig.toml"))
}
fun embedToString(embed: EmbedBuilder): String {
return (embed.delegate as EmbedBuilderDelegateImpl).toJsonNode().toString()
}
fun testMessageSuccess(content: String, result: String) {
val calls = mutableListOf<String>()
Kagebot.processMessage(mockMessage(content, replies = calls))
calls shouldBe mutableListOf(result)
}
fun withCommands(config: String, test: (() -> Unit)) {
val oldCmds = Config.commandConfig
Config.commandConfig = Config.commandSpec.string(config)
test()
Config.commandConfig = oldCmds
}
fun embedToString(embed: EmbedBuilder): String {
return (embed.delegate as EmbedBuilderDelegateImpl).toJsonNode().toString()
fun withReplyContents(
expected: List<String> = emptyList(),
unexpected: List<String> = emptyList(),
op: (MutableList<EmbedBuilder>) -> Unit,
) {
val replies = mutableListOf<EmbedBuilder>()
op(replies)
replies.size shouldBe 1
val replyString = embedToString(replies[0])
for (string in expected) {
replyString shouldContain string
}
fun <R> withCommands(config: String, test: (() -> R)) {
val oldCmds = Config.commands
val rawConfig = RawConfig.readFromString(config)
ConfigParser.reloadCommands(rawConfig)
test()
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(
expected: List<String> = emptyList(),
unexpected: List<String> = emptyList(),
op: (MutableList<EmbedBuilder>) -> Unit
) {
val replies = mutableListOf<EmbedBuilder>()
op(replies)
replies.size shouldBe 1
val replyString = embedToString(replies[0])
for (string in expected) {
replyString shouldContain string
}
for (string in unexpected) {
replyString shouldNotContain string
}
for (string in unexpected) {
replyString shouldNotContain string
}
}
}

@ -1,12 +1,13 @@
package moe.kageru.kagebot.command
import arrow.core.ListK
import io.kotlintest.matchers.string.shouldContain
import io.kotlintest.shouldBe
import io.kotlintest.specs.StringSpec
import io.mockk.every
import io.mockk.mockk
import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.Kagebot
import moe.kageru.kagebot.Globals
import moe.kageru.kagebot.Kagebot.process
import moe.kageru.kagebot.TestUtil
import moe.kageru.kagebot.TestUtil.embedToString
import moe.kageru.kagebot.TestUtil.messageableAuthor
@ -14,265 +15,298 @@ import moe.kageru.kagebot.TestUtil.mockMessage
import moe.kageru.kagebot.TestUtil.prepareTestEnvironment
import moe.kageru.kagebot.TestUtil.testMessageSuccess
import moe.kageru.kagebot.TestUtil.withCommands
import moe.kageru.kagebot.TestUtil.withLocalization
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.permission.Role
import org.javacord.api.entity.user.User
import java.util.*
class CommandTest : StringSpec({
prepareTestEnvironment()
"should match prefix command" {
withCommands(
"""
[[command]]
trigger = "!ping"
response = "pong"
""".trimIndent()
) {
testMessageSuccess("!ping", "pong")
}
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 print embed for command" {
val calls = mutableListOf<EmbedBuilder>()
prepareTestEnvironment(calls)
val heading = "heading 1"
val content = "this is the first paragraph of the embed"
withCommands(
"""
[[command]]
trigger = "!embed"
embed = [ "$heading", "$content" ]
""".trimIndent()
) {
TestUtil.withReplyContents(expected = listOf(heading, content)) {
Kagebot.processMessage(mockMessage("!embed", replyEmbeds = it))
}
}
}
"should match prefix command" {
withCommands(
"""
[[command]]
trigger = "!ping"
response = "pong"
""".trimIndent(),
) {
testMessageSuccess("!ping", "pong")
}
"should match contains command" {
withCommands(
"""
[[command]]
trigger = "somewhere"
response = "found it"
matchType = "CONTAINS"
""".trimIndent()
) {
testMessageSuccess("the trigger is somewhere in this message", "found it")
}
}
"should print embed for command" {
val calls = mutableListOf<EmbedBuilder>()
prepareTestEnvironment(calls)
val heading = "heading 1"
val content = "this is the first paragraph of the embed"
withCommands(
"""
[[command]]
trigger = "!embed"
embed = [ "$heading", "$content" ]
""".trimIndent(),
) {
TestUtil.withReplyContents(expected = listOf(heading, content)) {
mockMessage("!embed", replyEmbeds = it).process()
}
}
"should match regex command" {
withCommands(
"""
[[command]]
trigger = "A.+B"
response = "regex matched"
matchType = "REGEX"
""".trimIndent()
) {
testMessageSuccess("AcsdB", "regex matched")
}
}
"should match contains command" {
withCommands(
"""
[[command]]
trigger = "somewhere"
response = "found it"
matchType = "CONTAINS"
""".trimIndent(),
) {
testMessageSuccess("the trigger is somewhere in this message", "found it")
}
"should ping author" {
withCommands(
"""
[[command]]
trigger = "answer me"
response = "@@ there you go"
""".trimIndent()
) {
testMessageSuccess("answer me", "<@1> there you go")
}
}
"should match regex command" {
withCommands(
"""
[[command]]
trigger = "A.+B"
response = "regex matched"
matchType = "REGEX"
""".trimIndent(),
) {
testMessageSuccess("AcsdB", "regex matched")
}
"should not react to own message" {
withCommands(
"""
[[command]]
trigger = "!ping"
response = "pong"
""".trimIndent()
) {
val calls = mutableListOf<String>()
Kagebot.processMessage(mockMessage("!ping", replies = calls, isBot = true))
calls shouldBe mutableListOf()
}
}
"should ping author" {
withCommands(
"""
[[command]]
trigger = "answer me"
response = "@@ there you go"
""".trimIndent(),
) {
testMessageSuccess("answer me", "<@1> there you go")
}
"should delete messages and send copy to author" {
withCommands(
"""
[[command]]
trigger = "delet this"
[command.action]
delete = true
""".trimIndent()
) {
val messageContent = "delet this"
TestUtil.withReplyContents(expected = listOf(messageContent)) {
val mockMessage = mockMessage(messageContent)
every { mockMessage.deleteMessage() } returns mockk()
every { mockMessage.messageAuthor.asUser() } returns Optional.of(messageableAuthor(it))
Kagebot.processMessage(mockMessage)
}
}
}
"should not react to own message" {
withCommands(
"""
[[command]]
trigger = "!ping"
response = "pong"
""".trimIndent(),
) {
val calls = mutableListOf<String>()
mockMessage("!ping", replies = calls, isBot = true).process()
calls shouldBe mutableListOf()
}
"should refuse command without permissions" {
withCommands(
"""
[[command]]
trigger = "!restricted"
response = "access granted"
[command.permissions]
hasOneOf = [
"testrole",
]
""".trimIndent()
) {
val replies = mutableListOf<String>()
val mockMessage = mockMessage("!restricted", replies = replies)
Kagebot.processMessage(mockMessage)
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 delete messages and send copy to author" {
withCommands(
"""
[[command]]
trigger = "delet this"
[command.action]
delete = true
""".trimIndent(),
) {
val messageContent = "delet this"
TestUtil.withReplyContents(expected = listOf(messageContent)) {
val mockMessage = mockMessage(messageContent)
every { mockMessage.deleteMessage() } returns mockk()
every { mockMessage.messageAuthor.asUser() } returns Optional.of(messageableAuthor(it))
mockMessage.process()
}
}
"should accept restricted command for owner" {
withCommands(
"""
[[command]]
trigger = "!restricted"
response = "access granted"
[command.permissions]
hasOneOf = [
"testrole"
]
""".trimIndent()
) {
val calls = mutableListOf<String>()
val mockMessage = mockMessage("!restricted", replies = calls)
every { mockMessage.messageAuthor.isBotOwner } returns true
Kagebot.processMessage(mockMessage)
calls shouldBe mutableListOf("access granted")
}
}
"should refuse command without permissions" {
withCommands(
"""
[[command]]
trigger = "!restricted"
response = "access granted"
[command.permissions]
hasOneOf = [
"testrole",
]
""".trimIndent(),
) {
val replies = mutableListOf<String>()
val mockMessage = mockMessage("!restricted", replies = replies)
mockMessage.process()
replies shouldBe mutableListOf()
}
"should accept restricted command with permissions" {
withCommands(
"""
[[command]]
trigger = "!restricted"
response = "access granted"
[command.permissions]
hasOneOf = [
"testrole"
]
""".trimIndent()
) {
val calls = mutableListOf<String>()
val mockMessage = mockMessage("!restricted", replies = calls)
every { mockMessage.messageAuthor.asUser() } returns Optional.of(mockk {
every { getRoles(any()) } returns listOf(
Config.server.getRolesByNameIgnoreCase("testrole")[0]
)
})
Kagebot.processMessage(mockMessage)
calls shouldBe mutableListOf("access granted")
}
}
"should accept restricted command for owner" {
withCommands(
"""
[[command]]
trigger = "!restricted"
response = "access granted"
[command.permissions]
hasOneOf = [
"testrole"
]
""".trimIndent(),
) {
val calls = mutableListOf<String>()
val mockMessage = mockMessage("!restricted", replies = calls)
every { mockMessage.messageAuthor.isBotOwner } returns true
mockMessage.process()
calls shouldBe mutableListOf("access granted")
}
"should deny command to excluded roles" {
withCommands(
"""
[[command]]
trigger = "!almostUnrestricted"
response = "access granted"
[command.permissions]
hasNoneOf = ["testrole"]
""".trimIndent()
) {
val calls = mutableListOf<String>()
val mockMessage = mockMessage("!almostUnrestricted", replies = calls)
// with the banned role
every { mockMessage.messageAuthor.asUser() } returns mockk {
every { isPresent } returns true
every { get().getRoles(any()) } returns listOf(
Config.server.getRolesByNameIgnoreCase("testrole")[0]
)
}
Kagebot.processMessage(mockMessage)
}
"should accept restricted command with permissions" {
withCommands(
"""
[[command]]
trigger = "!restricted"
response = "access granted"
[command.permissions]
hasOneOf = [
"testrole"
]
""".trimIndent(),
) {
val calls = mutableListOf<String>()
val mockMessage = mockMessage("!restricted", replies = calls)
every { mockMessage.messageAuthor.asUser() } returns Optional.of(
mockk {
every { roles() } returns ListK.just(
Config.server.rolesByName("testrole").first(),
)
},
)
mockMessage.process()
calls shouldBe mutableListOf("access granted")
}
}
"should deny command to excluded roles" {
withCommands(
"""
[[command]]
trigger = "!almostUnrestricted"
response = "access granted"
[command.permissions]
hasNoneOf = ["testrole"]
""".trimIndent(),
) {
val calls = mutableListOf<String>()
val mockMessage = mockMessage("!almostUnrestricted", replies = calls)
// with the banned role
every { mockMessage.messageAuthor.asUser() } returns mockk {
every { isPresent } returns true
every { get().getRoles(any()) } returns listOf(
Config.server.rolesByName("testrole").first(),
)
}
mockMessage.process()
// without the role
every { mockMessage.messageAuthor.asUser() } returns mockk {
every { isPresent } returns true
every { get().getRoles(any()) } returns emptyList()
}
Kagebot.processMessage(mockMessage)
calls shouldBe mutableListOf(Config.localization.permissionDenied, "access granted")
}
// without the role
every { mockMessage.messageAuthor.asUser() } returns mockk {
every { isPresent } returns true
every { get().getRoles(any()) } returns emptyList()
}
mockMessage.process()
// first message didn’t answer anything
calls shouldBe mutableListOf("access granted")
}
"should refuse DM only message in server channel" {
withCommands(
"""
[[command]]
trigger = "!dm"
response = "access granted"
[command.permissions]
onlyDM = true
""".trimIndent()
) {
val calls = mutableListOf<String>()
Kagebot.processMessage(mockMessage("!dm", replies = calls))
calls shouldBe listOf(Config.localization.permissionDenied)
}
}
"should refuse DM only message in server channel" {
withCommands(
"""
[[command]]
trigger = "!dm"
response = "access granted"
[command.permissions]
onlyDM = true
""".trimIndent(),
) {
val calls = mutableListOf<String>()
mockMessage("!dm", replies = calls).process()
calls shouldBe mutableListOf()
}
/*
* This implicitly tests that the message author is not included in anonymous complaints
* because getting the authors name from the mock is undefined.
*/
"should redirect" {
val calls = mutableListOf<EmbedBuilder>()
prepareTestEnvironment(calls)
withCommands(
"""
[[command]]
trigger = "!redirect"
response = "redirected"
[command.action.redirect]
target = "testchannel"
anonymous = true
""".trimIndent()
) {
val message = "this is a message"
Kagebot.processMessage(mockMessage("!redirect $message"))
calls.size shouldBe 1
embedToString(calls[0]) shouldContain "\"$message\""
}
}
/*
* This implicitly tests that the message author is not included in anonymous complaints
* because getting the authors name from the mock is undefined.
*/
"should redirect" {
val calls = mutableListOf<EmbedBuilder>()
prepareTestEnvironment(calls)
withCommands(
"""
[[command]]
trigger = "!redirect"
response = "redirected"
[command.action.redirect]
target = "testchannel"
anonymous = true
""".trimIndent(),
) {
val message = "this is a message"
mockMessage("!redirect $message").process()
calls.size shouldBe 1
embedToString(calls[0]) shouldContain "\"$message\""
}
"should assign" {
withCommands(
"""
[[command]]
trigger = "!assign"
[command.action.assign]
role = "testrole"
""".trimIndent()
) {
val roles = mutableListOf<Role>()
val user = mockk<User> {
every { addRole(capture(roles), "Requested via command.") } returns mockk()
}
every { Config.server.getMemberById(1) } returns Optional.of(user)
Kagebot.processMessage(mockMessage("!assign"))
roles shouldBe mutableListOf(Util.findRole("testrole"))
}
}
"should assign" {
withCommands(
"""
[[command]]
trigger = "!assign"
[command.action.assign]
role = "testrole"
""".trimIndent(),
) {
val roles = mutableListOf<Role>()
val user = mockk<User> {
every { addRole(capture(roles), "Requested via command.") } returns mockk()
}
every { Config.server.getMemberById(1) } returns Optional.of(user)
mockMessage("!assign").process()
roles shouldBe mutableListOf(Util.findRole("testrole").unwrap())
}
}
"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
}
}
})

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

@ -4,29 +4,29 @@ import io.kotlintest.specs.StringSpec
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import moe.kageru.kagebot.Kagebot
import moe.kageru.kagebot.Kagebot.process
import moe.kageru.kagebot.TestUtil
import org.javacord.api.entity.message.embed.EmbedBuilder
import org.javacord.api.event.message.MessageCreateEvent
class DebugFeatureTest : StringSpec({
TestUtil.prepareTestEnvironment()
// this will fail if the bot tries to execute more than it should
// because the mock does not provide the necessary methods
"should ignore regular users" {
val message = TestUtil.mockMessage("!debug")
every { message.messageAuthor.isBotOwner } returns false
Kagebot.processMessage(message)
DebugFeature().handle(message)
verify(exactly = 0) { message.channel.sendMessage(any<EmbedBuilder>()) }
}
"should return something" {
val message = mockk<MessageCreateEvent> {
every { messageAuthor.isBotOwner } returns true
every { readableMessageContent } returns "!debug"
every { channel.sendMessage(any<EmbedBuilder>()) } returns mockk()
}
DebugFeature().handle(message)
verify(exactly = 1) { message.channel.sendMessage(any<EmbedBuilder>()) }
TestUtil.prepareTestEnvironment()
// this will fail if the bot tries to execute more than it should
// because the mock does not provide the necessary methods
"should ignore regular users" {
val message = TestUtil.mockMessage("!debug")
every { message.messageAuthor.isBotOwner } returns false
message.process()
DebugFeature().handle(message)
verify(exactly = 0) { message.channel.sendMessage(any<EmbedBuilder>()) }
}
"should return something" {
val message = mockk<MessageCreateEvent> {
every { messageAuthor.isBotOwner } returns true
every { readableMessageContent } returns "!debug"
every { channel.sendMessage(any<EmbedBuilder>()) } returns mockk()
}
DebugFeature().handle(message)
verify(exactly = 1) { message.channel.sendMessage(any<EmbedBuilder>()) }
}
})

@ -3,56 +3,59 @@ package moe.kageru.kagebot.features
import io.kotlintest.specs.StringSpec
import io.mockk.every
import io.mockk.mockk
import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.Kagebot
import moe.kageru.kagebot.Kagebot.process
import moe.kageru.kagebot.TestUtil
import moe.kageru.kagebot.TestUtil.mockMessage
import moe.kageru.kagebot.TestUtil.withCommands
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 java.util.*
class HelpFeatureTest : StringSpec({
val sentEmbeds = mutableListOf<EmbedBuilder>()
TestUtil.prepareTestEnvironment(sentEmbeds = sentEmbeds)
val commandConfig = """
[[command]]
trigger = "!help"
feature = "help"
[[command]]
trigger = "!ping"
[[command]]
trigger = "!something"
[[command]]
trigger = "not a prefix"
matchType = "CONTAINS"
[[command]]
trigger = "!prison"
[command.permissions]
hasOneOf = ["testrole"]
""".trimIndent()
"should show prefix command" {
withCommands(commandConfig) {
val expected = listOf("!ping", "!something")
val unexpected = listOf("not a prefix", "!prison")
withReplyContents(expected = expected, unexpected = unexpected) { replies ->
Kagebot.processMessage(mockMessage("!help", replyEmbeds = replies))
}
}
val sentEmbeds = mutableListOf<EmbedBuilder>()
TestUtil.prepareTestEnvironment(sentEmbeds = sentEmbeds)
val commandConfig = """
[[command]]
trigger = "!help"
feature = "help"
[[command]]
trigger = "!ping"
[[command]]
trigger = "!something"
[[command]]
trigger = "not a prefix"
matchType = "CONTAINS"
[[command]]
trigger = "!prison"
[command.permissions]
hasOneOf = ["testrole"]
""".trimIndent()
"should show prefix command" {
withCommands(commandConfig) {
val expected = listOf("!ping", "!something")
val unexpected = listOf("not a prefix", "!prison")
withReplyContents(expected = expected, unexpected = unexpected) { replies ->
mockMessage("!help", replyEmbeds = replies).process()
}
}
"should show moderation commands for mod" {
withCommands(commandConfig) {
val expected = listOf("!ping", "!something", "!prison")
val unexpected = listOf("not a prefix")
withReplyContents(expected = expected, unexpected = unexpected) { replies ->
val message = mockMessage("!help", replyEmbeds = replies)
every { message.messageAuthor.asUser() } returns Optional.of(mockk {
every { getRoles(any()) } returns listOf(
Config.server.getRolesByNameIgnoreCase("testrole")[0]
)
})
Kagebot.processMessage(message)
}
}
}
"should show moderation commands for mod" {
withCommands(commandConfig) {
val expected = listOf("!ping", "!something", "!prison")
val unexpected = listOf("not a prefix")
withReplyContents(expected = expected, unexpected = unexpected) { replies ->
val message = mockMessage("!help", replyEmbeds = replies)
every { message.messageAuthor.asUser() } returns Optional.of(
mockk {
every { getRoles(any()) } returns listOf(
Config.server.rolesByName("testrole").first(),
)
},
)
message.process()
}
}
}
})

@ -0,0 +1,62 @@
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)
}
}
}
}

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

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