Indent with 2 spaces instead of 4

master
kageru 4 years ago
parent 5c7efcd10e
commit 39083d8248
Signed by: kageru
GPG Key ID: 8282A2BEA4ADA3D2

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

@ -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())
}

@ -12,44 +12,44 @@ 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).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()
}
}

@ -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"
}
}

@ -9,53 +9,53 @@ import org.javacord.api.entity.message.embed.EmbedBuilder
import java.util.concurrent.CompletableFuture
object MessageUtil {
fun MessageAuthor.mention() = "<@$id>"
fun MessageAuthor.mention() = "<@$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 {
return EmbedBuilder().apply {
Config.server.icon.ifPresent { setThumbnail(it) }
setColor(SystemSpec.color)
op()
}
}
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())
}
}
}

@ -1,8 +1,11 @@
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.list.foldable.find
import arrow.core.firstOrNone
import moe.kageru.kagebot.config.Config.server
import moe.kageru.kagebot.extensions.*
import org.javacord.api.entity.channel.TextChannel
@ -16,80 +19,80 @@ 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<Role>): Boolean {
return messageAuthor.asUser().asOption().flatMap { user ->
user.roles().find { it in roles }
}.nonEmpty()
}
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)
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 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)
}
private fun <T> ListK<T>.getOnly(): Either<Int, T> {
return when (size) {
1 -> Either.right(first())
else -> Either.left(size)
}
}
fun findUser(idOrName: String): Option<User> {
return when {
idOrName.isEntityId() -> server.getMemberById(idOrName).asOption()
idOrName.contains('#') -> server.getMemberByDiscriminatedNameIgnoreCase(idOrName).asOption()
else -> server.membersByName(idOrName).firstOrNone()
}
fun 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> 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 <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 { "Cant DM user $idOrName" }
}
else -> server.channelsByName(idOrName).getOnly().mapLeft { "Found $it channels for $idOrName, expected 1" }
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 { "Cant 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", """```
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) } }
)
)
}
)
)
}
}
}

@ -14,47 +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) }
fun matches(msg: String) = this.matchType.matches(msg, this)
fun isAllowed(message: MessageCreateEvent) = permissions?.isAllowed(message) ?: true
fun execute(message: MessageCreateEvent) {
Log.info("Executing command ${this.trigger} triggered by user ${message.messageAuthor.discriminatedName} (ID: ${message.messageAuthor.id})")
Globals.commandCounter.incrementAndGet()
this.actions?.run(message, this)
this.response?.let {
message.channel.sendMessage(respond(message.messageAuthor, it))
}
this.embed?.let {
MessageUtil.sendEmbed(message.channel, embed)
}
this.feature?.handle(message)
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 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)
}
private fun respond(author: MessageAuthor, response: String) =
response.replace(AUTHOR_PLACEHOLDER, author.mention())
private fun respond(author: MessageAuthor, response: String) =
response.replace(AUTHOR_PLACEHOLDER, author.mention())
}
@Suppress("unused")
enum class MatchType(val matches: (String, Command) -> Boolean) {
PREFIX({ message, command -> message.startsWith(command.trigger, ignoreCase = true) }),
CONTAINS({ message, command -> message.contains(command.trigger, ignoreCase = true) }),
REGEX({ message, command -> command.regex!!.matches(message) });
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) });
}

@ -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}")
}
}
}

@ -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)
}
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")
}
if (MessageUtil.sendEmbed(targetChannel, embed).asOption().isEmpty()) {
Log.warn("Could not redirect message to channel $targetChannel")
}
}
}

@ -7,22 +7,22 @@ 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<Role>> = resolveRoles(hasOneOf)
private val hasNoneOf: Option<Set<Role>> = resolveRoles(hasNoneOf)
private fun resolveRoles(hasOneOf: List<String>?): Option<Set<Role>> {
return Option.fromNullable(hasOneOf?.mapTo(mutableSetOf(), { Util.findRole(it).unwrap() }))
}
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) }
}
}

@ -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.") }
)
}

@ -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()
}

@ -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())
}
}

@ -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(), name = "feature")
val features by optional(Features(), name = "feature")
}

@ -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)
}
}
}

@ -7,16 +7,16 @@ import arrow.core.getOrElse
import arrow.typeclasses.Functor
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") }
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) ->
AP.run { op(b).map { Tuple3(a, it, c) } }
AP.run { op(b).map { Tuple3(a, it, c) } }
}

@ -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

@ -11,51 +11,51 @@ 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 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 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 getOsInfo() = "Running on ${System.getProperty("os.name")} " +
"${System.getProperty("os.version")}-${System.getProperty("os.arch")}.\n"
}
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 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 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"
}

@ -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)
}

@ -1,26 +1,26 @@
package moe.kageru.kagebot.features
class Features(
val welcome: WelcomeFeature? = null,
val timeout: TimeoutFeature? = null,
vc: TempVCFeature = TempVCFeature(null)
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 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>()
fun findByString(feature: String) = featureMap[feature]
fun eventFeatures() = all.filterIsInstance<EventFeature>()
}

@ -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)
}
}

@ -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 }

@ -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}```")
}
}
}
}

@ -16,46 +16,46 @@ 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: cant 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: cant 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"
}

@ -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 couldnt 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 couldnt 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()}")
}
)
}
}

@ -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) }
}
}