Compare commits

...

22 Commits

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

It was a bad idea because konf-stacktraces are apparently infinitely
recursive
2020-07-22 13:47:30 +02:00
kageru 6b21625f19
Further improve stacktrace logging 2020-07-22 13:45:06 +02:00
kageru 6b52db8324
Improve config parser errors 2020-07-22 13:38:59 +02:00
kageru 75588b357c
update dependencies 2020-07-22 13:29:41 +02:00
kageru d7594c29e5
update dependencies 2020-05-13 17:59:36 +02:00
kageru 91259094ed
Permissions: match role purely by name 2020-04-12 00:58:34 +02:00
kageru 5679d5d8dd
Update Kotlin and Arrow 2020-03-10 13:01:20 +01:00
kageru 83a7b2cbdf
Fix tests 2020-03-10 13:00:08 +01:00
kageru a5bc947d27
Update javacord to 3.05 2019-11-26 14:58:09 +01:00
kageru 4185ee50fa
Remove useless newline 2019-11-24 11:40:38 +01:00
kageru d2027a2cdc
Remove unnecessary arrow subpackages 2019-11-24 11:40:10 +01:00
kageru d354821881
Minor changes to deployment process 2019-11-14 15:30:52 +01:00
kageru 39083d8248
Indent with 2 spaces instead of 4 2019-11-14 15:10:30 +01:00
kageru 5c7efcd10e
Add ktlint to deployment process 2019-11-14 14:59:35 +01:00
kageru cd9ee0e881
Simplifications 2019-11-13 23:18:28 +01:00
39 changed files with 1174 additions and 1155 deletions

8
.editorconfig Normal file
View 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

View File

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

View File

@ -1,69 +1,64 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
val arrowVersion = "0.10.2"
apply {
plugin("kotlin-kapt")
plugin("kotlin-kapt")
}
plugins {
kotlin("jvm") version "1.3.50"
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()
maven {
url = uri("https://dl.bintray.com/arrow-kt/arrow-kt/")
}
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.uchuhimo:konf-core:0.20.0")
implementation("com.uchuhimo:konf-toml:0.20.0")
implementation(kotlin("stdlib-jdk8"))
implementation("org.javacord:javacord:3.0.4")
implementation("org.mapdb:mapdb:3.0.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.2")
implementation("org.jetbrains.kotlin:kotlin-reflect:1.3.50")
implementation("com.fasterxml.jackson.core:jackson-annotations:2.10.0.pr3")
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")
implementation("io.arrow-kt:arrow-core:$arrowVersion")
implementation("io.arrow-kt:arrow-syntax:$arrowVersion")
implementation("io.arrow-kt:arrow-optics:$arrowVersion")
implementation("io.arrow-kt:arrow-meta:$arrowVersion")
implementation("io.arrow-kt:arrow-fx:$arrowVersion")
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.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.10.0.pr3")
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
View File

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

View File

@ -1,6 +1,4 @@
#!/bin/sh
if gradle test; then
gradle shadowjar
scp build/libs/moe.kageru.kagebot-0.1-all.jar lain:/home/selphybot/bot.jar
if ./check.sh; then
ssh lain sudo systemctl restart selphybot.service
fi

View File

@ -5,6 +5,6 @@ import org.javacord.api.DiscordApi
import java.util.concurrent.atomic.AtomicInteger
object Globals {
lateinit var api: DiscordApi
val commandCounter: AtomicInteger = AtomicInteger(Dao.getCommandCounter())
lateinit var api: DiscordApi
val commandCounter: AtomicInteger = AtomicInteger(Dao.getCommandCounter())
}

View File

@ -12,44 +12,47 @@ import java.io.File
import kotlin.system.exitProcess
fun main() {
Kagebot.init()
Kagebot.init()
}
object Kagebot {
fun MessageCreateEvent.process() {
if (messageAuthor.isBotUser) {
handleOwn()
return
}
Config.commands
.find { it.matches(readableMessageContent) && it.isAllowed(this) }
.map { it.execute(this) }
fun MessageCreateEvent.process() {
if (messageAuthor.isBotUser) {
handleOwn()
return
}
Config.commands
.find { it.matches(readableMessageContent) && it.isAllowed(this) }
.map { it.execute(this) }
}
private fun MessageCreateEvent.handleOwn() {
if (messageAuthor.isYourself) {
val loggedMessage = readableMessageContent.ifBlank { "[embed]" }
Log.info("<Self> $loggedMessage")
}
private fun MessageCreateEvent.handleOwn() {
if (messageAuthor.isYourself) {
val loggedMessage = readableMessageContent.ifBlank { "[embed]" }
Log.info("<Self> $loggedMessage")
}
}
fun init() {
val secret = File("secret").readText().trim()
val api = DiscordApiBuilder().setToken(secret).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")}")
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()
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()
}
}

View File

@ -8,30 +8,30 @@ import java.util.logging.LogRecord
import java.util.logging.Logger
object Log {
private val log: Logger by lazy {
Logger.getGlobal().apply {
addHandler(
FileHandler("kagebot.log", true).apply {
formatter = LogFormatter()
}
)
}
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 info(message: String) {
log.info(message)
}
fun warn(message: String) {
log.warning(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"
}
}

View File

@ -6,63 +6,56 @@ 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 withEmbed(op: EmbedBuilder.() -> Unit): EmbedBuilder {
val builder = EmbedBuilder()
Config.server.icon.ifPresent { builder.setThumbnail(it) }
builder.setColor(SystemSpec.color)
builder.op()
return builder
fun Messageable.sendEmbed(op: EmbedBuilder.() -> Unit) {
val embed = withEmbed {
setTimestampToNow()
op()
}
sendMessage(embed)
}
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 [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())
}
/**
* 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())
/**
* 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 {
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)
}
}
}
/**
* 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())
}
/**
* 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())
}
}
}

View File

@ -16,80 +16,70 @@ import java.util.concurrent.CompletableFuture
import java.util.concurrent.CompletionException
object Util {
inline fun <T> T.applyIf(condition: Boolean, op: (T) -> T): T {
return if (condition) op(this) else this
inline fun <T> T.applyIf(condition: Boolean, op: (T) -> T): T {
return if (condition) op(this) else this
}
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 hasOneOf(messageAuthor: MessageAuthor, roles: Set<Role>): Boolean {
return messageAuthor.asUser().asOption().flatMap { user ->
user.roles().find { it in roles }
}.nonEmpty()
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()
}
}
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" }
fun <T> CompletableFuture<T>.asOption(): Option<T> {
return try {
val future = join()
if (isCompletedExceptionally) None else Option.just(future)
} catch (e: CompletionException) {
Option.empty()
}
}
private fun <T> ListK<T>.getOnly(): Either<Int, T> {
return when (size) {
1 -> Either.right(first())
else -> Either.left(size)
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" }
}
else -> server.channelsByName(idOrName).getOnly().mapLeft { "Found $it channels for $idOrName, expected 1" }
}
}
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 {
Option.just(join())
} 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.getCachedUserByDiscriminatedName(idOrName.removePrefix("@")).asOption()
.toEither { "User $idOrName not found" }
.flatMap { user ->
user.openPrivateChannel().asOption().toEither { "Can’t DM user $idOrName" }
}
else -> server.channelsByName(idOrName).getOnly().mapLeft { "Found $it channels for $idOrName, expected 1" }
}
}
inline fun checked(op: (() -> Unit)) {
try {
op()
} catch (e: Exception) {
Log.warn("An uncaught exception occurred.\n$e")
Log.warn(e.stackTrace.joinToString("\n"))
MessageUtil.sendEmbed(
Globals.api.owner.get(),
EmbedBuilder()
.setColor(Color.RED)
.addField("Error", "kagebot has encountered an error")
.addField(
"$e", """```
${e.stackTrace.joinToString("\n\t")}
```""".trimIndent().run { applyIf(length > 1800) { substring(1..1800) } }
)
)
}
inline fun checked(op: (() -> Unit)) {
try {
op()
} catch (e: Exception) {
Log.warn("An uncaught exception occurred.\n$e")
Log.warn(e.stackTrace.joinToString("\n"))
}
}
}

View File

@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty
import moe.kageru.kagebot.Globals
import moe.kageru.kagebot.Log
import moe.kageru.kagebot.MessageUtil
import moe.kageru.kagebot.Util.applyIf
import moe.kageru.kagebot.MessageUtil.mention
import moe.kageru.kagebot.config.Config
import moe.kageru.kagebot.features.MessageFeature
import org.javacord.api.entity.message.MessageAuthor
@ -14,57 +14,47 @@ import org.javacord.api.event.message.MessageCreateEvent
private const val AUTHOR_PLACEHOLDER = "@@"
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 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) }
val matchType: MatchType = matchType?.let { type ->
MatchType.values().find { it.name.equals(type, ignoreCase = true) }
?: throw IllegalArgumentException("Invalid [command.matchType]: “$matchType")
} ?: MatchType.PREFIX
val regex: Regex? = if (this.matchType == MatchType.REGEX) Regex(trigger) else null
val embed: EmbedBuilder? = embed?.let(MessageUtil::listToEmbed)
private val feature: MessageFeature? = feature?.let { Config.features.findByString(it) }
fun matches(msg: String) = this.matchType.matches(msg, this)
fun matches(msg: String) = this.matchType.matches(msg, this)
fun isAllowed(message: MessageCreateEvent) = permissions?.isAllowed(message) ?: true
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))
}
this.embed?.let {
MessageUtil.sendEmbed(message.channel, embed)
}
this.feature?.handle(message)
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))
}
this.embed?.let {
MessageUtil.sendEmbed(message.channel, embed)
}
this.feature?.handle(message)
}
private fun respond(author: MessageAuthor, response: String) =
response.applyIf(response.contains(AUTHOR_PLACEHOLDER)) {
it.replace(AUTHOR_PLACEHOLDER, MessageUtil.mention(author))
}
private fun respond(author: MessageAuthor, response: String) =
response.replace(AUTHOR_PLACEHOLDER, author.mention())
}
@Suppress("unused")
enum class MatchType {
PREFIX {
override fun matches(message: String, command: Command) = message.startsWith(command.trigger, ignoreCase = true)
},
CONTAINS {
override fun matches(message: String, command: Command) = message.contains(command.trigger, ignoreCase = true)
},
REGEX {
override fun matches(message: String, command: Command) = command.regex!!.matches(message)
};
abstract fun matches(message: String, command: Command): Boolean
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) }),
}

View File

@ -8,31 +8,31 @@ import moe.kageru.kagebot.config.LocalizationSpec
import org.javacord.api.event.message.MessageCreateEvent
class MessageActions(
private val delete: Boolean = false,
private val redirect: MessageRedirect?,
@JsonProperty("assign")
private val assignment: RoleAssignment?
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 ->
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}")
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}")
}
}
}

View File

@ -12,29 +12,29 @@ import org.javacord.api.entity.channel.TextChannel
import org.javacord.api.event.message.MessageCreateEvent
class MessageRedirect(target: String, private val anonymous: Boolean = false) {
private val targetChannel: TextChannel = Util.findChannel(target).unwrap()
private val targetChannel: TextChannel = Util.findChannel(target).unwrap()
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")
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")
}
}
}

View File

@ -1,28 +1,23 @@
package moe.kageru.kagebot.command
import arrow.core.Option
import arrow.core.toOption
import moe.kageru.kagebot.Util
import moe.kageru.kagebot.extensions.unwrap
import org.javacord.api.entity.permission.Role
import org.javacord.api.event.message.MessageCreateEvent
class Permissions(
hasOneOf: List<String>?,
hasNoneOf: List<String>?,
private val onlyDM: Boolean = false
hasOneOf: List<String>?,
hasNoneOf: List<String>?,
private val onlyDM: Boolean = false,
) {
private val hasOneOf: Option<Set<Role>> = resolveRoles(hasOneOf)
private val hasNoneOf: Option<Set<Role>> = resolveRoles(hasNoneOf)
private val hasOneOf: Option<Set<String>> = hasOneOf?.toSet().toOption()
private val hasNoneOf: Option<Set<String>> = hasNoneOf?.toSet().toOption()
private fun resolveRoles(hasOneOf: List<String>?): Option<Set<Role>> {
return Option.fromNullable(hasOneOf?.mapTo(mutableSetOf(), { Util.findRole(it).unwrap() }))
}
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) }
}
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) }
}
}

View File

@ -8,10 +8,10 @@ import moe.kageru.kagebot.extensions.unwrap
import org.javacord.api.event.message.MessageCreateEvent
class RoleAssignment(@JsonProperty("role") role: String) {
private val role = Util.findRole(role).unwrap()
private val role = Util.findRole(role).unwrap()
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.") }
)
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.") },
)
}

View File

@ -9,18 +9,18 @@ import moe.kageru.kagebot.features.Features
import org.javacord.api.entity.server.Server
object Config {
val systemSpec = Config { addSpec(SystemSpec) }.from.toml
val localeSpec = Config { addSpec(LocalizationSpec) }.from.toml
val commandSpec = Config { addSpec(CommandSpec) }.from.toml
val featureSpec = Config { addSpec(FeatureSpec) }.from.toml
val systemSpec = Config { addSpec(SystemSpec) }.from.toml
val localeSpec = Config { addSpec(LocalizationSpec) }.from.toml
val commandSpec = Config { addSpec(CommandSpec) }.from.toml
val featureSpec = Config { addSpec(FeatureSpec) }.from.toml
lateinit var system: Config
lateinit var localization: Config
lateinit var commandConfig: Config
lateinit var featureConfig: Config
lateinit var server: Server
lateinit var 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()
// for easier access
val features: Features get() = featureConfig[FeatureSpec.features]
val commands: ListK<Command> get() = commandConfig[CommandSpec.command].k()
}

View File

@ -7,28 +7,28 @@ import moe.kageru.kagebot.config.SystemSpec.serverId
import java.io.File
object ConfigParser {
internal const val DEFAULT_CONFIG_PATH = "config.toml"
val configFile: File = File(DEFAULT_CONFIG_PATH)
internal const val DEFAULT_CONFIG_PATH = "config.toml"
val configFile: File = File(DEFAULT_CONFIG_PATH)
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 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)
}
}
private fun getFile(path: String): File {
val file = File(path)
if (file.isFile) {
return file
}
println("Config not found, falling back to defaults...")
return File(this::class.java.classLoader.getResource(path)!!.toURI())
private fun getFile(path: String): File {
val file = File(path)
if (file.isFile) {
return file
}
println("Config not found, falling back to defaults...")
return File(this::class.java.classLoader.getResource(path)!!.toURI())
}
}

View File

@ -7,21 +7,21 @@ 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])!! }
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.")
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>())
val command by optional(emptyList<Command>())
}
object FeatureSpec : ConfigSpec(prefix = "") {
val features by optional(Features.DEFAULT, name = "feature")
val features by optional(Features(), name = "feature")
}

View File

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

View File

@ -5,18 +5,22 @@ 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
this.map { op(it) }
return this
}
fun <T> Either<*, T>.unwrap(): T = getOrElse { error("Attempted to unwrap Either.left") }
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) } }
fun <T> Either<*, T>.unwrap(): T = getOrElse {
Log.warn("Attempted to unwrap $this")
error("Attempted to unwrap Either.left")
}
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) } }
inline fun <A, B, C, A2, F> Tuple3<A, B, C>.mapFirst(AP: Functor<F>, op: (A) -> Kind<F, A2>) = let { (a, b, c) ->
AP.run { op(a).map { Tuple3(it, b, c) } }
}
inline fun <A, B, C, B2, F> Tuple3<A, B, C>.mapSecond(AP: Functor<F>, op: (B) -> Kind<F, B2>) = let { (a, b, c) ->
AP.run { op(b).map { Tuple3(a, it, c) } }
}

View File

@ -3,10 +3,10 @@ package moe.kageru.kagebot.extensions
import arrow.core.ListK
import arrow.core.Option
import arrow.core.k
import org.javacord.api.entity.channel.ServerTextChannel
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

View File

@ -11,51 +11,55 @@ import java.time.temporal.ChronoUnit
class DebugFeature : MessageFeature {
override fun handle(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"
}

View File

@ -4,9 +4,9 @@ import org.javacord.api.DiscordApi
import org.javacord.api.event.message.MessageCreateEvent
interface MessageFeature {
fun handle(message: MessageCreateEvent)
fun handle(message: MessageCreateEvent)
}
interface EventFeature {
fun register(api: DiscordApi)
fun register(api: DiscordApi)
}

View File

@ -1,26 +1,26 @@
package moe.kageru.kagebot.features
class Features(val welcome: WelcomeFeature?, val timeout: TimeoutFeature?, vc: TempVCFeature = TempVCFeature(null)) {
private val debug = DebugFeature()
private val help = HelpFeature()
private val getConfig = GetConfigFeature()
private val setConfig = SetConfigFeature()
class Features(
val welcome: WelcomeFeature? = null,
val timeout: TimeoutFeature? = null,
vc: TempVCFeature = TempVCFeature(null),
) {
private val debug = DebugFeature()
private val help = HelpFeature()
private val getConfig = GetConfigFeature()
private val setConfig = SetConfigFeature()
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
)
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 eventFeatures() = all.filterIsInstance<EventFeature>()
companion object {
val DEFAULT = Features(null, null)
}
fun findByString(feature: String) = featureMap[feature]
fun eventFeatures() = all.filterIsInstance<EventFeature>()
}

View File

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

View File

@ -7,13 +7,13 @@ import moe.kageru.kagebot.config.Config
import org.javacord.api.event.message.MessageCreateEvent
class HelpFeature : MessageFeature {
override fun handle(message: MessageCreateEvent) {
message.channel.sendEmbed {
addField("Commands:", listCommands(message))
}
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) }
.joinToString("\n") { it.trigger }
.filter { it.matchType == MatchType.PREFIX && it.isAllowed(message) }
.joinToString("\n") { it.trigger }

View File

@ -6,23 +6,23 @@ 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}```")
}
}
@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}```")
}
}
}
}

View File

@ -16,46 +16,51 @@ 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() }
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 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)
}
}
}
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 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 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"
private fun generateChannelName(message: MessageCreateEvent): String =
"${message.messageAuthor.name}’s volatile corner"
}

View File

@ -23,58 +23,58 @@ import java.time.Duration
import java.time.Instant
class TimeoutFeature(@JsonProperty("role") role: String) : MessageFeature {
private val timeoutRole: Role = findRole(role).unwrap()
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) }
}
}
)
}
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}")
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()}")
},
)
}
fun checkAndRelease(): Unit = Dao.getAllTimeouts()
.filter { releaseTime -> Instant.now().epochSecond > releaseTime }
.map { Dao.deleteTimeout(it) }
.map { it.destructured() }
.forEach { (userId, roleIds) ->
Config.server.memberById(userId).fold(
{ Log.warn("Tried to free user $userId, but couldn’t find them on the server anymore") },
{ user ->
roleIds.forEach { findRole("$it").map(user::addRole) }
user.removeRole(timeoutRole)
Log.info("Lifted timeout from user ${user.discriminatedName}. Stored roles ${roleIds.joinToString()}")
}
)
}
}

View File

@ -3,8 +3,8 @@ package moe.kageru.kagebot.features
import moe.kageru.kagebot.Log
import moe.kageru.kagebot.MessageUtil
import moe.kageru.kagebot.Util
import moe.kageru.kagebot.Util.checked
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
@ -13,41 +13,41 @@ import org.javacord.api.event.message.MessageCreateEvent
import org.javacord.api.event.server.member.ServerMemberJoinEvent
class WelcomeFeature(
content: List<String>?,
fallbackChannel: String?,
private val fallbackMessage: String?
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 { content?.let(MessageUtil::listToEmbed) }
override fun register(api: DiscordApi) {
api.addServerMemberJoinListener { event ->
checked { welcomeUser(event) }
}
override fun register(api: DiscordApi) {
api.addServerMemberJoinListener { event ->
checked { welcomeUser(event) }
}
}
fun welcomeUser(event: ServerMemberJoinEvent) {
Log.info("User ${event.user.discriminatedName} joined")
val message = event.user.sendMessage(embed)
// If the user disabled direct messages, try the fallback (if defined)
if (message.asOption().isEmpty() && hasFallback()) {
fallbackChannel!!.sendMessage(
fallbackMessage!!.replace("@@", MessageUtil.mention(event.user))
)
}
fun welcomeUser(event: ServerMemberJoinEvent) {
Log.info("User ${event.user.discriminatedName} joined")
val message = event.user.sendMessage(embed)
// If the user disabled direct messages, try the fallback (if defined)
if (message.asOption().isEmpty() && hasFallback()) {
fallbackChannel!!.sendMessage(
fallbackMessage!!.replace("@@", event.user.mentionTag),
)
}
}
override fun handle(message: MessageCreateEvent) {
embed?.let {
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()
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()
}
}

View File

@ -5,40 +5,40 @@ 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()
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 saveTimeout(releaseTime: Long, user: Long, roles: List<Long>) {
prisoners[releaseTime] = (listOf(user) + roles).toLongArray()
}
fun setCommandCounter(count: Int) {
commands["total"] = count
}
fun setCommandCounter(count: Int) {
commands["total"] = count
}
fun getCommandCounter() = commands["total"] ?: 0
fun getCommandCounter() = commands["total"] ?: 0
fun close() = db.close()
fun close() = db.close()
fun getAllTimeouts() = prisoners.keys.k()
fun getAllTimeouts() = prisoners.keys.k()
fun deleteTimeout(releaseTime: Long): List<Long> {
val timeout = prisoners[releaseTime]!!
prisoners.remove(releaseTime)
return timeout.toList()
}
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 isTemporaryVC(channel: String): Boolean {
return channel in tempVcs
}
fun addTemporaryVC(channel: String) {
tempVcs.add(channel)
}
fun addTemporaryVC(channel: String) {
tempVcs.add(channel)
}
fun removeTemporaryVC(channel: String) {
tempVcs.remove(channel)
}
fun removeTemporaryVC(channel: String) {
tempVcs.remove(channel)
}
}

View File

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

View File

@ -23,124 +23,130 @@ import java.io.File
import java.util.*
object TestUtil {
private val TIMEOUT_ROLE = mockk<Role> {
every { id } returns 123
}
val TEST_ROLE = mockk<Role> {
every { id } returns 1
every { isManaged } returns false
}
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 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 { roles() } returns ListK.empty()
every { sendMessage(capture(messages)) } returns mockk(relaxed = true)
}
}
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(),
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)
}
Globals.api = mockk(relaxed = true) {
every { getServerById(any<String>()) } returns Optional.of(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 { membersByName(any()) } returns ListK.just(mockk(relaxed = true) {
every { id } returns 123
every { roles() } returns ListK.just(TEST_ROLE)
every { sendMessage(capture(dmEmbeds)) } returns mockk(relaxed = true) {
every { isCompletedExceptionally } returns false
}
})
})
}
Config.server = Globals.api.getServerById("").get()
ConfigParser.initialLoad("testconfig.toml")
}
fun embedToString(embed: EmbedBuilder): String {
return (embed.delegate as EmbedBuilderDelegateImpl).toJsonNode().toString()
}
fun testMessageSuccess(content: String, result: String) {
val calls = mutableListOf<String>()
mockMessage(content, replies = calls).process()
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 withCommands(config: String, test: (() -> Unit)) {
val oldCmds = Config.commandConfig
Config.commandConfig = Config.commandSpec.string(config)
test()
Config.commandConfig = oldCmds
}
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
}
}
}

View File

@ -27,284 +27,286 @@ import org.javacord.api.entity.user.User
import java.util.*
class CommandTest : StringSpec({
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)
}
prepareTestEnvironment()
"should increment command counter" {
withCommands(
"""
[[command]]
trigger = "!ping"
response = "pong"
""".trimIndent(),
) {
val before = Globals.commandCounter.get()
testMessageSuccess("!ping", "pong")
Globals.commandCounter.get() shouldBe (before + 1)
}
"should match prefix command" {
withCommands(
"""
[[command]]
trigger = "!ping"
response = "pong"
""".trimIndent()
) {
testMessageSuccess("!ping", "pong")
}
}
"should match prefix command" {
withCommands(
"""
[[command]]
trigger = "!ping"
response = "pong"
""".trimIndent(),
) {
testMessageSuccess("!ping", "pong")
}
"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 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 contains command" {
withCommands(
"""
[[command]]
trigger = "somewhere"
response = "found it"
matchType = "CONTAINS"
""".trimIndent()
) {
testMessageSuccess("the trigger is somewhere in this message", "found it")
}
}
"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 match regex command" {
withCommands(
"""
[[command]]
trigger = "A.+B"
response = "regex matched"
matchType = "REGEX"
""".trimIndent()
) {
testMessageSuccess("AcsdB", "regex matched")
}
}
"should match regex command" {
withCommands(
"""
[[command]]
trigger = "A.+B"
response = "regex matched"
matchType = "REGEX"
""".trimIndent(),
) {
testMessageSuccess("AcsdB", "regex matched")
}
"should ping author" {
withCommands(
"""
[[command]]
trigger = "answer me"
response = "@@ there you go"
""".trimIndent()
) {
testMessageSuccess("answer me", "<@1> there you go")
}
}
"should ping author" {
withCommands(
"""
[[command]]
trigger = "answer me"
response = "@@ there you go"
""".trimIndent(),
) {
testMessageSuccess("answer me", "<@1> there you go")
}
"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 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 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 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 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 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 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 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 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 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()
}
"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()
}
mockMessage.process()
// first message didn’t answer anything
calls shouldBe mutableListOf("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>()
mockMessage("!dm", replies = calls).process()
calls shouldBe mutableListOf()
}
}
"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"
mockMessage("!redirect $message").process()
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)
mockMessage("!assign").process()
roles shouldBe mutableListOf(Util.findRole("testrole").unwrap())
}
}
"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 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, got asd")
Dao.isTemporaryVC("12345") shouldBe false
}
}
"should reject invalid vc command" {
withCommands(
"""
[[command]]
trigger = "!vc"
feature = "vc"
""".trimIndent(),
) {
testMessageSuccess("!vc asd", "Invalid syntax, expected a number as limit, got asd")
Dao.isTemporaryVC("12345") shouldBe false
}
}
})

View File

@ -9,16 +9,18 @@ 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>()
mockMessage("!getConfig", files = calls).process()
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
}
}
})

View File

@ -10,23 +10,23 @@ 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
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>()) }
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>()) }
}
})

View File

@ -14,46 +14,48 @@ 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 ->
mockMessage("!help", replyEmbeds = replies).process()
}
}
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.rolesByName("testrole").first()
)
})
message.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.rolesByName("testrole").first(),
)
},
)
message.process()
}
}
}
})

View File

@ -10,53 +10,53 @@ 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()
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)
}
}
companion object {
private fun clearTimeouts() {
Dao.getAllTimeouts().forEach { to ->
Dao.deleteTimeout(to)
}
}
}
}

View File

@ -4,43 +4,45 @@ import io.kotlintest.shouldBe
import io.kotlintest.specs.StringSpec
import io.mockk.every
import io.mockk.mockk
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>()
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" {
val message = mutableListOf<String>()
TestUtil.prepareTestEnvironment(sentMessages = message)
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
}
}
}
)
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") }
}
})