From 1d45a26208c5d4e9b511a4e3a47141a2da15b7c8 Mon Sep 17 00:00:00 2001 From: Pauli Kauro <3965357+paulikauro@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:25:06 +0200 Subject: [PATCH 1/5] Replace JavaCord with Kord --- chattore/build.gradle.kts | 2 +- chattore/src/main/kotlin/feature/Discord.kt | 150 +++++++++++++------- gradle/libs.versions.toml | 2 +- 3 files changed, 98 insertions(+), 56 deletions(-) diff --git a/chattore/build.gradle.kts b/chattore/build.gradle.kts index 5b9e139..59730cb 100644 --- a/chattore/build.gradle.kts +++ b/chattore/build.gradle.kts @@ -12,7 +12,7 @@ plugins { dependencies { implementation(project(":common")) implementation(libs.acf) - implementation(libs.javacord) + implementation(libs.kord) implementation(libs.exposed.core) implementation(libs.exposed.jdbc) implementation(libs.exposed.javaTime) diff --git a/chattore/src/main/kotlin/feature/Discord.kt b/chattore/src/main/kotlin/feature/Discord.kt index 088357f..1974957 100644 --- a/chattore/src/main/kotlin/feature/Discord.kt +++ b/chattore/src/main/kotlin/feature/Discord.kt @@ -2,16 +2,19 @@ package org.openredstone.chattore.feature import com.velocitypowered.api.event.Subscribe import com.velocitypowered.api.proxy.ProxyServer -import org.javacord.api.DiscordApi -import org.javacord.api.DiscordApiBuilder -import org.javacord.api.entity.channel.TextChannel -import org.javacord.api.entity.intent.Intent -import org.javacord.api.entity.message.MessageBuilder -import org.javacord.api.event.message.MessageCreateEvent -import org.javacord.api.listener.message.MessageCreateListener +import dev.kord.common.annotation.KordPreview +import dev.kord.common.entity.Snowflake +import dev.kord.core.Kord +import dev.kord.core.entity.channel.TextChannel +import dev.kord.core.event.message.MessageCreateEvent +import dev.kord.core.live.channel.live +import dev.kord.core.live.channel.onMessageCreate +import dev.kord.gateway.Intent +import dev.kord.gateway.Intents +import dev.kord.gateway.PrivilegedIntent +import kotlinx.coroutines.* import org.openredstone.chattore.* import org.slf4j.Logger -import kotlin.jvm.optionals.getOrNull fun String.discordEscape() = this.replace("""_""", "\\_") @@ -50,49 +53,73 @@ fun PluginScope.createDiscordFeature( config: DiscordConfig, ) { if (!config.enable) return - val discordNetwork = DiscordApiBuilder() - .setToken(config.networkToken) - .addIntents(Intent.MESSAGE_CONTENT) - .login() - .join() - val discordMap = loadDiscordTokens(proxy, logger, config.serverTokens) - discordMap.forEach { (_, discordApi) -> discordApi.updateActivity(config.playingMessage) } - val textChannel = discordNetwork.getTextChannelById(config.channelId).getOrNull() - ?: throw ChattoreException("Cannot find Discord channel") - textChannel.addMessageCreateListener( - DiscordListener(logger, messenger, proxy, emojis, config) - ) - registerListeners(DiscordBroadcastListener(config, discordMap, discordNetwork)) + + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.Default) { + coroutineScope { + val discordNetwork = Kord(config.networkToken) + // login blocks until the bot shuts down, so we launch it in its own coroutine + launch { + discordNetwork.login { + @OptIn(PrivilegedIntent::class) + intents += Intent.MessageContent + presence { + playing(config.playingMessage) + } + } + } + val discordMap = spawnServerBots(proxy, logger, config) + val textChannel = discordNetwork.getChannelOf(Snowflake(config.channelId)) + ?: throw ChattoreException("Cannot find Discord channel") + val listener = DiscordListener(logger, messenger, proxy, emojis, config) + @OptIn(KordPreview::class) + textChannel.live().onMessageCreate(block = listener::onMessageCreate) + registerListeners(createBroadcastListener(config, discordMap, discordNetwork)) + } + } } -private class DiscordBroadcastListener( - private val config: DiscordConfig, - discordMap: Map, - discordApi: DiscordApi, -) { - private val serverChannelMapping: Map = discordMap.entries.associate { (server, api) -> - server to (api.getTextChannelById(config.channelId).getOrNull() +private suspend fun CoroutineScope.createBroadcastListener( + config: DiscordConfig, + discordMap: Map, + discordApi: Kord, +) = DiscordBroadcastListener( + config, + serverChannelMapping = discordMap.entries.associate { (server, api) -> + server to (api.getChannelOf(Snowflake(config.channelId)) ?: throw IllegalArgumentException("Could not get specified channel")) - } + }, + mainBotChannel = discordApi.getChannelOf(Snowflake(config.channelId)) + ?: throw IllegalArgumentException("Could not get specified channel"), + this, +) - private val mainBotChannel: TextChannel = discordApi.getTextChannelById(config.channelId).getOrNull() - ?: throw IllegalArgumentException("Could not get specified channel") +private class DiscordBroadcastListener( + private val config: DiscordConfig, + private val serverChannelMapping: Map, + private val mainBotChannel: TextChannel, + private val scope: CoroutineScope, +) { @Subscribe fun onBroadcastEvent(event: DiscordBroadcastEvent) { - val channel = serverChannelMapping[event.server] ?: return - val content = config.discordFormat - .replace("%prefix%", event.prefix) - .replace("%sender%", event.sender.discordEscape()) - .replace("%message%", event.message) - MessageBuilder().setContent(content).send(channel) + scope.launch { + val channel = serverChannelMapping[event.server] ?: return@launch + val content = config.discordFormat + .replace("%prefix%", event.prefix) + .replace("%sender%", event.sender.discordEscape()) + .replace("%message%", event.message) + channel.createMessage(content) + } } @Subscribe fun onBroadcastEventRaw(event: DiscordBroadcastEventMain) { - val message = event.format - .replace("%player%", event.player.discordEscape()) - MessageBuilder().setContent(message).send(mainBotChannel) + scope.launch { + val message = event.format + .replace("%player%", event.player.discordEscape()) + mainBotChannel.createMessage(message) + } } } @@ -102,7 +129,7 @@ private class DiscordListener( private val proxy: ProxyServer, private val emojis: Emojis, private val config: DiscordConfig, -) : MessageCreateListener { +) { private val emojiPattern = emojis.emojiToName.keys.joinToString("|", "(", ")") { Regex.escape(it) } private val emojiRegex = Regex(emojiPattern) @@ -114,11 +141,18 @@ private class DiscordListener( if (emojiName != null) ":$emojiName:" else emoji } - override fun onMessageCreate(event: MessageCreateEvent) { - if (event.messageAuthor.isBotUser && event.messageAuthor.id != config.chadId) return - val attachments = event.messageAttachments.joinToString(" ", " ") { it.url.toString() } - val toSend = replaceEmojis(event.message.readableContent) + attachments - logger.info("[Discord] ${event.messageAuthor.displayName} (${event.messageAuthor.id}): $toSend") + suspend fun onMessageCreate(event: MessageCreateEvent) { + val sender = event.member ?: run { + // TODO: just throw and catch somewhere + // make sure it doesn't cancel the coroutine scope + logger.error("Message (id: ${event.message.id}) sent by non-member!") + return + } + if (sender.isBot && sender.id != Snowflake(config.chadId)) return + val attachments = event.message.attachments.joinToString(" ", " ") { it.url } + val toSend = replaceEmojis(event.message.content) + attachments + val displayName = sender.effectiveName + logger.info("[Discord] $displayName (${sender.id}): $toSend") val transformedMessage = toSend.replace(urlMarkdownRegex) { matchResult -> val text = matchResult.groupValues[1].trim() val url = matchResult.groupValues[2].trim() @@ -126,17 +160,18 @@ private class DiscordListener( }.replace("""\s+""".toRegex(), " ") proxy.all.sendRichMessage( config.ingameFormat, - "sender" toS event.messageAuthor.displayName, + "sender" toS displayName, "message" toC messenger.prepareChatMessage(transformedMessage, null), ) } } -private fun loadDiscordTokens( +private suspend fun CoroutineScope.spawnServerBots( proxy: ProxyServer, logger: Logger, - serverTokens: Map, -): Map { + config: DiscordConfig, +): Map { + val serverTokens = config.serverTokens val availableServers = proxy.allServers.map { it.serverInfo.name.lowercase() }.sorted() val configServers = serverTokens.map { it.key.lowercase() }.sorted() if (availableServers != configServers) { @@ -149,9 +184,16 @@ private fun loadDiscordTokens( ) } return serverTokens.mapValues { (_, token) -> - DiscordApiBuilder() - .setToken(token) - .login() - .join() + Kord(token).also { + launch { + it.login { + // server bots don't need any intents + intents = Intents() + presence { + playing(config.playingMessage) + } + } + } + } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ce4bc58..4e4f082 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ jackson = "2.19.0" [libraries] acf = "co.aikar:acf-velocity:0.5.1-SNAPSHOT" -javacord = "org.javacord:javacord:3.8.0" +kord = "dev.kord:kord-core:0.17.0" sqliteJdbc = "org.xerial:sqlite-jdbc:3.46.0.0" luckperms = "net.luckperms:api:5.1" paper = "io.papermc.paper:paper-api:1.20.4-R0.1-SNAPSHOT" From ae0376865ddf40d44cccfce4c5eec08a803f0b6a Mon Sep 17 00:00:00 2001 From: Pauli Kauro <3965357+paulikauro@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:39:52 +0200 Subject: [PATCH 2/5] Refactor Discord.kt a bit --- chattore/src/main/kotlin/feature/Discord.kt | 52 +++++++-------------- 1 file changed, 17 insertions(+), 35 deletions(-) diff --git a/chattore/src/main/kotlin/feature/Discord.kt b/chattore/src/main/kotlin/feature/Discord.kt index 1974957..ebe37a7 100644 --- a/chattore/src/main/kotlin/feature/Discord.kt +++ b/chattore/src/main/kotlin/feature/Discord.kt @@ -69,31 +69,18 @@ fun PluginScope.createDiscordFeature( } } val discordMap = spawnServerBots(proxy, logger, config) - val textChannel = discordNetwork.getChannelOf(Snowflake(config.channelId)) - ?: throw ChattoreException("Cannot find Discord channel") + val serverChannels = discordMap.mapValues { (_, api) -> getGameChat(api, config.channelId) } + val mainBotChannel = getGameChat(discordNetwork, config.channelId) val listener = DiscordListener(logger, messenger, proxy, emojis, config) @OptIn(KordPreview::class) - textChannel.live().onMessageCreate(block = listener::onMessageCreate) - registerListeners(createBroadcastListener(config, discordMap, discordNetwork)) + mainBotChannel.live().onMessageCreate(block = listener::onMessageCreate) + registerListeners(DiscordBroadcastListener(config, serverChannels, mainBotChannel, this)) } } } -private suspend fun CoroutineScope.createBroadcastListener( - config: DiscordConfig, - discordMap: Map, - discordApi: Kord, -) = DiscordBroadcastListener( - config, - serverChannelMapping = discordMap.entries.associate { (server, api) -> - server to (api.getChannelOf(Snowflake(config.channelId)) - ?: throw IllegalArgumentException("Could not get specified channel")) - }, - mainBotChannel = discordApi.getChannelOf(Snowflake(config.channelId)) - ?: throw IllegalArgumentException("Could not get specified channel"), - this, -) - +private suspend fun getGameChat(api: Kord, id: Long): TextChannel = api.getChannelOf(Snowflake(id)) + ?: throw IllegalArgumentException("Cannot find game-chat channel") private class DiscordBroadcastListener( private val config: DiscordConfig, @@ -130,7 +117,6 @@ private class DiscordListener( private val emojis: Emojis, private val config: DiscordConfig, ) { - private val emojiPattern = emojis.emojiToName.keys.joinToString("|", "(", ")") { Regex.escape(it) } private val emojiRegex = Regex(emojiPattern) private val urlMarkdownRegex = """\[([^]]*)]\(\s?(\S+)\s?\)""".toRegex() @@ -141,13 +127,9 @@ private class DiscordListener( if (emojiName != null) ":$emojiName:" else emoji } - suspend fun onMessageCreate(event: MessageCreateEvent) { - val sender = event.member ?: run { - // TODO: just throw and catch somewhere - // make sure it doesn't cancel the coroutine scope - logger.error("Message (id: ${event.message.id}) sent by non-member!") - return - } + fun onMessageCreate(event: MessageCreateEvent) { + // guaranteed to not happen because events are filtered beforehand + val sender = event.member ?: throw IllegalStateException("onMessageCreate: event.member is null") if (sender.isBot && sender.id != Snowflake(config.chadId)) return val attachments = event.message.attachments.joinToString(" ", " ") { it.url } val toSend = replaceEmojis(event.message.content) + attachments @@ -184,16 +166,16 @@ private suspend fun CoroutineScope.spawnServerBots( ) } return serverTokens.mapValues { (_, token) -> - Kord(token).also { - launch { - it.login { - // server bots don't need any intents - intents = Intents() - presence { - playing(config.playingMessage) - } + val kord = Kord(token) + launch { + kord.login { + // server bots don't need any intents + intents = Intents() + presence { + playing(config.playingMessage) } } } + kord } } From fe23767495ee081c0089db84dec53e9e9837ab42 Mon Sep 17 00:00:00 2001 From: Pauli Kauro <3965357+paulikauro@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:54:34 +0200 Subject: [PATCH 3/5] Gracefully shutdown Discord bots on proxy shutdown --- chattore/src/main/kotlin/feature/Discord.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/chattore/src/main/kotlin/feature/Discord.kt b/chattore/src/main/kotlin/feature/Discord.kt index ebe37a7..ae05be1 100644 --- a/chattore/src/main/kotlin/feature/Discord.kt +++ b/chattore/src/main/kotlin/feature/Discord.kt @@ -1,6 +1,7 @@ package org.openredstone.chattore.feature import com.velocitypowered.api.event.Subscribe +import com.velocitypowered.api.event.proxy.ProxyShutdownEvent import com.velocitypowered.api.proxy.ProxyServer import dev.kord.common.annotation.KordPreview import dev.kord.common.entity.Snowflake @@ -75,6 +76,16 @@ fun PluginScope.createDiscordFeature( @OptIn(KordPreview::class) mainBotChannel.live().onMessageCreate(block = listener::onMessageCreate) registerListeners(DiscordBroadcastListener(config, serverChannels, mainBotChannel, this)) + onEvent { + // block so that velocity waits before shutting down + // future considerations: + // - can this use async velocity events? + // - should we do the shutdowns concurrently? + runBlocking { + discordNetwork.shutdown() + discordMap.forEach { (_, kord) -> kord.shutdown() } + } + } } } } From 4f48d4a9001e36521f614b63a34b59989197e1cf Mon Sep 17 00:00:00 2001 From: Pauli Kauro <3965357+paulikauro@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:14:37 +0200 Subject: [PATCH 4/5] Fix default aliases There was an off-by-one error in many of them: "$1" instead of "$0". That caused them to require two arguments when they need only one. "$args" now also requires no arguments, which makes a lot of the aliases more useful. --- chattore/src/main/kotlin/feature/Alias.kt | 9 ++--- chattore/src/main/resources/aliases.json | 44 +++++++++++++---------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/chattore/src/main/kotlin/feature/Alias.kt b/chattore/src/main/kotlin/feature/Alias.kt index 9be11f3..be1cc4a 100644 --- a/chattore/src/main/kotlin/feature/Alias.kt +++ b/chattore/src/main/kotlin/feature/Alias.kt @@ -20,12 +20,12 @@ import org.slf4j.Logger private val IDENTIFIER: MinecraftChannelIdentifier = MinecraftChannelIdentifier.from(ALIAS_CHANNEL) -private val argumentsRegex = Regex("\\\$(args|[0-9]+)") +private val argumentsRegex = Regex("""\$(args|[0-9]+)""") @Serializable data class AliasConfig( - val alias: String = "alias", - val commands: List = listOf("some", "commands"), + val alias: String, + val commands: List, ) fun PluginScope.createAliasFeature() { @@ -53,7 +53,8 @@ private class AliasCommand( private val requiredArgs = commands.flatMap { argumentsRegex.findAll(it).map { match -> - match.groupValues[1].toIntOrNull()?.plus(1) ?: 1 + // require 0 for "args" + match.groupValues[1].toIntOrNull()?.plus(1) ?: 0 } }.maxOrNull() ?: 0 diff --git a/chattore/src/main/resources/aliases.json b/chattore/src/main/resources/aliases.json index 733234e..840c627 100644 --- a/chattore/src/main/resources/aliases.json +++ b/chattore/src/main/resources/aliases.json @@ -26,7 +26,7 @@ { "alias": "nv", "commands": [ - "effect give $1 minecraft:night_vision infinite" + "effect give $0 minecraft:night_vision infinite" ] }, { @@ -83,14 +83,14 @@ "alias": "/hs", "commands": [ "/hpos2", - "/rs $1 1" + "/rs $0 1" ] }, { "alias": "/me", "commands": [ "/hpos2", - "/replace $1 air" + "/replace $0 air" ] }, { @@ -127,41 +127,41 @@ { "alias": "/s", "commands": [ - "/stack $1" + "/stack $args" ] }, { "alias": "/sa", "commands": [ - "/stack -a $1" + "/stack -a $args" ] }, { "alias": "/su", "commands": [ "/hpos2", - "/stack $1 u" + "/stack $0 u" ] }, { "alias": "/sd", "commands": [ "/hpos2", - "/stack $1 d" + "/stack $0 d" ] }, { "alias": "/sl", "commands": [ "/hpos2", - "/stack $1 l" + "/stack $0 l" ] }, { "alias": "/sr", "commands": [ "/hpos2", - "/stack $1 r" + "/stack $0 r" ] }, { @@ -173,49 +173,49 @@ { "alias": "/r", "commands": [ - "/rotate $1" + "/rotate $args" ] }, { "alias": "/u", "commands": [ - "/undo $1" + "/undo $args" ] }, { "alias": "/z", "commands": [ - "/undo" + "/undo $args" ] }, { "alias": "/y", "commands": [ - "/redo" + "/redo $args" ] }, { "alias": "/m", "commands": [ - "/move $1" + "/move $args" ] }, { "alias": "/md", "commands": [ - "/move $1 $2" + "/move $0 d" ] }, { "alias": "/ma", "commands": [ - "/move $1 -a" + "/move -a $args" ] }, { "alias": "/mas", "commands": [ - "/move $1 -a -s" + "/move -as $args" ] }, { @@ -230,7 +230,7 @@ { "alias": "/e", "commands": [ - "/expand $1" + "/expand $args" ] }, { @@ -247,5 +247,13 @@ "/flip", "/paste -a" ] + }, + { + "alias": "/xfv", + "commands": [ + "/cut", + "/flip", + "/paste -a" + ] } ] From c4175e5e87cbb320ed1091d5c96d84ea98d71201 Mon Sep 17 00:00:00 2001 From: Pauli Kauro <3965357+paulikauro@users.noreply.github.com> Date: Fri, 23 Jan 2026 19:47:34 +0200 Subject: [PATCH 5/5] Add name to /redcmd --- chattore/src/main/resources/commands.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chattore/src/main/resources/commands.json b/chattore/src/main/resources/commands.json index 7c57d6d..8e38038 100644 --- a/chattore/src/main/resources/commands.json +++ b/chattore/src/main/resources/commands.json @@ -159,7 +159,7 @@ { "command": "redcmd", "description": "intal gento", - "globalChat": "Hello allo" + "globalChat": ": Hello allo" }, { "command": "lag",