Indent with 2 spaces instead of 4

This commit is contained in:
kageru 2019-11-14 15:10:30 +01:00
parent 5c7efcd10e
commit 39083d8248
Signed by: kageru
GPG Key ID: 8282A2BEA4ADA3D2
35 changed files with 1090 additions and 1078 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

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

View File

@ -12,44 +12,44 @@ import java.io.File
import kotlin.system.exitProcess import kotlin.system.exitProcess
fun main() { fun main() {
Kagebot.init() Kagebot.init()
} }
object Kagebot { object Kagebot {
fun MessageCreateEvent.process() { fun MessageCreateEvent.process() {
if (messageAuthor.isBotUser) { if (messageAuthor.isBotUser) {
handleOwn() handleOwn()
return return
}
Config.commands
.find { it.matches(readableMessageContent) && it.isAllowed(this) }
.map { it.execute(this) }
} }
Config.commands
.find { it.matches(readableMessageContent) && it.isAllowed(this) }
.map { it.execute(this) }
}
private fun MessageCreateEvent.handleOwn() { private fun MessageCreateEvent.handleOwn() {
if (messageAuthor.isYourself) { if (messageAuthor.isYourself) {
val loggedMessage = readableMessageContent.ifBlank { "[embed]" } val loggedMessage = readableMessageContent.ifBlank { "[embed]" }
Log.info("<Self> $loggedMessage") Log.info("<Self> $loggedMessage")
}
} }
}
fun init() { fun init() {
val secret = File("secret").readText().trim() val secret = File("secret").readText().trim()
val api = DiscordApiBuilder().setToken(secret).login().join() val api = DiscordApiBuilder().setToken(secret).login().join()
Globals.api = api Globals.api = api
ConfigParser.initialLoad(ConfigParser.DEFAULT_CONFIG_PATH).mapLeft { e -> ConfigParser.initialLoad(ConfigParser.DEFAULT_CONFIG_PATH).mapLeft { e ->
println("Config parsing error:\n$e,\n${e.message},\n${e.stackTrace.joinToString("\n")}") println("Config parsing error:\n$e,\n${e.message},\n${e.stackTrace.joinToString("\n")}")
exitProcess(1) 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()
} }
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 import java.util.logging.Logger
object Log { object Log {
private val log: Logger by lazy { private val log: Logger by lazy {
Logger.getGlobal().apply { Logger.getGlobal().apply {
addHandler( addHandler(
FileHandler("kagebot.log", true).apply { FileHandler("kagebot.log", true).apply {
formatter = LogFormatter() formatter = LogFormatter()
}
)
} }
)
} }
}
fun info(message: String) { fun info(message: String) {
log.info(message) log.info(message)
} }
fun warn(message: String) { fun warn(message: String) {
log.warning(message) log.warning(message)
} }
} }
private class LogFormatter : Formatter() { private class LogFormatter : Formatter() {
private val timeFormatter: DateTimeFormatter = private val timeFormatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()) DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault())
override fun format(record: LogRecord): String { override fun format(record: LogRecord): String {
return "[${record.level}] ${timeFormatter.format(record.instant)}: ${record.message}\n" return "[${record.level}] ${timeFormatter.format(record.instant)}: ${record.message}\n"
} }
} }

View File

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

View File

@ -1,8 +1,11 @@
package moe.kageru.kagebot package moe.kageru.kagebot
import arrow.core.* import arrow.core.Either
import arrow.core.ListK
import arrow.core.Option
import arrow.core.extensions.either.monad.flatMap import arrow.core.extensions.either.monad.flatMap
import arrow.core.extensions.list.foldable.find import arrow.core.extensions.list.foldable.find
import arrow.core.firstOrNone
import moe.kageru.kagebot.config.Config.server import moe.kageru.kagebot.config.Config.server
import moe.kageru.kagebot.extensions.* import moe.kageru.kagebot.extensions.*
import org.javacord.api.entity.channel.TextChannel import org.javacord.api.entity.channel.TextChannel
@ -16,80 +19,80 @@ import java.util.concurrent.CompletableFuture
import java.util.concurrent.CompletionException import java.util.concurrent.CompletionException
object Util { object Util {
inline fun <T> T.applyIf(condition: Boolean, op: (T) -> T): T { inline fun <T> T.applyIf(condition: Boolean, op: (T) -> T): T {
return if (condition) op(this) else this return if (condition) op(this) else this
}
fun hasOneOf(messageAuthor: MessageAuthor, roles: Set<Role>): Boolean {
return messageAuthor.asUser().asOption().flatMap { user ->
user.roles().find { it 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 { fun findUser(idOrName: String): Option<User> {
return messageAuthor.asUser().asOption().flatMap { user -> return when {
user.roles().find { it in roles } idOrName.isEntityId() -> server.getMemberById(idOrName).asOption()
}.nonEmpty() idOrName.contains('#') -> server.getMemberByDiscriminatedNameIgnoreCase(idOrName).asOption()
else -> server.membersByName(idOrName).firstOrNone()
} }
}
private val channelIdRegex = Regex("\\d{18}") fun <T> CompletableFuture<T>.asOption(): Option<T> {
private fun String.isEntityId() = channelIdRegex.matches(this) return try {
Option.just(join())
fun findRole(idOrName: String): Either<String, Role> { } catch (e: CompletionException) {
return when { Option.empty()
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> { fun <T> Optional<T>.asOption(): Option<T> = if (this.isPresent) Option.just(this.get()) else Option.empty()
return when (size) {
1 -> Either.right(first()) fun findChannel(idOrName: String): Either<String, TextChannel> {
else -> Either.left(size) 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" }
} }
}
fun findUser(idOrName: String): Option<User> { inline fun checked(op: (() -> Unit)) {
return when { try {
idOrName.isEntityId() -> server.getMemberById(idOrName).asOption() op()
idOrName.contains('#') -> server.getMemberByDiscriminatedNameIgnoreCase(idOrName).asOption() } catch (e: Exception) {
else -> server.membersByName(idOrName).firstOrNone() Log.warn("An uncaught exception occurred.\n$e")
} Log.warn(e.stackTrace.joinToString("\n"))
} MessageUtil.sendEmbed(
Globals.api.owner.get(),
fun <T> CompletableFuture<T>.asOption(): Option<T> { EmbedBuilder()
return try { .setColor(Color.RED)
Option.just(join()) .addField("Error", "kagebot has encountered an error")
} catch (e: CompletionException) { .addField(
Option.empty() "$e", """```
}
}
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")} ${e.stackTrace.joinToString("\n\t")}
```""".trimIndent().run { applyIf(length > 1800) { substring(1..1800) } } ```""".trimIndent().run { applyIf(length > 1800) { substring(1..1800) } }
) )
) )
}
} }
}
} }

View File

@ -14,47 +14,47 @@ import org.javacord.api.event.message.MessageCreateEvent
private const val AUTHOR_PLACEHOLDER = "@@" private const val AUTHOR_PLACEHOLDER = "@@"
class Command( class Command(
val trigger: String, val trigger: String,
private val response: String? = null, private val response: String? = null,
private val permissions: Permissions?, private val permissions: Permissions?,
@JsonProperty("action") @JsonProperty("action")
private val actions: MessageActions?, private val actions: MessageActions?,
embed: List<String>?, embed: List<String>?,
feature: String?, feature: String?,
matchType: String? matchType: String?
) { ) {
val matchType: MatchType = matchType?.let { type -> val matchType: MatchType = matchType?.let { type ->
MatchType.values().find { it.name.equals(type, ignoreCase = true) } MatchType.values().find { it.name.equals(type, ignoreCase = true) }
?: throw IllegalArgumentException("Invalid [command.matchType]: “$matchType") ?: throw IllegalArgumentException("Invalid [command.matchType]: “$matchType")
} ?: MatchType.PREFIX } ?: MatchType.PREFIX
val regex: Regex? = if (this.matchType == MatchType.REGEX) Regex(trigger) else null val regex: Regex? = if (this.matchType == MatchType.REGEX) Regex(trigger) else null
val embed: EmbedBuilder? = embed?.let(MessageUtil::listToEmbed) val embed: EmbedBuilder? = embed?.let(MessageUtil::listToEmbed)
private val feature: MessageFeature? = feature?.let { Config.features.findByString(it) } 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) { fun execute(message: MessageCreateEvent) {
Log.info("Executing command ${this.trigger} triggered by user ${message.messageAuthor.discriminatedName} (ID: ${message.messageAuthor.id})") Log.info("Executing command ${this.trigger} triggered by user ${message.messageAuthor.discriminatedName} (ID: ${message.messageAuthor.id})")
Globals.commandCounter.incrementAndGet() Globals.commandCounter.incrementAndGet()
this.actions?.run(message, this) this.actions?.run(message, this)
this.response?.let { this.response?.let {
message.channel.sendMessage(respond(message.messageAuthor, it)) 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)
}
private fun respond(author: MessageAuthor, response: String) = private fun respond(author: MessageAuthor, response: String) =
response.replace(AUTHOR_PLACEHOLDER, author.mention()) response.replace(AUTHOR_PLACEHOLDER, author.mention())
} }
@Suppress("unused") @Suppress("unused")
enum class MatchType(val matches: (String, Command) -> Boolean) { enum class MatchType(val matches: (String, Command) -> Boolean) {
PREFIX({ message, command -> message.startsWith(command.trigger, ignoreCase = true) }), PREFIX({ message, command -> message.startsWith(command.trigger, ignoreCase = true) }),
CONTAINS({ message, command -> message.contains(command.trigger, ignoreCase = true) }), CONTAINS({ message, command -> message.contains(command.trigger, ignoreCase = true) }),
REGEX({ message, command -> command.regex!!.matches(message) }); 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 import org.javacord.api.event.message.MessageCreateEvent
class MessageActions( class MessageActions(
private val delete: Boolean = false, private val delete: Boolean = false,
private val redirect: MessageRedirect?, private val redirect: MessageRedirect?,
@JsonProperty("assign") @JsonProperty("assign")
private val assignment: RoleAssignment? private val assignment: RoleAssignment?
) { ) {
fun run(message: MessageCreateEvent, command: Command) { fun run(message: MessageCreateEvent, command: Command) {
if (delete) { if (delete) {
deleteMessage(message) deleteMessage(message)
}
redirect?.execute(message, command)
assignment?.assign(message)
} }
redirect?.execute(message, command)
assignment?.assign(message)
}
private fun deleteMessage(message: MessageCreateEvent) { private fun deleteMessage(message: MessageCreateEvent) {
if (message.message.canYouDelete()) { if (message.message.canYouDelete()) {
message.deleteMessage() message.deleteMessage()
message.messageAuthor.asUser().ifPresent { user -> message.messageAuthor.asUser().ifPresent { user ->
user.sendEmbed { user.sendEmbed {
addField("__Blacklisted__", Config.localization[LocalizationSpec.messageDeleted]) addField("__Blacklisted__", Config.localization[LocalizationSpec.messageDeleted])
addField("Original:", "${message.readableMessageContent}") addField("Original:", "${message.readableMessageContent}")
}
}
} else {
Log.info("Tried to delete a message without the necessary permissions. Channel: ${message.channel.id}")
} }
}
} 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 import org.javacord.api.event.message.MessageCreateEvent
class MessageRedirect(target: String, private val anonymous: Boolean = false) { 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) { fun execute(message: MessageCreateEvent, command: Command) {
val embed = MessageUtil.withEmbed { val embed = MessageUtil.withEmbed {
val redirectedText = message.readableMessageContent val redirectedText = message.readableMessageContent
.applyIf(command.matchType == MatchType.PREFIX) { content -> .applyIf(command.matchType == MatchType.PREFIX) { content ->
content.removePrefix(command.trigger).trim() 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")
} }
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

@ -7,22 +7,22 @@ import org.javacord.api.entity.permission.Role
import org.javacord.api.event.message.MessageCreateEvent import org.javacord.api.event.message.MessageCreateEvent
class Permissions( class Permissions(
hasOneOf: List<String>?, hasOneOf: List<String>?,
hasNoneOf: List<String>?, hasNoneOf: List<String>?,
private val onlyDM: Boolean = false private val onlyDM: Boolean = false
) { ) {
private val hasOneOf: Option<Set<Role>> = resolveRoles(hasOneOf) private val hasOneOf: Option<Set<Role>> = resolveRoles(hasOneOf)
private val hasNoneOf: Option<Set<Role>> = resolveRoles(hasNoneOf) private val hasNoneOf: Option<Set<Role>> = resolveRoles(hasNoneOf)
private fun resolveRoles(hasOneOf: List<String>?): Option<Set<Role>> { private fun resolveRoles(hasOneOf: List<String>?): Option<Set<Role>> {
return Option.fromNullable(hasOneOf?.mapTo(mutableSetOf(), { Util.findRole(it).unwrap() })) return Option.fromNullable(hasOneOf?.mapTo(mutableSetOf(), { Util.findRole(it).unwrap() }))
} }
fun isAllowed(message: MessageCreateEvent): Boolean = when { fun isAllowed(message: MessageCreateEvent): Boolean = when {
message.messageAuthor.isBotOwner -> true message.messageAuthor.isBotOwner -> true
onlyDM && !message.isPrivateMessage -> false onlyDM && !message.isPrivateMessage -> false
// returns true if the Option is empty (case for no restrictions) // returns true if the Option is empty (case for no restrictions)
else -> hasOneOf.forall { Util.hasOneOf(message.messageAuthor, it) } && else -> hasOneOf.forall { Util.hasOneOf(message.messageAuthor, it) } &&
hasNoneOf.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 import org.javacord.api.event.message.MessageCreateEvent
class RoleAssignment(@JsonProperty("role") role: String) { 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( fun assign(message: MessageCreateEvent) = message.getUser().fold(
{ Log.warn("Could not find user ${message.messageAuthor.name} for role assign") }, { Log.warn("Could not find user ${message.messageAuthor.name} for role assign") },
{ it.addRole(role, "Requested via command.") } { 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 import org.javacord.api.entity.server.Server
object Config { object Config {
val systemSpec = Config { addSpec(SystemSpec) }.from.toml val systemSpec = Config { addSpec(SystemSpec) }.from.toml
val localeSpec = Config { addSpec(LocalizationSpec) }.from.toml val localeSpec = Config { addSpec(LocalizationSpec) }.from.toml
val commandSpec = Config { addSpec(CommandSpec) }.from.toml val commandSpec = Config { addSpec(CommandSpec) }.from.toml
val featureSpec = Config { addSpec(FeatureSpec) }.from.toml val featureSpec = Config { addSpec(FeatureSpec) }.from.toml
lateinit var system: Config lateinit var system: Config
lateinit var localization: Config lateinit var localization: Config
lateinit var commandConfig: Config lateinit var commandConfig: Config
lateinit var featureConfig: Config lateinit var featureConfig: Config
lateinit var server: Server lateinit var server: Server
// for easier access // for easier access
val features: Features get() = featureConfig[FeatureSpec.features] val features: Features get() = featureConfig[FeatureSpec.features]
val commands: ListK<Command> get() = commandConfig[CommandSpec.command].k() 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 import java.io.File
object ConfigParser { object ConfigParser {
internal const val DEFAULT_CONFIG_PATH = "config.toml" internal const val DEFAULT_CONFIG_PATH = "config.toml"
val configFile: File = File(DEFAULT_CONFIG_PATH) val configFile: File = File(DEFAULT_CONFIG_PATH)
fun initialLoad(file: String) = runBlocking { fun initialLoad(file: String) = runBlocking {
Either.catch { Either.catch {
val configFile = getFile(file) val configFile = getFile(file)
val config = Config.systemSpec.file(configFile) val config = Config.systemSpec.file(configFile)
Config.system = config Config.system = config
Config.server = Globals.api.getServerById(config[serverId]) Config.server = Globals.api.getServerById(config[serverId])
.orElseThrow { IllegalArgumentException("Invalid server configured.") } .orElseThrow { IllegalArgumentException("Invalid server configured.") }
Config.localization = Config.localeSpec.file(configFile) Config.localization = Config.localeSpec.file(configFile)
Config.featureConfig = Config.featureSpec.file(configFile) Config.featureConfig = Config.featureSpec.file(configFile)
Config.commandConfig = Config.commandSpec.file(configFile) Config.commandConfig = Config.commandSpec.file(configFile)
}
} }
}
private fun getFile(path: String): File { private fun getFile(path: String): File {
val file = File(path) val file = File(path)
if (file.isFile) { if (file.isFile) {
return file return file
}
println("Config not found, falling back to defaults...")
return File(this::class.java.classLoader.getResource(path)!!.toURI())
} }
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 import java.awt.Color
object SystemSpec : ConfigSpec() { object SystemSpec : ConfigSpec() {
private val rawColor by optional("#1793d0", name = "color") private val rawColor by optional("#1793d0", name = "color")
val serverId by required<String>() val serverId by required<String>()
val color by kotlin.lazy { Color.decode(system[rawColor])!! } val color by kotlin.lazy { Color.decode(system[rawColor])!! }
} }
object LocalizationSpec : ConfigSpec() { object LocalizationSpec : ConfigSpec() {
val redirectedMessage by optional("says") val redirectedMessage by optional("says")
val messageDeleted by optional("Your message was deleted.") val messageDeleted by optional("Your message was deleted.")
val timeout by optional("You have been timed out for @@ minutes.") val timeout by optional("You have been timed out for @@ minutes.")
} }
object CommandSpec : ConfigSpec(prefix = "") { object CommandSpec : ConfigSpec(prefix = "") {
val command by optional(emptyList<Command>()) val command by optional(emptyList<Command>())
} }
object FeatureSpec : ConfigSpec(prefix = "") { object FeatureSpec : ConfigSpec(prefix = "") {
val features by optional(Features(), 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 import moe.kageru.kagebot.config.Config
object CronD { object CronD {
fun startAll() { fun startAll() {
GlobalScope.launch { GlobalScope.launch {
minutely() minutely()
}
} }
}
private suspend fun minutely() { private suspend fun minutely() {
while (true) { while (true) {
Config.features.timeout?.checkAndRelease() Config.features.timeout?.checkAndRelease()
delay(60_000) delay(60_000)
}
} }
}
} }

View File

@ -7,16 +7,16 @@ import arrow.core.getOrElse
import arrow.typeclasses.Functor import arrow.typeclasses.Functor
fun <L, R> Either<L, R>.on(op: (R) -> Unit): Either<L, R> { fun <L, R> Either<L, R>.on(op: (R) -> Unit): Either<L, R> {
this.map { op(it) } this.map { op(it) }
return this return this
} }
fun <T> Either<*, T>.unwrap(): T = getOrElse { error("Attempted to unwrap Either.left") } fun <T> Either<*, T>.unwrap(): T = getOrElse { 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) -> 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) } } 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) -> 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) } } 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.ListK
import arrow.core.Option import arrow.core.Option
import arrow.core.k import arrow.core.k
import org.javacord.api.entity.channel.ServerTextChannel
import moe.kageru.kagebot.Util.asOption import moe.kageru.kagebot.Util.asOption
import moe.kageru.kagebot.config.Config import moe.kageru.kagebot.config.Config
import org.javacord.api.entity.channel.ChannelCategory 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.permission.Role
import org.javacord.api.entity.server.Server import org.javacord.api.entity.server.Server
import org.javacord.api.entity.user.User import org.javacord.api.entity.user.User

View File

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

View File

@ -1,26 +1,26 @@
package moe.kageru.kagebot.features package moe.kageru.kagebot.features
class Features( class Features(
val welcome: WelcomeFeature? = null, val welcome: WelcomeFeature? = null,
val timeout: TimeoutFeature? = null, val timeout: TimeoutFeature? = null,
vc: TempVCFeature = TempVCFeature(null) vc: TempVCFeature = TempVCFeature(null)
) { ) {
private val debug = DebugFeature() private val debug = DebugFeature()
private val help = HelpFeature() private val help = HelpFeature()
private val getConfig = GetConfigFeature() private val getConfig = GetConfigFeature()
private val setConfig = SetConfigFeature() private val setConfig = SetConfigFeature()
private val all = listOf(welcome, debug, help, getConfig, setConfig, timeout, vc) private val all = listOf(welcome, debug, help, getConfig, setConfig, timeout, vc)
private val featureMap = mapOf( private val featureMap = mapOf(
"help" to help, "help" to help,
"debug" to debug, "debug" to debug,
"welcome" to welcome, "welcome" to welcome,
"getConfig" to getConfig, "getConfig" to getConfig,
"setConfig" to setConfig, "setConfig" to setConfig,
"timeout" to timeout, "timeout" to timeout,
"vc" to vc "vc" to vc
) )
fun findByString(feature: String) = featureMap[feature] fun findByString(feature: String) = featureMap[feature]
fun eventFeatures() = all.filterIsInstance<EventFeature>() 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. * Simple message handler to send the current config file via message attachment.
*/ */
class GetConfigFeature : MessageFeature { class GetConfigFeature : MessageFeature {
override fun handle(message: MessageCreateEvent) { override fun handle(message: MessageCreateEvent) {
message.channel.sendMessage(ConfigParser.configFile) message.channel.sendMessage(ConfigParser.configFile)
} }
} }

View File

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

View File

@ -6,23 +6,23 @@ import moe.kageru.kagebot.config.ConfigParser
import org.javacord.api.event.message.MessageCreateEvent import org.javacord.api.event.message.MessageCreateEvent
class SetConfigFeature : MessageFeature { class SetConfigFeature : MessageFeature {
@ExperimentalStdlibApi @ExperimentalStdlibApi
override fun handle(message: MessageCreateEvent) { override fun handle(message: MessageCreateEvent) {
if (message.messageAttachments.size != 1) { if (message.messageAttachments.size != 1) {
message.channel.sendMessage("Error: please attach the new config to your message.") message.channel.sendMessage("Error: please attach the new config to your message.")
return return
}
val newConfig = message.messageAttachments[0].url.openStream().readAllBytes().decodeToString()
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}```")
}
}
} }
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,46 @@ import org.javacord.api.entity.channel.ServerVoiceChannel
import org.javacord.api.event.message.MessageCreateEvent import org.javacord.api.event.message.MessageCreateEvent
class TempVCFeature(@JsonProperty("category") category: String? = null) : EventFeature, MessageFeature { 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) { override fun handle(message: MessageCreateEvent): Unit = with(message) {
Either.cond(' ' in readableMessageContent, Either.cond(' ' in readableMessageContent,
{ readableMessageContent.split(' ', limit = 2).last() }, { readableMessageContent.split(' ', limit = 2).last() },
{ "Invalid syntax, expected `<command> <userlimit>`" }) { "Invalid syntax, expected `<command> <userlimit>`" })
.flatMap { limit -> .flatMap { limit ->
limit.toIntOrNull().rightIfNotNull { "Invalid syntax, expected a number as limit, got $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." }) }.filterOrElse({ it < 99 }, { "Error: can’t create a channel with that many users." })
.fold({ err -> channel.sendMessage(err) }, .fold({ err -> channel.sendMessage(err) },
{ limit -> { limit ->
createChannel(message, limit) createChannel(message, limit)
channel.sendMessage("Done") 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) { private fun deleteChannel(channel: ServerVoiceChannel) =
api.addServerVoiceChannelMemberLeaveListener { event -> channel.delete("Empty temporary channel").asOption().fold(
if (event.channel.connectedUsers.isEmpty() && Dao.isTemporaryVC(event.channel.idAsString)) { { Log.warn("Attempted to delete temporary VC without the necessary permissions") },
deleteChannel(event.channel) { Dao.removeTemporaryVC(channel.idAsString) }
} )
}
}
private fun deleteChannel(channel: ServerVoiceChannel) = private fun createChannel(message: MessageCreateEvent, limit: Int): Unit =
channel.delete("Empty temporary channel").asOption().fold( Config.server.createVoiceChannelBuilder().apply {
{ Log.warn("Attempted to delete temporary VC without the necessary permissions") }, setUserlimit(limit)
{ Dao.removeTemporaryVC(channel.idAsString) } 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 = private fun generateChannelName(message: MessageCreateEvent): String =
Config.server.createVoiceChannelBuilder().apply { "${message.messageAuthor.name}’s volatile corner"
setUserlimit(limit)
setName(generateChannelName(message))
setAuditLogReason("Created temporary VC for user ${message.messageAuthor.discriminatedName}")
setCategory(category)
}.create().asOption().fold(
{ Log.warn("Attempted to create temporary VC without the necessary permissions") },
{ channel -> Dao.addTemporaryVC(channel.idAsString) })
private fun generateChannelName(message: MessageCreateEvent): String =
"${message.messageAuthor.name}’s volatile corner"
} }

View File

@ -23,58 +23,58 @@ import java.time.Duration
import java.time.Instant import java.time.Instant
class TimeoutFeature(@JsonProperty("role") role: String) : MessageFeature { 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) { override fun handle(message: MessageCreateEvent) {
message.readableMessageContent.split(' ', limit = 4).let { args -> message.readableMessageContent.split(' ', limit = 4).let { args ->
Either.cond( Either.cond(
args.size >= 3, args.size >= 3,
{ Tuple3(args[1], args[2], args.getOrNull(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." } { "Error: expected “<command> <user> <time> [<reason>]”. If the name contains spaces, please use the user ID instead." }
).flatMap { ).flatMap {
it.mapFirst(Option.applicative(), ::findUser).fix() it.mapFirst(Option.applicative(), ::findUser).fix()
.toEither { "Error: User ${it.a} not found, consider using the user ID" } .toEither { "Error: User ${it.a} not found, consider using the user ID" }
}.flatMap { }.flatMap {
it.mapSecond(Either.applicative()) { time -> it.mapSecond(Either.applicative()) { time ->
time.toLongOrNull().rightIfNotNull { "Error: malformed time “${it.b}" } time.toLongOrNull().rightIfNotNull { "Error: malformed time “${it.b}" }
}.fix() }.fix()
}.on { (user, time, _) -> }.on { (user, time, _) ->
applyTimeout(user, time) applyTimeout(user, time)
}.fold( }.fold(
{ error -> message.channel.sendMessage(error) }, { error -> message.channel.sendMessage(error) },
{ (user, time, reason) -> { (user, time, reason) ->
user.sendEmbed { user.sendEmbed {
addField("Timeout", Config.localization[LocalizationSpec.timeout].replace("@@", "$time")) addField("Timeout", Config.localization[LocalizationSpec.timeout].replace("@@", "$time"))
reason?.let { addField("Reason", it) } reason?.let { addField("Reason", it) }
} }
}
)
} }
)
} }
}
private fun applyTimeout(user: User, time: Long) { private fun applyTimeout(user: User, time: Long) {
val oldRoles = user.roles() val oldRoles = user.roles()
.filter { !it.isManaged } .filter { !it.isManaged }
.onEach { user.removeRole(it) } .onEach { user.removeRole(it) }
.map { it.id } .map { it.id }
user.addRole(timeoutRole) user.addRole(timeoutRole)
val releaseTime = Instant.now().plus(Duration.ofMinutes(time)).epochSecond val releaseTime = Instant.now().plus(Duration.ofMinutes(time)).epochSecond
Dao.saveTimeout(releaseTime, user.id, oldRoles) Dao.saveTimeout(releaseTime, user.id, oldRoles)
Log.info("Removed roles ${oldRoles.joinToString()} from user ${user.discriminatedName}") Log.info("Removed roles ${oldRoles.joinToString()} from user ${user.discriminatedName}")
} }
fun checkAndRelease(): Unit = Dao.getAllTimeouts() fun checkAndRelease(): Unit = Dao.getAllTimeouts()
.filter { releaseTime -> Instant.now().epochSecond > releaseTime } .filter { releaseTime -> Instant.now().epochSecond > releaseTime }
.map { Dao.deleteTimeout(it) } .map { Dao.deleteTimeout(it) }
.map { it.destructured() } .map { it.destructured() }
.forEach { (userId, roleIds) -> .forEach { (userId, roleIds) ->
Config.server.memberById(userId).fold( Config.server.memberById(userId).fold(
{ Log.warn("Tried to free user $userId, but couldn’t find them on the server anymore") }, { Log.warn("Tried to free user $userId, but couldn’t find them on the server anymore") },
{ user -> { user ->
roleIds.forEach { findRole("$it").map(user::addRole) } roleIds.forEach { findRole("$it").map(user::addRole) }
user.removeRole(timeoutRole) user.removeRole(timeoutRole)
Log.info("Lifted timeout from user ${user.discriminatedName}. Stored roles ${roleIds.joinToString()}") Log.info("Lifted timeout from user ${user.discriminatedName}. Stored roles ${roleIds.joinToString()}")
}
)
} }
)
}
} }

View File

@ -13,41 +13,41 @@ import org.javacord.api.event.message.MessageCreateEvent
import org.javacord.api.event.server.member.ServerMemberJoinEvent import org.javacord.api.event.server.member.ServerMemberJoinEvent
class WelcomeFeature( class WelcomeFeature(
content: List<String>?, content: List<String>?,
fallbackChannel: String?, fallbackChannel: String?,
private val fallbackMessage: String? private val fallbackMessage: String?
) : MessageFeature, EventFeature { ) : 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) { override fun register(api: DiscordApi) {
api.addServerMemberJoinListener { event -> api.addServerMemberJoinListener { event ->
checked { welcomeUser(event) } checked { welcomeUser(event) }
}
} }
}
fun welcomeUser(event: ServerMemberJoinEvent) { fun welcomeUser(event: ServerMemberJoinEvent) {
Log.info("User ${event.user.discriminatedName} joined") Log.info("User ${event.user.discriminatedName} joined")
val message = event.user.sendMessage(embed) val message = event.user.sendMessage(embed)
// If the user disabled direct messages, try the fallback (if defined) // If the user disabled direct messages, try the fallback (if defined)
if (message.asOption().isEmpty() && hasFallback()) { if (message.asOption().isEmpty() && hasFallback()) {
fallbackChannel!!.sendMessage( fallbackChannel!!.sendMessage(
fallbackMessage!!.replace("@@", event.user.mentionTag) fallbackMessage!!.replace("@@", event.user.mentionTag)
) )
}
} }
}
override fun handle(message: MessageCreateEvent) { override fun handle(message: MessageCreateEvent) {
embed?.let { embed?.let {
MessageUtil.sendEmbed(message.channel, it) MessageUtil.sendEmbed(message.channel, it)
} ?: Log.info("Welcome command was triggered, but no welcome embed defined.") } ?: Log.info("Welcome command was triggered, but no welcome embed defined.")
} }
private fun hasFallback(): Boolean = fallbackChannel != null && fallbackMessage != null private fun hasFallback(): Boolean = fallbackChannel != null && fallbackMessage != null
private val fallbackChannel: TextChannel? = fallbackChannel?.let { channel -> private val fallbackChannel: TextChannel? = fallbackChannel?.let { channel ->
requireNotNull(fallbackMessage) { requireNotNull(fallbackMessage) {
"[feature.welcome.fallbackMessage] must not be null if fallbackChannel is defined" "[feature.welcome.fallbackMessage] must not be null if fallbackChannel is defined"
}
Util.findChannel(channel).unwrap()
} }
Util.findChannel(channel).unwrap()
}
} }

View File

@ -5,40 +5,40 @@ import org.mapdb.DBMaker
import org.mapdb.Serializer import org.mapdb.Serializer
object Dao { object Dao {
private val db = DBMaker.fileDB("kagebot.db").fileMmapEnable().transactionEnable().make() private val db = DBMaker.fileDB("kagebot.db").fileMmapEnable().transactionEnable().make()
private val prisoners = db.hashMap("timeout", Serializer.LONG, Serializer.LONG_ARRAY).createOrOpen() 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 commands = db.hashMap("commands", Serializer.STRING, Serializer.INTEGER).createOrOpen()
private val tempVcs = db.hashSet("vcs", Serializer.STRING).createOrOpen() private val tempVcs = db.hashSet("vcs", Serializer.STRING).createOrOpen()
fun saveTimeout(releaseTime: Long, user: Long, roles: List<Long>) { fun saveTimeout(releaseTime: Long, user: Long, roles: List<Long>) {
prisoners[releaseTime] = (listOf(user) + roles).toLongArray() prisoners[releaseTime] = (listOf(user) + roles).toLongArray()
} }
fun setCommandCounter(count: Int) { fun setCommandCounter(count: Int) {
commands["total"] = count 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> { fun deleteTimeout(releaseTime: Long): List<Long> {
val timeout = prisoners[releaseTime]!! val timeout = prisoners[releaseTime]!!
prisoners.remove(releaseTime) prisoners.remove(releaseTime)
return timeout.toList() return timeout.toList()
} }
fun isTemporaryVC(channel: String): Boolean { fun isTemporaryVC(channel: String): Boolean {
return channel in tempVcs return channel in tempVcs
} }
fun addTemporaryVC(channel: String) { fun addTemporaryVC(channel: String) {
tempVcs.add(channel) tempVcs.add(channel)
} }
fun removeTemporaryVC(channel: String) { fun removeTemporaryVC(channel: String) {
tempVcs.remove(channel) tempVcs.remove(channel)
} }
} }

View File

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

View File

@ -23,124 +23,124 @@ import java.io.File
import java.util.* import java.util.*
object TestUtil { object TestUtil {
private val TIMEOUT_ROLE = mockk<Role> { private val TIMEOUT_ROLE = mockk<Role> {
every { id } returns 123 every { id } returns 123
} }
val TEST_ROLE = mockk<Role> { val TEST_ROLE = mockk<Role> {
every { id } returns 1 every { id } returns 1
every { isManaged } returns false every { isManaged } returns false
} }
fun mockMessage( fun mockMessage(
content: String, content: String,
replies: MutableList<String> = mutableListOf(), replies: MutableList<String> = mutableListOf(),
replyEmbeds: MutableList<EmbedBuilder> = mutableListOf(), replyEmbeds: MutableList<EmbedBuilder> = mutableListOf(),
files: MutableList<File> = mutableListOf(), files: MutableList<File> = mutableListOf(),
isBot: Boolean = false isBot: Boolean = false
): MessageCreateEvent { ): MessageCreateEvent {
return mockk { return mockk {
every { messageContent } returns content every { messageContent } returns content
every { readableMessageContent } returns content every { readableMessageContent } returns content
every { channel.sendMessage(capture(replies)) } returns mockk(relaxed = true) { every { channel.sendMessage(capture(replies)) } returns mockk(relaxed = true) {
every { isCompletedExceptionally } returns false every { isCompletedExceptionally } returns false
} }
every { channel.sendMessage(capture(replyEmbeds)) } returns mockk(relaxed = true) { every { channel.sendMessage(capture(replyEmbeds)) } returns mockk(relaxed = true) {
every { isCompletedExceptionally } returns false every { isCompletedExceptionally } returns false
} }
every { channel.sendMessage(capture(files)) } returns mockk(relaxed = true) { every { channel.sendMessage(capture(files)) } returns mockk(relaxed = true) {
every { isCompletedExceptionally } returns false every { isCompletedExceptionally } returns false
} }
every { message.canYouDelete() } returns true every { message.canYouDelete() } returns true
every { isPrivateMessage } returns false every { isPrivateMessage } returns false
// We can’t use a nested mock here because other fields of messageAuthor might // We can’t use a nested mock here because other fields of messageAuthor might
// get overwritten by other tests, which would delete a nested mock. // get overwritten by other tests, which would delete a nested mock.
every { messageAuthor.id } returns 1 every { messageAuthor.id } returns 1
every { messageAuthor.discriminatedName } returns "testuser#1234" every { messageAuthor.discriminatedName } returns "testuser#1234"
every { messageAuthor.isBotUser } returns isBot every { messageAuthor.isBotUser } returns isBot
every { messageAuthor.isYourself } returns isBot every { messageAuthor.isYourself } returns isBot
every { messageAuthor.isBotOwner } returns false every { messageAuthor.isBotOwner } returns false
every { messageAuthor.asUser() } returns Optional.of(messageableAuthor(replyEmbeds)) every { messageAuthor.asUser() } returns Optional.of(messageableAuthor(replyEmbeds))
every { messageAuthor.name } returns "kageru" 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)
} }
Globals.api = mockk(relaxed = true) {
fun messageableAuthor(messages: MutableList<EmbedBuilder> = mutableListOf()): User { every { getServerById(any<String>()) } returns Optional.of(mockk(relaxed = true) {
return mockk { every { icon.ifPresent(any()) } just Runs
every { roles() } returns ListK.empty() every { channelById(any()) } returns Option.just(channel)
every { sendMessage(capture(messages)) } returns mockk(relaxed = true) 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 prepareTestEnvironment( fun testMessageSuccess(content: String, result: String) {
sentEmbeds: MutableList<EmbedBuilder> = mutableListOf(), val calls = mutableListOf<String>()
sentMessages: MutableList<String> = mutableListOf(), mockMessage(content, replies = calls).process()
dmEmbeds: MutableList<EmbedBuilder> = mutableListOf() calls shouldBe mutableListOf(result)
) { }
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 testMessageSuccess(content: String, result: String) { fun embedToString(embed: EmbedBuilder): String {
val calls = mutableListOf<String>() return (embed.delegate as EmbedBuilderDelegateImpl).toJsonNode().toString()
mockMessage(content, replies = calls).process() }
calls shouldBe mutableListOf(result)
}
fun embedToString(embed: EmbedBuilder): String { fun withCommands(config: String, test: (() -> Unit)) {
return (embed.delegate as EmbedBuilderDelegateImpl).toJsonNode().toString() val oldCmds = Config.commandConfig
} Config.commandConfig = Config.commandSpec.string(config)
test()
Config.commandConfig = oldCmds
}
fun withCommands(config: String, test: (() -> Unit)) { fun withReplyContents(
val oldCmds = Config.commandConfig expected: List<String> = emptyList(),
Config.commandConfig = Config.commandSpec.string(config) unexpected: List<String> = emptyList(),
test() op: (MutableList<EmbedBuilder>) -> Unit
Config.commandConfig = oldCmds ) {
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) {
fun withReplyContents( replyString shouldNotContain string
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
}
} }
}
} }

View File

@ -27,284 +27,284 @@ import org.javacord.api.entity.user.User
import java.util.* import java.util.*
class CommandTest : StringSpec({ class CommandTest : StringSpec({
prepareTestEnvironment() prepareTestEnvironment()
"should increment command counter" { "should increment command counter" {
withCommands( withCommands(
""" """
[[command]] [[command]]
trigger = "!ping" trigger = "!ping"
response = "pong" response = "pong"
""".trimIndent() """.trimIndent()
) { ) {
val before = Globals.commandCounter.get() val before = Globals.commandCounter.get()
testMessageSuccess("!ping", "pong") testMessageSuccess("!ping", "pong")
Globals.commandCounter.get() shouldBe (before + 1) Globals.commandCounter.get() shouldBe (before + 1)
}
} }
"should match prefix command" { }
withCommands( "should match prefix command" {
""" withCommands(
[[command]] """
trigger = "!ping" [[command]]
response = "pong" trigger = "!ping"
""".trimIndent() response = "pong"
) { """.trimIndent()
testMessageSuccess("!ping", "pong") ) {
} testMessageSuccess("!ping", "pong")
} }
"should print embed for command" { }
val calls = mutableListOf<EmbedBuilder>() "should print embed for command" {
prepareTestEnvironment(calls) val calls = mutableListOf<EmbedBuilder>()
val heading = "heading 1" prepareTestEnvironment(calls)
val content = "this is the first paragraph of the embed" val heading = "heading 1"
withCommands( val content = "this is the first paragraph of the embed"
""" withCommands(
[[command]] """
trigger = "!embed" [[command]]
embed = [ "$heading", "$content" ] trigger = "!embed"
""".trimIndent() embed = [ "$heading", "$content" ]
) { """.trimIndent()
TestUtil.withReplyContents(expected = listOf(heading, content)) { ) {
mockMessage("!embed", replyEmbeds = it).process() TestUtil.withReplyContents(expected = listOf(heading, content)) {
} mockMessage("!embed", replyEmbeds = it).process()
} }
} }
"should match contains command" { }
withCommands( "should match contains command" {
""" withCommands(
[[command]] """
trigger = "somewhere" [[command]]
response = "found it" trigger = "somewhere"
matchType = "CONTAINS" response = "found it"
""".trimIndent() matchType = "CONTAINS"
) { """.trimIndent()
testMessageSuccess("the trigger is somewhere in this message", "found it") ) {
} testMessageSuccess("the trigger is somewhere in this message", "found it")
} }
"should match regex command" { }
withCommands( "should match regex command" {
""" withCommands(
[[command]] """
trigger = "A.+B" [[command]]
response = "regex matched" trigger = "A.+B"
matchType = "REGEX" response = "regex matched"
""".trimIndent() matchType = "REGEX"
) { """.trimIndent()
testMessageSuccess("AcsdB", "regex matched") ) {
} testMessageSuccess("AcsdB", "regex matched")
} }
"should ping author" { }
withCommands( "should ping author" {
""" withCommands(
[[command]] """
trigger = "answer me" [[command]]
response = "@@ there you go" trigger = "answer me"
""".trimIndent() response = "@@ there you go"
) { """.trimIndent()
testMessageSuccess("answer me", "<@1> there you go") ) {
} testMessageSuccess("answer me", "<@1> there you go")
} }
"should not react to own message" { }
withCommands( "should not react to own message" {
""" withCommands(
[[command]] """
trigger = "!ping" [[command]]
response = "pong" trigger = "!ping"
""".trimIndent() response = "pong"
) { """.trimIndent()
val calls = mutableListOf<String>() ) {
mockMessage("!ping", replies = calls, isBot = true).process() val calls = mutableListOf<String>()
calls shouldBe mutableListOf() mockMessage("!ping", replies = calls, isBot = true).process()
} calls shouldBe mutableListOf()
} }
"should delete messages and send copy to author" { }
withCommands( "should delete messages and send copy to author" {
""" withCommands(
[[command]] """
trigger = "delet this" [[command]]
[command.action] trigger = "delet this"
delete = true [command.action]
""".trimIndent() delete = true
) { """.trimIndent()
val messageContent = "delet this" ) {
TestUtil.withReplyContents(expected = listOf(messageContent)) { val messageContent = "delet this"
val mockMessage = mockMessage(messageContent) TestUtil.withReplyContents(expected = listOf(messageContent)) {
every { mockMessage.deleteMessage() } returns mockk() val mockMessage = mockMessage(messageContent)
every { mockMessage.messageAuthor.asUser() } returns Optional.of(messageableAuthor(it)) every { mockMessage.deleteMessage() } returns mockk()
mockMessage.process() every { mockMessage.messageAuthor.asUser() } returns Optional.of(messageableAuthor(it))
} mockMessage.process()
} }
} }
"should refuse command without permissions" { }
withCommands( "should refuse command without permissions" {
""" withCommands(
[[command]] """
trigger = "!restricted" [[command]]
response = "access granted" trigger = "!restricted"
[command.permissions] response = "access granted"
hasOneOf = [ [command.permissions]
"testrole", hasOneOf = [
] "testrole",
""".trimIndent() ]
) { """.trimIndent()
val replies = mutableListOf<String>() ) {
val mockMessage = mockMessage("!restricted", replies = replies) val replies = mutableListOf<String>()
mockMessage.process() val mockMessage = mockMessage("!restricted", replies = replies)
replies shouldBe mutableListOf() mockMessage.process()
} replies shouldBe mutableListOf()
} }
"should accept restricted command for owner" { }
withCommands( "should accept restricted command for owner" {
""" withCommands(
[[command]] """
trigger = "!restricted" [[command]]
response = "access granted" trigger = "!restricted"
[command.permissions] response = "access granted"
hasOneOf = [ [command.permissions]
"testrole" hasOneOf = [
] "testrole"
""".trimIndent() ]
) { """.trimIndent()
val calls = mutableListOf<String>() ) {
val mockMessage = mockMessage("!restricted", replies = calls) val calls = mutableListOf<String>()
every { mockMessage.messageAuthor.isBotOwner } returns true val mockMessage = mockMessage("!restricted", replies = calls)
mockMessage.process() every { mockMessage.messageAuthor.isBotOwner } returns true
calls shouldBe mutableListOf("access granted") mockMessage.process()
} calls shouldBe mutableListOf("access granted")
} }
"should accept restricted command with permissions" { }
withCommands( "should accept restricted command with permissions" {
""" withCommands(
[[command]] """
trigger = "!restricted" [[command]]
response = "access granted" trigger = "!restricted"
[command.permissions] response = "access granted"
hasOneOf = [ [command.permissions]
"testrole" hasOneOf = [
] "testrole"
""".trimIndent() ]
) { """.trimIndent()
val calls = mutableListOf<String>() ) {
val mockMessage = mockMessage("!restricted", replies = calls) val calls = mutableListOf<String>()
every { mockMessage.messageAuthor.asUser() } returns Optional.of(mockk { val mockMessage = mockMessage("!restricted", replies = calls)
every { roles() } returns ListK.just( every { mockMessage.messageAuthor.asUser() } returns Optional.of(mockk {
Config.server.rolesByName("testrole").first() every { roles() } returns ListK.just(
) Config.server.rolesByName("testrole").first()
}) )
mockMessage.process() })
calls shouldBe mutableListOf("access granted") mockMessage.process()
} calls shouldBe mutableListOf("access granted")
} }
"should deny command to excluded roles" { }
withCommands( "should deny command to excluded roles" {
""" withCommands(
[[command]] """
trigger = "!almostUnrestricted" [[command]]
response = "access granted" trigger = "!almostUnrestricted"
[command.permissions] response = "access granted"
hasNoneOf = ["testrole"] [command.permissions]
""".trimIndent() hasNoneOf = ["testrole"]
) { """.trimIndent()
val calls = mutableListOf<String>() ) {
val mockMessage = mockMessage("!almostUnrestricted", replies = calls) val calls = mutableListOf<String>()
// with the banned role val mockMessage = mockMessage("!almostUnrestricted", replies = calls)
every { mockMessage.messageAuthor.asUser() } returns mockk { // with the banned role
every { isPresent } returns true every { mockMessage.messageAuthor.asUser() } returns mockk {
every { get().getRoles(any()) } returns listOf( every { isPresent } returns true
Config.server.rolesByName("testrole").first() every { get().getRoles(any()) } returns listOf(
) Config.server.rolesByName("testrole").first()
} )
mockMessage.process() }
mockMessage.process()
// without the role // without the role
every { mockMessage.messageAuthor.asUser() } returns mockk { every { mockMessage.messageAuthor.asUser() } returns mockk {
every { isPresent } returns true every { isPresent } returns true
every { get().getRoles(any()) } returns emptyList() every { get().getRoles(any()) } returns emptyList()
} }
mockMessage.process() mockMessage.process()
// first message didn’t answer anything // first message didn’t answer anything
calls shouldBe mutableListOf("access granted") calls shouldBe mutableListOf("access granted")
}
} }
"should refuse DM only message in server channel" { }
withCommands( "should refuse DM only message in server channel" {
""" withCommands(
[[command]] """
trigger = "!dm" [[command]]
response = "access granted" trigger = "!dm"
[command.permissions] response = "access granted"
onlyDM = true [command.permissions]
""".trimIndent() onlyDM = true
) { """.trimIndent()
val calls = mutableListOf<String>() ) {
mockMessage("!dm", replies = calls).process() val calls = mutableListOf<String>()
calls shouldBe mutableListOf() 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. * 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>() "should redirect" {
prepareTestEnvironment(calls) val calls = mutableListOf<EmbedBuilder>()
withCommands( prepareTestEnvironment(calls)
""" withCommands(
[[command]] """
trigger = "!redirect" [[command]]
response = "redirected" trigger = "!redirect"
[command.action.redirect] response = "redirected"
target = "testchannel" [command.action.redirect]
anonymous = true target = "testchannel"
""".trimIndent() anonymous = true
) { """.trimIndent()
val message = "this is a message" ) {
mockMessage("!redirect $message").process() val message = "this is a message"
calls.size shouldBe 1 mockMessage("!redirect $message").process()
embedToString(calls[0]) shouldContain "\"$message\"" calls.size shouldBe 1
} embedToString(calls[0]) shouldContain "\"$message\""
} }
"should assign" { }
withCommands( "should assign" {
""" withCommands(
[[command]] """
trigger = "!assign" [[command]]
[command.action.assign] trigger = "!assign"
role = "testrole" [command.action.assign]
""".trimIndent() role = "testrole"
) { """.trimIndent()
val roles = mutableListOf<Role>() ) {
val user = mockk<User> { val roles = mutableListOf<Role>()
every { addRole(capture(roles), "Requested via command.") } returns mockk() 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() every { Config.server.getMemberById(1) } returns Optional.of(user)
roles shouldBe mutableListOf(Util.findRole("testrole").unwrap()) mockMessage("!assign").process()
} roles shouldBe mutableListOf(Util.findRole("testrole").unwrap())
} }
"should create VC" { }
withCommands( "should create VC" {
""" withCommands(
[[command]] """
trigger = "!vc" [[command]]
feature = "vc" trigger = "!vc"
""".trimIndent() feature = "vc"
) { """.trimIndent()
testMessageSuccess("!vc 2", "Done") ) {
Dao.isTemporaryVC("12345") shouldBe true testMessageSuccess("!vc 2", "Done")
Dao.removeTemporaryVC("12345") Dao.isTemporaryVC("12345") shouldBe true
} Dao.removeTemporaryVC("12345")
} }
"should reject invalid vc command" { }
withCommands( "should reject invalid vc command" {
""" withCommands(
[[command]] """
trigger = "!vc" [[command]]
feature = "vc" trigger = "!vc"
""".trimIndent() feature = "vc"
) { """.trimIndent()
testMessageSuccess("!vc asd", "Invalid syntax, expected a number, got asd") ) {
Dao.isTemporaryVC("12345") shouldBe false testMessageSuccess("!vc asd", "Invalid syntax, expected a number, got asd")
} Dao.isTemporaryVC("12345") shouldBe false
} }
}
}) })

View File

@ -9,16 +9,17 @@ import moe.kageru.kagebot.TestUtil.withCommands
import java.io.File import java.io.File
class ConfigFeatureTest : ShouldSpec({ class ConfigFeatureTest : ShouldSpec({
TestUtil.prepareTestEnvironment() TestUtil.prepareTestEnvironment()
"getConfig should sent message with attachment" { "getConfig should sent message with attachment" {
withCommands(""" withCommands(
[[command]] """
trigger = "!getConfig" [[command]]
feature = "getConfig" trigger = "!getConfig"
""".trimIndent()) { feature = "getConfig"
val calls = mutableListOf<File>() """.trimIndent()) {
mockMessage("!getConfig", files = calls).process() val calls = mutableListOf<File>()
calls.size shouldBe 1 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 import org.javacord.api.event.message.MessageCreateEvent
class DebugFeatureTest : StringSpec({ class DebugFeatureTest : StringSpec({
TestUtil.prepareTestEnvironment() TestUtil.prepareTestEnvironment()
// this will fail if the bot tries to execute more than it should // this will fail if the bot tries to execute more than it should
// because the mock does not provide the necessary methods // because the mock does not provide the necessary methods
"should ignore regular users" { "should ignore regular users" {
val message = TestUtil.mockMessage("!debug") val message = TestUtil.mockMessage("!debug")
every { message.messageAuthor.isBotOwner } returns false every { message.messageAuthor.isBotOwner } returns false
message.process() message.process()
DebugFeature().handle(message) DebugFeature().handle(message)
verify(exactly = 0) { message.channel.sendMessage(any<EmbedBuilder>()) } verify(exactly = 0) { message.channel.sendMessage(any<EmbedBuilder>()) }
} }
"should return something" { "should return something" {
val message = mockk<MessageCreateEvent> { val message = mockk<MessageCreateEvent> {
every { messageAuthor.isBotOwner } returns true every { messageAuthor.isBotOwner } returns true
every { readableMessageContent } returns "!debug" every { readableMessageContent } returns "!debug"
every { channel.sendMessage(any<EmbedBuilder>()) } returns mockk() every { channel.sendMessage(any<EmbedBuilder>()) } returns mockk()
}
DebugFeature().handle(message)
verify(exactly = 1) { message.channel.sendMessage(any<EmbedBuilder>()) }
} }
DebugFeature().handle(message)
verify(exactly = 1) { message.channel.sendMessage(any<EmbedBuilder>()) }
}
}) })

View File

@ -14,46 +14,46 @@ import org.javacord.api.entity.message.embed.EmbedBuilder
import java.util.* import java.util.*
class HelpFeatureTest : StringSpec({ class HelpFeatureTest : StringSpec({
val sentEmbeds = mutableListOf<EmbedBuilder>() val sentEmbeds = mutableListOf<EmbedBuilder>()
TestUtil.prepareTestEnvironment(sentEmbeds = sentEmbeds) TestUtil.prepareTestEnvironment(sentEmbeds = sentEmbeds)
val commandConfig = """ val commandConfig = """
[[command]] [[command]]
trigger = "!help" trigger = "!help"
feature = "help" feature = "help"
[[command]] [[command]]
trigger = "!ping" trigger = "!ping"
[[command]] [[command]]
trigger = "!something" trigger = "!something"
[[command]] [[command]]
trigger = "not a prefix" trigger = "not a prefix"
matchType = "CONTAINS" matchType = "CONTAINS"
[[command]] [[command]]
trigger = "!prison" trigger = "!prison"
[command.permissions] [command.permissions]
hasOneOf = ["testrole"] hasOneOf = ["testrole"]
""".trimIndent() """.trimIndent()
"should show prefix command" { "should show prefix command" {
withCommands(commandConfig) { withCommands(commandConfig) {
val expected = listOf("!ping", "!something") val expected = listOf("!ping", "!something")
val unexpected = listOf("not a prefix", "!prison") val unexpected = listOf("not a prefix", "!prison")
withReplyContents(expected = expected, unexpected = unexpected) { replies -> withReplyContents(expected = expected, unexpected = unexpected) { replies ->
mockMessage("!help", replyEmbeds = replies).process() mockMessage("!help", replyEmbeds = replies).process()
} }
}
} }
"should show moderation commands for mod" { }
withCommands(commandConfig) { "should show moderation commands for mod" {
val expected = listOf("!ping", "!something", "!prison") withCommands(commandConfig) {
val unexpected = listOf("not a prefix") val expected = listOf("!ping", "!something", "!prison")
withReplyContents(expected = expected, unexpected = unexpected) { replies -> val unexpected = listOf("not a prefix")
val message = mockMessage("!help", replyEmbeds = replies) withReplyContents(expected = expected, unexpected = unexpected) { replies ->
every { message.messageAuthor.asUser() } returns Optional.of(mockk { val message = mockMessage("!help", replyEmbeds = replies)
every { getRoles(any()) } returns listOf( every { message.messageAuthor.asUser() } returns Optional.of(mockk {
Config.server.rolesByName("testrole").first() every { getRoles(any()) } returns listOf(
) Config.server.rolesByName("testrole").first()
}) )
message.process() })
} message.process()
} }
} }
}
}) })

View File

@ -10,53 +10,53 @@ import moe.kageru.kagebot.persistence.Dao
import org.javacord.api.entity.message.embed.EmbedBuilder import org.javacord.api.entity.message.embed.EmbedBuilder
class TimeoutFeatureTest : StringSpec({ class TimeoutFeatureTest : StringSpec({
TestUtil.prepareTestEnvironment() TestUtil.prepareTestEnvironment()
"should remove and store roles" { "should remove and store roles" {
clearTimeouts() clearTimeouts()
TestUtil.mockMessage("!timeout kageru 99999999").process() TestUtil.mockMessage("!timeout kageru 99999999").process()
Dao.getAllTimeouts().let { Dao.getAllTimeouts().let {
it.size shouldBe 1 it.size shouldBe 1
val user = Dao.deleteTimeout(it.first()) val user = Dao.deleteTimeout(it.first())
user shouldBe arrayOf(123, TEST_ROLE.id) 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()
} }
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 { companion object {
private fun clearTimeouts() { private fun clearTimeouts() {
Dao.getAllTimeouts().forEach { to -> Dao.getAllTimeouts().forEach { to ->
Dao.deleteTimeout(to) Dao.deleteTimeout(to)
} }
}
} }
}
} }

View File

@ -10,37 +10,37 @@ import org.javacord.api.entity.message.embed.EmbedBuilder
@ExperimentalStdlibApi @ExperimentalStdlibApi
class WelcomeFeatureTest : StringSpec({ class WelcomeFeatureTest : StringSpec({
TestUtil.prepareTestEnvironment() TestUtil.prepareTestEnvironment()
"should send welcome" { "should send welcome" {
val sentMessages = mutableListOf<EmbedBuilder>() val sentMessages = mutableListOf<EmbedBuilder>()
Config.features.welcome!!.welcomeUser( Config.features.welcome!!.welcomeUser(
mockk { mockk {
every { user } returns mockk { every { user } returns mockk {
every { discriminatedName } returns "testuser#1234" every { discriminatedName } returns "testuser#1234"
every { sendMessage(capture(sentMessages)) } returns mockk { every { sendMessage(capture(sentMessages)) } returns mockk {
every { join() } returns mockk() every { join() } returns mockk()
every { isCompletedExceptionally } returns false every { isCompletedExceptionally } returns false
} }
} }
} }
) )
sentMessages shouldBe mutableListOf(Config.features.welcome!!.embed) sentMessages shouldBe mutableListOf(Config.features.welcome!!.embed)
} }
"should send welcome fallback if DMs are disabled" { "should send welcome fallback if DMs are disabled" {
val message = mutableListOf<String>() val message = mutableListOf<String>()
TestUtil.prepareTestEnvironment(sentMessages = message) TestUtil.prepareTestEnvironment(sentMessages = message)
Config.features.welcome!!.welcomeUser( Config.features.welcome!!.welcomeUser(
mockk { mockk {
every { user } returns mockk { every { user } returns mockk {
every { discriminatedName } returns "testuser#1234" every { discriminatedName } returns "testuser#1234"
every { id } returns 123 every { id } returns 123
every { sendMessage(any<EmbedBuilder>()) } returns mockk { every { sendMessage(any<EmbedBuilder>()) } returns mockk {
every { join() } returns mockk() every { join() } returns mockk()
every { isCompletedExceptionally } returns true every { isCompletedExceptionally } returns true
} }
} }
} }
) )
message shouldBe mutableListOf("<@123> welcome") message shouldBe mutableListOf("<@123> welcome")
} }
}) })