update javacord and run ktlint

This commit is contained in:
kageru 2023-08-14 10:14:19 +02:00
parent 997284fb54
commit 80111b2bbf
20 changed files with 112 additions and 101 deletions

View File

@ -1,4 +1,6 @@
# kagebot – where the code is better than the name
# kagebot
Kinda dead.
This bot is a replacement for [my old one](https://git.kageru.moe/kageru/discord-selphybot) with a very simple premise:
As much as possible should be configurable in a human-readable way.
This will allow anyone to modify the config to host their own instance tailored to their own needs,
@ -14,4 +16,6 @@ The implementation has kind of deteriorated into a playground for me
but it’s been running and moderating a 1000+ user server for over a year
with relatively little maintenance.
[1]: While arrow is great, adding it to a project after the fact leads to a very weird combination of FP and non-FP constructs. Would not recommend in production.
[1]: While arrow is great, adding it to a project after the fact leads to a very weird combination of FP and non-FP constructs.
Would not recommend in production. This was also built in an early version of arrow that still had `Kind` and other concepts that were scrapped later,
but I don’t plan to update that ever. The bot can keep running as-is until it breaks.

View File

@ -4,22 +4,22 @@ apply {
plugin("kotlin-kapt")
}
plugins {
kotlin("jvm") version "1.4.10"
id("com.github.johnrengelman.shadow") version "5.2.0" apply true
kotlin("jvm") version "1.9.0"
id("com.github.johnrengelman.shadow") version "8.1.1" apply true
application
}
val botMainClass = "moe.kageru.kagebot.KagebotKt"
application {
mainClassName = botMainClass
mainClass.set(botMainClass)
}
tasks.withType<Jar> {
manifest {
attributes(
mapOf(
"Main-Class" to botMainClass
)
"Main-Class" to botMainClass,
),
)
}
}
@ -43,10 +43,10 @@ val arrowVersion = "0.11.0"
dependencies {
implementation("com.uchuhimo:konf-core:0.23.0")
implementation("com.uchuhimo:konf-toml:0.23.0")
implementation("org.javacord:javacord:3.1.1")
implementation("org.javacord:javacord:3.8.0")
implementation("org.mapdb:mapdb:3.0.8")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9")
implementation("org.jetbrains.kotlin:kotlin-reflect:1.4.10")
implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.0")
implementation("com.fasterxml.jackson.core:jackson-annotations:2.11.3")
implementation("io.arrow-kt:arrow-core:$arrowVersion")
@ -55,10 +55,10 @@ dependencies {
testImplementation("io.kotlintest:kotlintest-runner-junit5:3.4.2")
testImplementation("io.mockk:mockk:1.10.0")
// these two are needed to access javacord internals (such as reading from sent embeds during tests)
testImplementation("org.javacord:javacord-core:3.1.1")
testImplementation("org.javacord:javacord-core:3.8.0")
testImplementation("com.fasterxml.jackson.core:jackson-databind:2.11.3")
}
tasks.withType<KotlinCompile> {
kotlinOptions.jvmTarget = "14"
kotlinOptions.jvmTarget = "20"
}

View File

@ -42,12 +42,14 @@ object Kagebot {
println("Caused by: ${e.cause}\n${e.cause?.stackTrace?.joinToString("\n")}")
exitProcess(1)
}
Runtime.getRuntime().addShutdownHook(Thread {
Log.info("Bot has been interrupted. Shutting down.")
Dao.setCommandCounter(Globals.commandCounter.get())
Dao.close()
api.disconnect()
})
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) }

View File

@ -13,7 +13,7 @@ object Log {
addHandler(
FileHandler("kagebot.log", true).apply {
formatter = LogFormatter()
}
},
)
}
}

View File

@ -80,17 +80,6 @@ object Util {
} 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) } }
)
)
}
}
}

View File

@ -21,7 +21,7 @@ class Command(
private val actions: MessageActions?,
embed: List<String>?,
feature: String?,
matchType: String?
matchType: String?,
) {
val matchType: MatchType = matchType?.let { type ->
MatchType.values().find { it.name.equals(type, ignoreCase = true) }
@ -56,5 +56,5 @@ class Command(
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) });
REGEX({ message, command -> command.regex!!.matches(message) }),
}

View File

@ -11,7 +11,7 @@ class MessageActions(
private val delete: Boolean = false,
private val redirect: MessageRedirect?,
@JsonProperty("assign")
private val assignment: RoleAssignment?
private val assignment: RoleAssignment?,
) {
fun run(message: MessageCreateEvent, command: Command) {

View File

@ -3,14 +3,12 @@ package moe.kageru.kagebot.command
import arrow.core.Option
import arrow.core.toOption
import moe.kageru.kagebot.Util
import moe.kageru.kagebot.extensions.unwrap
import org.javacord.api.entity.permission.Role
import org.javacord.api.event.message.MessageCreateEvent
class Permissions(
hasOneOf: List<String>?,
hasNoneOf: List<String>?,
private val onlyDM: Boolean = false
private val onlyDM: Boolean = false,
) {
private val hasOneOf: Option<Set<String>> = hasOneOf?.toSet().toOption()
private val hasNoneOf: Option<Set<String>> = hasNoneOf?.toSet().toOption()

View File

@ -12,6 +12,6 @@ class RoleAssignment(@JsonProperty("role") role: String) {
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.") }
{ it.addRole(role, "Requested via command.") },
)
}

View File

@ -22,11 +22,15 @@ class DebugFeature : MessageFeature {
val runtime = Runtime.getRuntime()
return MessageUtil.listToEmbed(
listOf(
"Bot:", getBotStats(),
"Memory:", getMemoryInfo(runtime, osBean),
"CPU:", getCpuInfo(osBean),
"System:", getOsInfo()
)
"Bot:",
getBotStats(),
"Memory:",
getMemoryInfo(runtime, osBean),
"CPU:",
getCpuInfo(osBean),
"System:",
getOsInfo(),
),
)
}
@ -40,7 +44,7 @@ class DebugFeature : MessageFeature {
uptime.toDaysPart(),
uptime.toHoursPart(),
uptime.toMinutesPart(),
uptime.toSecondsPart()
uptime.toSecondsPart(),
)
}

View File

@ -3,7 +3,7 @@ package moe.kageru.kagebot.features
class Features(
val welcome: WelcomeFeature? = null,
val timeout: TimeoutFeature? = null,
vc: TempVCFeature = TempVCFeature(null)
vc: TempVCFeature = TempVCFeature(null),
) {
private val debug = DebugFeature()
private val help = HelpFeature()
@ -18,7 +18,7 @@ class Features(
"getConfig" to getConfig,
"setConfig" to setConfig,
"timeout" to timeout,
"vc" to vc
"vc" to vc,
)
fun findByString(feature: String) = featureMap[feature]

View File

@ -19,17 +19,21 @@ class TempVCFeature(@JsonProperty("category") category: String? = null) : EventF
private val category: ChannelCategory? = category?.let { Config.server.categoriesByName(it).first() }
override fun handle(message: MessageCreateEvent): Unit = with(message) {
Either.cond(' ' in readableMessageContent,
Either.cond(
' ' in readableMessageContent,
{ readableMessageContent.split(' ', limit = 2).last() },
{ "Invalid syntax, expected `<command> <userlimit>`" })
{ "Invalid syntax, expected `<command> <userlimit>`" },
)
.flatMap { limit ->
limit.toIntOrNull().rightIfNotNull { "Invalid syntax, expected a number as limit, got $limit" }
}.filterOrElse({ it < 99 }, { "Error: can’t create a channel with that many users." })
.fold({ err -> channel.sendMessage(err) },
.fold(
{ err -> channel.sendMessage(err) },
{ limit ->
createChannel(message, limit)
channel.sendMessage("Done")
})
},
)
}
override fun register(api: DiscordApi) {
@ -43,7 +47,7 @@ class TempVCFeature(@JsonProperty("category") category: String? = null) : EventF
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) }
{ Dao.removeTemporaryVC(channel.idAsString) },
)
private fun createChannel(message: MessageCreateEvent, limit: Int): Unit =
@ -54,7 +58,8 @@ class TempVCFeature(@JsonProperty("category") category: String? = null) : EventF
setCategory(category)
}.create().asOption().fold(
{ Log.warn("Attempted to create temporary VC without the necessary permissions") },
{ channel -> Dao.addTemporaryVC(channel.idAsString) })
{ channel -> Dao.addTemporaryVC(channel.idAsString) },
)
private fun generateChannelName(message: MessageCreateEvent): String =
"${message.messageAuthor.name}’s volatile corner"

View File

@ -30,7 +30,7 @@ class TimeoutFeature(@JsonProperty("role") role: String) : MessageFeature {
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." }
{ "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" }
@ -47,7 +47,7 @@ class TimeoutFeature(@JsonProperty("role") role: String) : MessageFeature {
addField("Timeout", Config.localization[LocalizationSpec.timeout].replace("@@", "$time"))
reason?.let { addField("Reason", it) }
}
}
},
)
}
}
@ -74,7 +74,7 @@ class TimeoutFeature(@JsonProperty("role") role: String) : MessageFeature {
roleIds.forEach { findRole("$it").map(user::addRole) }
user.removeRole(timeoutRole)
Log.info("Lifted timeout from user ${user.discriminatedName}. Stored roles ${roleIds.joinToString()}")
}
},
)
}
}

View File

@ -15,7 +15,7 @@ import org.javacord.api.event.server.member.ServerMemberJoinEvent
class WelcomeFeature(
content: List<String>?,
fallbackChannel: String?,
private val fallbackMessage: String?
private val fallbackMessage: String?,
) : MessageFeature, EventFeature {
val embed: EmbedBuilder? by lazy { content?.let(MessageUtil::listToEmbed) }
@ -31,7 +31,7 @@ class WelcomeFeature(
// If the user disabled direct messages, try the fallback (if defined)
if (message.asOption().isEmpty() && hasFallback()) {
fallbackChannel!!.sendMessage(
fallbackMessage!!.replace("@@", event.user.mentionTag)
fallbackMessage!!.replace("@@", event.user.mentionTag),
)
}
}

View File

@ -31,9 +31,11 @@ class ConfigTest : StringSpec() {
timeout = "timeout"
""".trimIndent()
val message = TestUtil.mockMessage("anything")
every { message.messageAttachments } returns listOf(mockk {
every { url.openStream().readAllBytes() } returns testConfig.toByteArray()
})
every { message.messageAttachments } returns listOf(
mockk {
every { url.openStream().readAllBytes() } returns testConfig.toByteArray()
},
)
SetConfigFeature().handle(message)
Config.localization[LocalizationSpec.redirectedMessage] shouldBe redir
}

View File

@ -37,7 +37,7 @@ object TestUtil {
replies: MutableList<String> = mutableListOf(),
replyEmbeds: MutableList<EmbedBuilder> = mutableListOf(),
files: MutableList<File> = mutableListOf(),
isBot: Boolean = false
isBot: Boolean = false,
): MessageCreateEvent {
return mockk {
every { messageContent } returns content
@ -75,7 +75,7 @@ object TestUtil {
fun prepareTestEnvironment(
sentEmbeds: MutableList<EmbedBuilder> = mutableListOf(),
sentMessages: MutableList<String> = mutableListOf(),
dmEmbeds: MutableList<EmbedBuilder> = mutableListOf()
dmEmbeds: MutableList<EmbedBuilder> = mutableListOf(),
) {
val channel = mockk<ServerTextChannel>(relaxed = true) {
every { sendMessage(capture(sentEmbeds)) } returns mockk(relaxed = true) {
@ -99,14 +99,16 @@ object TestUtil {
every { isCompletedExceptionally } returns false
every { join().idAsString } returns "12345"
}
every { getMembersByName(any()) } returns ListK.just(mockk(relaxed = true) {
every { id } returns 123
every { roles() } returns ListK.just(TEST_ROLE)
every { getRoles(any()) } returns ListK.just(TEST_ROLE)
every { sendMessage(capture(dmEmbeds)) } returns mockk(relaxed = true) {
every { isCompletedExceptionally } returns false
}
})
every { getMembersByName(any()) } returns setOf(
mockk(relaxed = true) {
every { id } returns 123
every { roles() } returns ListK.just(TEST_ROLE)
every { getRoles(any()) } returns ListK.just(TEST_ROLE)
every { sendMessage(capture(dmEmbeds)) } returns mockk(relaxed = true) {
every { isCompletedExceptionally } returns false
}
},
)
}
Globals.api = mockk(relaxed = true) {
every { getServerById(any<String>()) } returns Optional.of(Config.server)
@ -134,7 +136,7 @@ object TestUtil {
fun withReplyContents(
expected: List<String> = emptyList(),
unexpected: List<String> = emptyList(),
op: (MutableList<EmbedBuilder>) -> Unit
op: (MutableList<EmbedBuilder>) -> Unit,
) {
val replies = mutableListOf<EmbedBuilder>()
op(replies)

View File

@ -34,7 +34,7 @@ class CommandTest : StringSpec({
[[command]]
trigger = "!ping"
response = "pong"
""".trimIndent()
""".trimIndent(),
) {
val before = Globals.commandCounter.get()
testMessageSuccess("!ping", "pong")
@ -47,7 +47,7 @@ class CommandTest : StringSpec({
[[command]]
trigger = "!ping"
response = "pong"
""".trimIndent()
""".trimIndent(),
) {
testMessageSuccess("!ping", "pong")
}
@ -62,7 +62,7 @@ class CommandTest : StringSpec({
[[command]]
trigger = "!embed"
embed = [ "$heading", "$content" ]
""".trimIndent()
""".trimIndent(),
) {
TestUtil.withReplyContents(expected = listOf(heading, content)) {
mockMessage("!embed", replyEmbeds = it).process()
@ -76,7 +76,7 @@ class CommandTest : StringSpec({
trigger = "somewhere"
response = "found it"
matchType = "CONTAINS"
""".trimIndent()
""".trimIndent(),
) {
testMessageSuccess("the trigger is somewhere in this message", "found it")
}
@ -88,7 +88,7 @@ class CommandTest : StringSpec({
trigger = "A.+B"
response = "regex matched"
matchType = "REGEX"
""".trimIndent()
""".trimIndent(),
) {
testMessageSuccess("AcsdB", "regex matched")
}
@ -99,7 +99,7 @@ class CommandTest : StringSpec({
[[command]]
trigger = "answer me"
response = "@@ there you go"
""".trimIndent()
""".trimIndent(),
) {
testMessageSuccess("answer me", "<@1> there you go")
}
@ -110,7 +110,7 @@ class CommandTest : StringSpec({
[[command]]
trigger = "!ping"
response = "pong"
""".trimIndent()
""".trimIndent(),
) {
val calls = mutableListOf<String>()
mockMessage("!ping", replies = calls, isBot = true).process()
@ -124,7 +124,7 @@ class CommandTest : StringSpec({
trigger = "delet this"
[command.action]
delete = true
""".trimIndent()
""".trimIndent(),
) {
val messageContent = "delet this"
TestUtil.withReplyContents(expected = listOf(messageContent)) {
@ -145,7 +145,7 @@ class CommandTest : StringSpec({
hasOneOf = [
"testrole",
]
""".trimIndent()
""".trimIndent(),
) {
val replies = mutableListOf<String>()
val mockMessage = mockMessage("!restricted", replies = replies)
@ -163,7 +163,7 @@ class CommandTest : StringSpec({
hasOneOf = [
"testrole"
]
""".trimIndent()
""".trimIndent(),
) {
val calls = mutableListOf<String>()
val mockMessage = mockMessage("!restricted", replies = calls)
@ -182,15 +182,17 @@ class CommandTest : StringSpec({
hasOneOf = [
"testrole"
]
""".trimIndent()
""".trimIndent(),
) {
val calls = mutableListOf<String>()
val mockMessage = mockMessage("!restricted", replies = calls)
every { mockMessage.messageAuthor.asUser() } returns Optional.of(mockk {
every { roles() } returns ListK.just(
Config.server.rolesByName("testrole").first()
)
})
every { mockMessage.messageAuthor.asUser() } returns Optional.of(
mockk {
every { roles() } returns ListK.just(
Config.server.rolesByName("testrole").first(),
)
},
)
mockMessage.process()
calls shouldBe mutableListOf("access granted")
}
@ -203,7 +205,7 @@ class CommandTest : StringSpec({
response = "access granted"
[command.permissions]
hasNoneOf = ["testrole"]
""".trimIndent()
""".trimIndent(),
) {
val calls = mutableListOf<String>()
val mockMessage = mockMessage("!almostUnrestricted", replies = calls)
@ -211,7 +213,7 @@ class CommandTest : StringSpec({
every { mockMessage.messageAuthor.asUser() } returns mockk {
every { isPresent } returns true
every { get().getRoles(any()) } returns listOf(
Config.server.rolesByName("testrole").first()
Config.server.rolesByName("testrole").first(),
)
}
mockMessage.process()
@ -234,7 +236,7 @@ class CommandTest : StringSpec({
response = "access granted"
[command.permissions]
onlyDM = true
""".trimIndent()
""".trimIndent(),
) {
val calls = mutableListOf<String>()
mockMessage("!dm", replies = calls).process()
@ -256,7 +258,7 @@ class CommandTest : StringSpec({
[command.action.redirect]
target = "testchannel"
anonymous = true
""".trimIndent()
""".trimIndent(),
) {
val message = "this is a message"
mockMessage("!redirect $message").process()
@ -271,7 +273,7 @@ class CommandTest : StringSpec({
trigger = "!assign"
[command.action.assign]
role = "testrole"
""".trimIndent()
""".trimIndent(),
) {
val roles = mutableListOf<Role>()
val user = mockk<User> {
@ -288,7 +290,7 @@ class CommandTest : StringSpec({
[[command]]
trigger = "!vc"
feature = "vc"
""".trimIndent()
""".trimIndent(),
) {
testMessageSuccess("!vc 2", "Done")
Dao.isTemporaryVC("12345") shouldBe true
@ -301,7 +303,7 @@ class CommandTest : StringSpec({
[[command]]
trigger = "!vc"
feature = "vc"
""".trimIndent()
""".trimIndent(),
) {
testMessageSuccess("!vc asd", "Invalid syntax, expected a number as limit, got asd")
Dao.isTemporaryVC("12345") shouldBe false

View File

@ -16,7 +16,8 @@ class ConfigFeatureTest : ShouldSpec({
[[command]]
trigger = "!getConfig"
feature = "getConfig"
""".trimIndent()) {
""".trimIndent(),
) {
val calls = mutableListOf<File>()
mockMessage("!getConfig", files = calls).process()
calls.size shouldBe 1

View File

@ -31,7 +31,7 @@ class HelpFeatureTest : StringSpec({
trigger = "!prison"
[command.permissions]
hasOneOf = ["testrole"]
""".trimIndent()
""".trimIndent()
"should show prefix command" {
withCommands(commandConfig) {
val expected = listOf("!ping", "!something")
@ -47,11 +47,13 @@ class HelpFeatureTest : StringSpec({
val unexpected = listOf("not a prefix")
withReplyContents(expected = expected, unexpected = unexpected) { replies ->
val message = mockMessage("!help", replyEmbeds = replies)
every { message.messageAuthor.asUser() } returns Optional.of(mockk {
every { getRoles(any()) } returns listOf(
Config.server.rolesByName("testrole").first()
)
})
every { message.messageAuthor.asUser() } returns Optional.of(
mockk {
every { getRoles(any()) } returns listOf(
Config.server.rolesByName("testrole").first(),
)
},
)
message.process()
}
}

View File

@ -24,7 +24,7 @@ class WelcomeFeatureTest : StringSpec({
every { isCompletedExceptionally } returns false
}
}
}
},
)
sentMessages shouldBe mutableListOf(Config.features.welcome!!.embed)
}
@ -40,7 +40,7 @@ class WelcomeFeatureTest : StringSpec({
}
every { mentionTag } returns "<@123>"
}
}
},
)
val channel = Config.server.channelsByName("").first()
verify(exactly = 1) { channel.sendMessage("<@123> welcome") }