diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a861f0..8f964d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 1.5.0 +- Introduce webhook chat sync, allowing for user imitation + + **IMPORTANT INFO**: If you don't want to manually set the webhook URL, the + bot needs to have the MANAGE_WEBHOOKS permission + # 1.4.0 - Port to 1.20.4 and NeoForge - Removed Forge as a supported Platform for Minecraft > 1.20.1 diff --git a/README.md b/README.md index 17cf69f..7f40665 100644 --- a/README.md +++ b/README.md @@ -12,19 +12,18 @@ while allowing true complexity for power users. These features are the core of what this bot will do. See [the Status section](#status) to see how much is already implemented. - Discord and Minecraft account linking, optionally requiring this to be whitelisted -- Discord and Minecraft chat synchronization +- Discord and Minecraft chat synchronization, optionally via webhook for improved readability - FAQ commands using Markdown files without needing to restart the server ## Dependencies This mod has a few dependencies, some of which are not specified directly as they're technically optional: - The Kotlin Implementation for the platform you're running. (e.g. [Kotlin For Forge](https://modrinth.com/mod/kotlin-for-forge) or [Fabric Language Kotlin](https://modrinth.com/mod/fabric-language-kotlin)) -- The (Neo-)Forge Config API (Included in NeoForge, [extra mod for Fabric](https://modrinth.com/mod/forge-config-api-port)) +- The (Neo-)Forge Config API (Included in Neo/Forge, [extra mod for Fabric](https://modrinth.com/mod/forge-config-api-port)) ## Implementation - [JDA](https://jda.wiki) library to communicate with the Discord API. - Kotlin for the improved development experience over Java -- Architectury for multiplatform development - [JetBrains Exposed](https://github.com/JetBrains/Exposed) to communicate with the Database ## Configuration @@ -35,7 +34,7 @@ The following things will be configurable: 2. (If possible) Minecraft user imitation (The Minecraft usernames and heads will be used for messages) 3. (Only available if requiring linking for whitelist) Discord user imitation (The messages will be written under the linked Discord name) - Default: No user imitation + Default: Minecraft user imitation (if webhook available is set, otherwise no imitation) - Managing FAQs through a command interface (default: `off`) - Database connection. Uses an SQLite database by default @@ -45,10 +44,10 @@ The following things will be configurable: - [x] Reading Markdown files - [x] Updating suggestions without restart - [ ] Management/Creation via commands -- [ ] Chat synchronization +- [x] Chat synchronization - [x] Minecraft to Discord - [x] Discord to Minecraft - - [ ] User imitation on Discord + - [x] User imitation on Discord. Either uses Minecraft avatars or Discord avatars ## Running There is no public instance of this bot/mod available. To use it, create a new Application @@ -56,9 +55,9 @@ on the [Discord Developer Portal](https://discord.com/developers/applications) a to have the three privileged gateway intents: `PRESENCE`, `SERVER MEMBERS` and `MESSAGE CONTENT`. Copy the bot token and store it somewhere safe (like a Password Manager) and never show it to -anybody else. To make sure the token gets read by the bot, it has to be in an [Environment Variable](https://en.wikipedia.org/wiki/Environment_variable) -`DISCORD_TOKEN` where the running Minecraft server can access it. You could for example modify a `start.sh` script -on a Unix-like system to `export` it or start the shell script with it set directly: +anybody else. To make sure the token gets read by the bot, it has to be in either an [Environment Variable](https://en.wikipedia.org/wiki/Environment_variable) +`DISCORD_TOKEN` where the running Minecraft server can access it or in the config file under the key `token`. +You could for example modify a `start.sh` script on a Unix-like system to `export` it or start the shell script with it set directly: `startmc.sh`: ```shell @@ -75,4 +74,9 @@ DISCORD_TOKEN= startmc.sh # Start the script with the env vari After starting the server, you can go into the OAuth2 URL builder on the Discord Developer Portal and generate a URL with the `bot` and `applications.command` permissions. -Use the generated URL to have the bot join your server. \ No newline at end of file +Use the generated URL to have the bot join your server. + +To be able to use user imitation, which makes the chat synchronization more readable, the bot will try and create a webhook +in the configured chat synchronization channel. This means it will need to have the permission to manage webhooks. If you +don't want that you can manually create a webhook in the channel where you want the messages synchronized and set it in the +`webhook.url` option in the config for the mod. \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index e2b3187..cf2d328 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,11 +1,10 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import org.jetbrains.kotlin.gradle.utils.extendsFrom import java.nio.charset.StandardCharsets import java.text.SimpleDateFormat import java.util.* import dev.architectury.plugin.ArchitectPluginExtension import net.fabricmc.loom.api.LoomGradleExtensionAPI -import net.fabricmc.loom.task.RemapJarTask -import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar plugins { // This is an Architectury repository, as such the relevant plugins are needed @@ -86,6 +85,7 @@ subprojects { // Bot dependencies val jdaVersion: String by project + val dcWebhooksVersion: String by project val exposedVersion: String by project val sqliteJDBCVersion: String by project val commonmarkVersion: String by project @@ -135,6 +135,8 @@ subprojects { arrayOf( // Library used to communicate with Discord, see https://jda.wiki "net.dv8tion:JDA:$jdaVersion", + // Library used for sending messages via Discord Webhooks + "club.minnced:discord-webhooks:$dcWebhooksVersion", // Library to interact with the SQLite database, // see: https://github.com/JetBrains/Exposed @@ -142,6 +144,10 @@ subprojects { "org.jetbrains.exposed:exposed-dao:$exposedVersion", "org.jetbrains.exposed:exposed-jdbc:$exposedVersion", ).forEach { + implementation(it) { + exclude(module = "opus-java") + exclude(group = "org.slf4j") + } runtimeLib(it) { exclude(module = "opus-java") exclude(group = "org.slf4j") @@ -155,6 +161,7 @@ subprojects { // on JDA and Exposed, but is already provided by the // respective Kotlin implementation of the mod loaders exclude(group = "org.jetbrains.kotlin") + exclude(group = "org.jetbrains.kotlinx") // Minecraft already ships with a logging system exclude(group = "org.slf4j") } @@ -230,42 +237,41 @@ subprojects { platformSetupLoomIde() } - tasks { - named("shadowJar") { - archiveClassifier.set("dev-shadow") + tasks.named("shadowJar") { + // The shadowBotDep configuration was explicitly made to be shaded in, this is where that happens + configurations.clear() + configurations = listOf(shadowBotDep) - configurations = listOf(shadowBotDep, shadowCommon) + // This transforms the service files to make relocated Exposed work (see: https://github.com/JetBrains/Exposed/issues/1353) + mergeServiceFiles() - // This transforms the service files to make relocated Exposed work (see: https://github.com/JetBrains/Exposed/issues/1353) - mergeServiceFiles() + // Forge restricts loading certain classes for security reasons. + // Luckily, shadow can relocate them to a different package. + relocate("org.apache.commons.collections4", "dev.erdragh.shadowed.org.apache.commons.collections4") - // Relocating Exposed somewhere different so other mods not doing that don't run into issues (e.g. Ledger) - relocate("org.jetbrains.exposed", "dev.erdragh.shadowed.org.jetbrains.exposed") + // Relocating Exposed somewhere different so other mods not doing that don't run into issues (e.g. Ledger) + relocate("org.jetbrains.exposed", "dev.erdragh.shadowed.org.jetbrains.exposed") - // Forge restricts loading certain classes for security reasons. - // Luckily, shadow can relocate them to a different package. - relocate("org.apache.commons.collections4", "dev.erdragh.shadowed.org.apache.commons.collections4") + // Relocating jackson to prevent incompatibilities with other mods also bundling it (e.g. GroovyModLoader on Forge) + relocate("com.fasterxml.jackson", "dev.erdragh.shadowed.com.fasterxml.jackson") - // Relocating jackson to prevent incompatibilities with other mods also bundling it (e.g. GroovyModLoader on Forge) - relocate("com.fasterxml.jackson", "dev.erdragh.shadowed.com.fasterxml.jackson") + // relocate discord interaction stuff to maybe allow other discord integrations mods to work + relocate("club.minnced.discord", "dev.erdragh.shadowed.club.minnced.discord") + relocate("net.dv8tion.jda", "dev.erdragh.shadowed.net.dv8tion.jda") - exclude(".cache/**") //Remove datagen cache from jar. - exclude("**/astralbot/datagen/**") //Remove data gen code from jar. - exclude("**/org/slf4j/**") + // relocate dependencies of discord stuff + relocate("okhttp3", "dev.erdragh.shadowed.okhttp3") + relocate("okio", "dev.erdragh.shadowed.okio") + relocate("gnu.trove", "dev.erdragh.shadowed.gnu.trove") + relocate("com.iwebpp.crypto", "dev.erdragh.shadowed.com.iwebpp.crypto") + relocate("com.neovisionaries.ws", "dev.erdragh.shadowed.com.neovisionaries.ws") + relocate("org.json", "dev.erdragh.shadowed.org.json") + relocate("net.bytebuddy", "dev.erdragh.net.bytebuddy") - exclude("kotlinx/**") - exclude("_COROUTINE/**") - exclude("**/org/jetbrains/annotations/*") - exclude("**/org/intellij/**") - } + exclude("**/org/slf4j/**") - named("remapJar") { - inputFile.set(named("shadowJar").get().archiveFile) - dependsOn("shadowJar") - // Results in the remapped jar not having any extra bit in - // its file name, identifying it as the main distribution - archiveClassifier.set(null as String?) - } + exclude("**/org/jetbrains/annotations/*") + exclude("**/org/intellij/**") } } diff --git a/common/src/main/java/dev/erdragh/astralbot/mixins/PlayerListMixin.java b/common/src/main/java/dev/erdragh/astralbot/mixins/PlayerListMixin.java index 451d880..43eaa5c 100644 --- a/common/src/main/java/dev/erdragh/astralbot/mixins/PlayerListMixin.java +++ b/common/src/main/java/dev/erdragh/astralbot/mixins/PlayerListMixin.java @@ -20,6 +20,6 @@ public class PlayerListMixin { @Inject(method = "canPlayerLogin", at = @At(value = "INVOKE", target = "Lnet/minecraft/network/chat/Component;translatable(Ljava/lang/String;)Lnet/minecraft/network/chat/MutableComponent;"), cancellable = true) private void astralbot$returnWhiteListMessage(SocketAddress socketAddress, GameProfile gameProfile, CallbackInfoReturnable cir) { - cir.setReturnValue(WhitelistHandler.INSTANCE.writeWhitelistMessage(gameProfile)); + WhitelistHandler.INSTANCE.writeWhitelistMessage(gameProfile).ifPresent(cir::setReturnValue); } } diff --git a/common/src/main/kotlin/dev/erdragh/astralbot/Bot.kt b/common/src/main/kotlin/dev/erdragh/astralbot/Bot.kt index 8c38944..211ed54 100644 --- a/common/src/main/kotlin/dev/erdragh/astralbot/Bot.kt +++ b/common/src/main/kotlin/dev/erdragh/astralbot/Bot.kt @@ -28,7 +28,7 @@ var minecraftHandler: MinecraftHandler? = null var textChannel: TextChannel? = null var guild: Guild? = null -private var jda: JDA? = null +var jda: JDA? = null var applicationId by Delegates.notNull() var baseDirectory: File? = null @@ -97,6 +97,9 @@ private fun setupFromJDA(api: JDA) { textChannel = ch guild = g + // Text channel was fetched, now the minecraftHandler can fetch its webhook stuff + minecraftHandler?.updateWebhookClient() + ch.sendMessage("Server Started!").queue() } diff --git a/common/src/main/kotlin/dev/erdragh/astralbot/commands/minecraft/MinecraftCommands.kt b/common/src/main/kotlin/dev/erdragh/astralbot/commands/minecraft/MinecraftCommands.kt index 71777d4..8f07dfa 100644 --- a/common/src/main/kotlin/dev/erdragh/astralbot/commands/minecraft/MinecraftCommands.kt +++ b/common/src/main/kotlin/dev/erdragh/astralbot/commands/minecraft/MinecraftCommands.kt @@ -18,6 +18,7 @@ object LinkCommand : Command { val caller = context?.source?.playerOrException!! if (WhitelistHandler.checkWhitelist(caller.uuid) != null) { context.source.sendFailure(Component.literal(AstralBotTextConfig.LINK_COMMAND_ALREADY_LINKED.get())) + return 1 } val whitelistCode = WhitelistHandler.getOrGenerateWhitelistCode(caller.uuid) context.source.sendSuccess({ diff --git a/common/src/main/kotlin/dev/erdragh/astralbot/config/AstralBotConfig.kt b/common/src/main/kotlin/dev/erdragh/astralbot/config/AstralBotConfig.kt index d6921aa..ac23c55 100644 --- a/common/src/main/kotlin/dev/erdragh/astralbot/config/AstralBotConfig.kt +++ b/common/src/main/kotlin/dev/erdragh/astralbot/config/AstralBotConfig.kt @@ -3,7 +3,10 @@ package dev.erdragh.astralbot.config import dev.erdragh.astralbot.LOGGER import dev.erdragh.astralbot.commands.discord.allCommands import net.minecraftforge.common.ForgeConfigSpec +import java.net.URI import java.net.URL +import java.net.URLDecoder +import java.nio.charset.StandardCharsets /** * Config for the AstralBot mod. This uses Forge's config system @@ -20,6 +23,30 @@ object AstralBotConfig { */ val DISCORD_TOKEN: ForgeConfigSpec.ConfigValue + /** + * Used for sending more appealing messages + */ + val WEBHOOK_URL: ForgeConfigSpec.ConfigValue + /** + * Whether the configured [WEBHOOK_URL] should actually be used, + * useful in case somebody wants to temporarily disable using + * the webhook without removing the URL + */ + val WEBHOOK_ENABLED: ForgeConfigSpec.BooleanValue + + /** + * URL template for getting avatars from Minecraft users + */ + val WEBHOOK_MC_AVATAR_URL: ForgeConfigSpec.ConfigValue + + /** + * Whether the chat messages sent via the webhook should + * imitate the sender's Discord account or their Minecraft + * account. If this is on, the linked Discord account will + * be used. + */ + val WEBHOOK_USE_LINKED: ForgeConfigSpec.BooleanValue + /** * Whether the default whitelisting process is respected or ignored. * Setting this to `true` will *force* every user who wants to join @@ -101,6 +128,15 @@ object AstralBotConfig { DISCORD_TOKEN = builder.comment("Discord token for the bot. Can also be supplied via DISCORD_TOKEN environment variable") .define("token", "") + WEBHOOK_URL = builder.comment("URL to the webhook where the messages will be sent from") + .define(listOf("webhook", "url"), "") + WEBHOOK_ENABLED = builder.comment("Whether to use the configured webhook for sending messages") + .define(listOf("webhook", "enabled"), true) + WEBHOOK_USE_LINKED = builder.comment("Whether to imitate user's linked Discord accounts when sending messages from MC to DC") + .define(listOf("webhook", "useLinked"), false) + WEBHOOK_MC_AVATAR_URL = builder.comment("API that returns images based on Minecraft users. {{uuid}} and {{name}} can be used") + .define(listOf("webhook", "mcAvatarUrl"), "https://mc-heads.net/head/{{uuid}}") + REQUIRE_LINK_FOR_WHITELIST = builder.comment("Whether to require being linked to be whitelisted") .define("requireLinkForWhitelist", false) DISCORD_LINK = builder.comment("Link to the discord where your users can run the /link command") @@ -136,15 +172,15 @@ object AstralBotConfig { ) ) { if (it !is String) { - LOGGER.warn("$it in URL blocklist is not a String") + LOGGER.warn("$it in URI blocklist is not a String") return@defineList false } // TODO: Replace with better way to check for URL try { - URL(it) + URI(it) return@defineList true } catch (e: Exception) { - LOGGER.warn("Failed to parse URL on blocklist: $it", e) + LOGGER.warn("Failed to parse URI on blocklist: $it", e) return@defineList false } } @@ -180,12 +216,12 @@ object AstralBotConfig { fun urlAllowed(url: String?): Boolean { if (url == null) return true try { - val parsedURL = URL(url) + val parsedURL = URI(url) for (blockedURL in URL_BLOCKLIST.get()) { - if (parsedURL.host == URL(blockedURL).host) return false + if (parsedURL.host == URI(blockedURL).host) return false } } catch (e: Exception) { - LOGGER.warn("URL $url", e) + LOGGER.warn("URI $url", e) return false } return true diff --git a/common/src/main/kotlin/dev/erdragh/astralbot/config/AstralBotTextConfig.kt b/common/src/main/kotlin/dev/erdragh/astralbot/config/AstralBotTextConfig.kt index b41b8b5..f4eb529 100644 --- a/common/src/main/kotlin/dev/erdragh/astralbot/config/AstralBotTextConfig.kt +++ b/common/src/main/kotlin/dev/erdragh/astralbot/config/AstralBotTextConfig.kt @@ -16,6 +16,8 @@ object AstralBotTextConfig { val PLAYER_MESSAGE: ForgeConfigSpec.ConfigValue + val WEBHOOK_NAME_TEMPLATE: ForgeConfigSpec.ConfigValue + val DISCORD_MESSAGE: ForgeConfigSpec.ConfigValue val DISCORD_REPLY: ForgeConfigSpec.ConfigValue val DISCORD_EMBEDS: ForgeConfigSpec.ConfigValue @@ -23,6 +25,8 @@ object AstralBotTextConfig { val RELOAD_ERROR: ForgeConfigSpec.ConfigValue val RELOAD_SUCCESS: ForgeConfigSpec.ConfigValue + val WHITELIST_LINKED_NOT_ALLOWED: ForgeConfigSpec.ConfigValue + val LINK_NO_MINECRAFT: ForgeConfigSpec.ConfigValue val LINK_MINECRAFT_TAKEN: ForgeConfigSpec.ConfigValue val LINK_DISCORD_TAKEN: ForgeConfigSpec.ConfigValue @@ -63,12 +67,19 @@ object AstralBotTextConfig { .define("tickReport", "Average Tick Time: {{mspt}} MSPT (TPS: {{tps}})") PLAYER_MESSAGE = - builder.comment("""Template for how Minecraft chat messages are sent to Discord. + builder.comment("""Template for how Minecraft chat messages are sent to Discord if webhooks aren't used The player's name can be accessed via {{name}} and its name with pre- and suffix via {{fullName}}. The message itself is accessed via {{message}}. """.replace(whitespaceRegex, "\n")) .define(mutableListOf("messages", "minecraft"), "<{{fullName}}> {{message}}") + WEBHOOK_NAME_TEMPLATE = + builder.comment("""Template for how chat synchronization using Webhooks formats + the message author's name. + The player's primary name can be accessed via {{primary}} and the secondary name via {{secondary}}. + """.replace(whitespaceRegex, "\n")) + .define(mutableListOf("webhook", "name"), "{{primary}} ({{secondary}})") + DISCORD_MESSAGE = builder.comment("""Template for how Discord messages are synchronized to Minecraft. The sender is referenced by {{user}}. The optional response is accessed by {{reply}}. @@ -92,6 +103,13 @@ object AstralBotTextConfig { RELOAD_SUCCESS = builder.comment("Message sent to Discord after a successful reload") .define(mutableListOf("reload", "success"), "Reloaded commands for guild") + WHITELIST_LINKED_NOT_ALLOWED = builder.comment(""" + The message the user gets shown if they are already linked, but have not + yet been whitelisted by another means (i.e. being an operator, being on the vanilla whitelist, etc.) + The Minecraft username is accessible via {{name}} + """.replace(whitespaceRegex, "\n")) + .define(listOf("whitelist", "linkedNotAllowed"), "Hi {{mc}}! You're already linked, but not yet whitelisted.") + LINK_NO_MINECRAFT = builder.comment(""" Message for when there's no Minecraft account associated with the given Link code. diff --git a/common/src/main/kotlin/dev/erdragh/astralbot/handlers/MinecraftHandler.kt b/common/src/main/kotlin/dev/erdragh/astralbot/handlers/MinecraftHandler.kt index 326604b..f94d6ae 100644 --- a/common/src/main/kotlin/dev/erdragh/astralbot/handlers/MinecraftHandler.kt +++ b/common/src/main/kotlin/dev/erdragh/astralbot/handlers/MinecraftHandler.kt @@ -1,5 +1,10 @@ package dev.erdragh.astralbot.handlers +import club.minnced.discord.webhook.WebhookClient +import club.minnced.discord.webhook.external.JDAWebhookClient +import club.minnced.discord.webhook.send.WebhookEmbed +import club.minnced.discord.webhook.send.WebhookEmbedBuilder +import club.minnced.discord.webhook.send.WebhookMessageBuilder import com.mojang.authlib.GameProfile import dev.erdragh.astralbot.* import dev.erdragh.astralbot.config.AstralBotConfig @@ -9,7 +14,10 @@ import net.dv8tion.jda.api.entities.Member import net.dv8tion.jda.api.entities.Message import net.dv8tion.jda.api.entities.MessageEmbed import net.dv8tion.jda.api.events.message.MessageReceivedEvent +import net.dv8tion.jda.api.exceptions.ErrorResponseException import net.dv8tion.jda.api.hooks.ListenerAdapter +import net.dv8tion.jda.api.requests.ErrorResponse +import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder import net.minecraft.ChatFormatting import net.minecraft.network.chat.ClickEvent import net.minecraft.network.chat.Component @@ -23,6 +31,7 @@ import java.util.* import kotlin.jvm.optionals.getOrNull import kotlin.math.min + /** * Wrapper class around the [MinecraftServer] to provide convenience * methods for fetching [GameProfile]s, sending Messages, acting @@ -30,9 +39,43 @@ import kotlin.math.min * @author Erdragh */ class MinecraftHandler(private val server: MinecraftServer) : ListenerAdapter() { - private val playerNames = HashSet(server.maxPlayers); + private val playerNames = HashSet(server.maxPlayers) private val notchPlayer = byName("Notch")?.let { ServerPlayer(this.server, this.server.allLevels.elementAt(0), it) } + private var webhookClient: WebhookClient? = null + /** + * Method that maybe creates a new webhook client if one wasn't configured before. + * Gets called when the config is reloaded. + */ + fun updateWebhookClient() { + val url = AstralBotConfig.WEBHOOK_URL.get() + if (webhookClient == null || url != webhookClient?.url) { + webhookClient = url.let { if (it != "") WebhookClient.withUrl(it) else null } + } + if (webhookClient == null) { + textChannel?.retrieveWebhooks()?.submit()?.whenComplete { webhooks, error -> + if (error != null) { + LOGGER.error("Failed to fetch webhooks for channel: ${textChannel!!.id} (${textChannel!!.name})", error) + } else { + if (webhooks.size > 1) { + LOGGER.warn("Multiple webhooks available for channel: ${textChannel!!.id} (${textChannel!!.name}). AstralBot will use the first one. If you want to use a specific one, set the webhook.url config option.") + webhookClient = JDAWebhookClient.from(webhooks[0]) + } else if (webhooks.size == 1) { + webhookClient = JDAWebhookClient.from(webhooks[0]) + } else { + LOGGER.info("No webhook found in channel: ${textChannel!!.id} (${textChannel!!.name}). AstralBot will try to create one.") + textChannel!!.createWebhook("AstralBot Chat synchronization").submit().whenComplete { webhook, error -> + if (error != null) { + LOGGER.error("Failed to create webhook for channel: ${textChannel!!.id} (${textChannel!!.name})", error) + } else { + webhookClient = JDAWebhookClient.from(webhook) + } + } + } + } + } + } + } companion object { private val numberFormat = DecimalFormat("###.##") @@ -148,18 +191,45 @@ class MinecraftHandler(private val server: MinecraftServer) : ListenerAdapter() } } + val messageSenderInfo = if(useWebhooks()) MessageSenderLookup.getMessageSenderInfo(player, AstralBotConfig.WEBHOOK_USE_LINKED.get()) else null val escape = { it: String -> it.replace("_", "\\_") } - textChannel?.sendMessage( - if (player != null) - AstralBotTextConfig.PLAYER_MESSAGE.get() - .replace("{{message}}", formatComponentToMarkdown(message)) - .replace("{{fullName}}", escape(player.displayName?.string ?: player.name.string)) - .replace("{{name}}", escape(player.name.string)) - else escape(message.string) - ) - ?.addEmbeds(formattedEmbeds) - ?.setSuppressedNotifications(true) - ?.queue() + val content = if (messageSenderInfo != null) { + formatComponentToMarkdown(message) + } else if (player != null) { + AstralBotTextConfig.PLAYER_MESSAGE.get() + .replace("{{message}}", formatComponentToMarkdown(message)) + .replace("{{fullName}}", escape(player.displayName?.string ?: player.name.string)) + .replace("{{name}}", escape(player.name.string)) + } else { + formatComponentToMarkdown(message) + } + + if (!useWebhooks() || webhookClient == null || messageSenderInfo == null) { + val createdMessage = MessageCreateBuilder() + .addEmbeds(formattedEmbeds) + .setContent(content) + .setSuppressedNotifications(true) + .build() + textChannel?.sendMessage(createdMessage)?.queue() + } else { + val webhookMessage = WebhookMessageBuilder() + .addEmbeds(formattedEmbeds.map { embed -> + WebhookEmbedBuilder().let { builder -> embed.title?.let { title -> builder.setTitle(WebhookEmbed.EmbedTitle(title, null)) }; builder } + .setDescription(embed.description) + .setColor(embed.colorRaw) + .build() + }) + .setContent(content) + .setUsername( + AstralBotTextConfig.WEBHOOK_NAME_TEMPLATE.get() + .replace("{{primary}}", messageSenderInfo.primaryName) + .replace("{{secondary}}", messageSenderInfo.secondaryName ?: "Unlinked") + ) + .setAvatarUrl(messageSenderInfo.avatar) + .build() + + webhookClient!!.send(webhookMessage) + } } /** @@ -228,18 +298,28 @@ class MinecraftHandler(private val server: MinecraftServer) : ListenerAdapter() messageContents.append(formatEmbeds(message)) } - val referencedAuthor = message.referencedMessage?.author?.id + val respondeeId = message.referencedMessage?.author?.id + val respondeeName = message.referencedMessage?.author?.effectiveName waitForSetup() - if (referencedAuthor != null) { - // This fetches the Member from the ID in a blocking manner - guild?.retrieveMemberById(referencedAuthor)?.submit()?.whenComplete { repliedMember, error -> + if (respondeeId != null) { + guild?.retrieveMemberById(respondeeId)?.submit()?.whenComplete { repliedMember, error -> if (error != null) { - LOGGER.error("Failed to get member with id: $referencedAuthor", error) + if (error !is ErrorResponseException || error.errorResponse.code != ErrorResponse.UNKNOWN_USER.code) { + // if the error is because the user was unknown, the log shouldn't be spammed. + // This is becausane webhook messages will produce this error. + LOGGER.error("Failed to get member with id: $respondeeId", error) + } + if (respondeeName != null) { + message.member?.let { + comp.append(formatMessageWithUsers(messageContents, it, Component.literal(respondeeName).withStyle(ChatFormatting.WHITE))) + send(comp) + } + } return@whenComplete } else if (repliedMember == null) { - LOGGER.error("Failed to get member with id: $referencedAuthor") + LOGGER.error("Failed to get member with id: $respondeeId") return@whenComplete } message.member?.let { @@ -247,6 +327,11 @@ class MinecraftHandler(private val server: MinecraftServer) : ListenerAdapter() send(comp) } } + } else if (respondeeName != null) { + message.member?.let { + comp.append(formatMessageWithUsers(messageContents, it, Component.literal(respondeeName).withStyle(ChatFormatting.WHITE))) + send(comp) + } } else { message.member?.let { comp.append(formatMessageWithUsers(messageContents, it, null)) @@ -255,7 +340,7 @@ class MinecraftHandler(private val server: MinecraftServer) : ListenerAdapter() } } - private fun formatMessageWithUsers(message: MutableComponent, author: Member, replied: Member?): MutableComponent { + private fun formatMessageWithUsers(message: MutableComponent, author: Member, replied: Component?): MutableComponent { val formatted = Component.empty() val templateSplitByUser = AstralBotTextConfig.DISCORD_MESSAGE.get().split("{{user}}") @@ -288,7 +373,7 @@ class MinecraftHandler(private val server: MinecraftServer) : ListenerAdapter() reply.append(formattedLiteral(value)) if (index + 1 < replyTemplateSplit.size) { - reply.append(formattedUser(it)) + reply.append(it) } } } @@ -316,6 +401,10 @@ class MinecraftHandler(private val server: MinecraftServer) : ListenerAdapter() return formatted } + private fun formatMessageWithUsers(message: MutableComponent, author: Member, replied: Member): MutableComponent { + return formatMessageWithUsers(message, author, formattedUser(replied)) + } + /** * Formats the attachments and embeds on a Discord [Message] into * a comma separated list. @@ -396,4 +485,8 @@ class MinecraftHandler(private val server: MinecraftServer) : ListenerAdapter() } return comp } + + private fun useWebhooks(): Boolean { + return AstralBotConfig.WEBHOOK_ENABLED.get() && webhookClient != null + } } \ No newline at end of file diff --git a/common/src/main/kotlin/dev/erdragh/astralbot/handlers/WhitelistHandler.kt b/common/src/main/kotlin/dev/erdragh/astralbot/handlers/WhitelistHandler.kt index b9033ee..18c5b60 100644 --- a/common/src/main/kotlin/dev/erdragh/astralbot/handlers/WhitelistHandler.kt +++ b/common/src/main/kotlin/dev/erdragh/astralbot/handlers/WhitelistHandler.kt @@ -4,7 +4,9 @@ import com.mojang.authlib.GameProfile import dev.erdragh.astralbot.LOGGER import dev.erdragh.astralbot.baseDirectory import dev.erdragh.astralbot.config.AstralBotConfig +import dev.erdragh.astralbot.config.AstralBotTextConfig import net.dv8tion.jda.api.entities.User +import net.minecraft.network.chat.ClickEvent import net.minecraft.network.chat.Component import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq @@ -250,10 +252,19 @@ object WhitelistHandler { * - `{{DISCORD}}` with the link to the Discord server * configured in the [AstralBotConfig] */ - fun writeWhitelistMessage(user: GameProfile): Component { + fun writeWhitelistMessage(user: GameProfile): Optional { + // This value represents whether the user is whitelisted in the DB. If this is the + // case the config to require linking is on and the user hasn't yet been whitelisted + // by another means. + val isDbWhitelisted = checkWhitelist(user.id) != null + + if (isDbWhitelisted) return Optional.of(Component.literal(AstralBotTextConfig.WHITELIST_LINKED_NOT_ALLOWED.get().replace("{{name}}", user.name))) + + val linkCode = getWhitelistCode(user.id) ?: return Optional.empty() + val template = whitelistTemplate.readText() .replace("{{USER}}", user.name) - .replace("{{CODE}}", getWhitelistCode(user.id).toString()) + .replace("{{CODE}}", linkCode.toString()) .split("{{DISCORD}}") // Throws an error if the template is malformed @@ -266,9 +277,9 @@ object WhitelistHandler { component.append(template[i]) if (discordLink.isNotEmpty() && i + 1 < template.size) { // TODO: Make this clickable. Using `withStyle` and a ClickEvent did not seem to work - component.append(Component.literal(discordLink)) + component.append(Component.literal(discordLink).withStyle {it.withClickEvent(ClickEvent(ClickEvent.Action.OPEN_URL, discordLink))}) } } - return component + return Optional.of(component) } } \ No newline at end of file diff --git a/common/src/main/kotlin/dev/erdragh/astralbot/util/MessageSenderLookup.kt b/common/src/main/kotlin/dev/erdragh/astralbot/util/MessageSenderLookup.kt new file mode 100644 index 0000000..f3d29b9 --- /dev/null +++ b/common/src/main/kotlin/dev/erdragh/astralbot/util/MessageSenderLookup.kt @@ -0,0 +1,64 @@ +package dev.erdragh.astralbot.util + +import dev.erdragh.astralbot.LOGGER +import dev.erdragh.astralbot.config.AstralBotConfig +import dev.erdragh.astralbot.handlers.WhitelistHandler +import dev.erdragh.astralbot.jda +import net.dv8tion.jda.api.entities.User +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.player.Player + +object MessageSenderLookup { + private val minecraftSkins: MutableMap = mutableMapOf() + private val discordAvatars: MutableMap = mutableMapOf() + + fun getMessageSenderInfo(player: ServerPlayer?, discord: Boolean = false): ResolvedSenderInfo? { + if (player == null) return null + + val discordId = WhitelistHandler.checkWhitelist(player.gameProfile.id) + var discordUser: User? = null + if (discordId != null) { + discordUser = jda?.getUserById(discordId) + if (discordUser == null) { + // Lazily load the user info if it's not cached + jda?.retrieveUserById(discordId)?.submit()?.whenComplete { user, error -> + if (error == null) { + discordAvatars[player] = ResolvedSenderInfo(user.effectiveName, (player.displayName ?: player.name).string, user.avatarUrl) + minecraftSkins[player] = ResolvedSenderInfo( + (player.displayName ?: player.name).string, + user.effectiveName, + AstralBotConfig.WEBHOOK_MC_AVATAR_URL.get() + .replace("{{uuid}}", player.gameProfile.id.toString()) + .replace("{{name}}", player.gameProfile.name) + ) + } else { + LOGGER.error("Failed to retrieve user: $discordId for chat synchronization", error) + } + } + } + } + + if (discord) { + val cached = discordAvatars[player] + if (cached != null) return cached + if (discordUser == null) { + return ResolvedSenderInfo("Uncached", (player.displayName ?: player.name).string, null) + } + + return discordAvatars.computeIfAbsent(player) { + ResolvedSenderInfo(discordUser.effectiveName, (player.displayName ?: player.name).string, discordUser.avatarUrl) + } + } + return minecraftSkins.computeIfAbsent(player) { + ResolvedSenderInfo( + (player.displayName ?: player.name).string, + if (discordId == null) null else (discordUser?.effectiveName ?: "Uncached"), + AstralBotConfig.WEBHOOK_MC_AVATAR_URL.get() + .replace("{{uuid}}", player.gameProfile.id.toString()) + .replace("{{name}}", player.gameProfile.name) + ) + } + } + + data class ResolvedSenderInfo(val primaryName: String, val secondaryName: String?, val avatar: String?) +} \ No newline at end of file diff --git a/forge/src/main/kotlin/dev/erdragh/astralbot/forge/BotMod.kt b/forge/src/main/kotlin/dev/erdragh/astralbot/forge/BotMod.kt index 8d65a19..d8984b4 100644 --- a/forge/src/main/kotlin/dev/erdragh/astralbot/forge/BotMod.kt +++ b/forge/src/main/kotlin/dev/erdragh/astralbot/forge/BotMod.kt @@ -1,13 +1,20 @@ package dev.erdragh.astralbot.forge -import dev.erdragh.astralbot.* +import dev.erdragh.astralbot.LOGGER import dev.erdragh.astralbot.commands.minecraft.registerMinecraftCommands import dev.erdragh.astralbot.config.AstralBotConfig import dev.erdragh.astralbot.config.AstralBotTextConfig import dev.erdragh.astralbot.forge.event.SystemMessageEvent import dev.erdragh.astralbot.handlers.DiscordMessageComponent +import dev.erdragh.astralbot.minecraftHandler +import dev.erdragh.astralbot.neoforge.event.CommandMessageEvent +import dev.erdragh.astralbot.neoforge.event.SystemMessageEvent +import dev.erdragh.astralbot.startAstralbot +import dev.erdragh.astralbot.stopAstralbot import dev.erdragh.astralbot.forge.event.CommandMessageEvent import net.minecraft.server.level.ServerPlayer +import thedarkcolour.kotlinforforge.neoforge.forge.FORGE_BUS +import thedarkcolour.kotlinforforge.neoforge.forge.MOD_BUS import net.minecraftforge.event.RegisterCommandsEvent import net.minecraftforge.event.ServerChatEvent import net.minecraftforge.event.entity.player.PlayerEvent @@ -16,13 +23,14 @@ import net.minecraftforge.event.server.ServerStoppingEvent import net.minecraftforge.fml.ModLoadingContext import net.minecraftforge.fml.common.Mod import net.minecraftforge.fml.config.ModConfig -import thedarkcolour.kotlinforforge.forge.FORGE_BUS @Mod("astralbot") object BotMod { init { ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, AstralBotConfig.SPEC) ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, AstralBotTextConfig.SPEC, "astralbot-text.toml") + MOD_BUS.addListener(::onConfigReloaded) + FORGE_BUS.addListener(::onServerStart) FORGE_BUS.addListener(::onServerStop) FORGE_BUS.addListener(::onChatMessage) @@ -34,6 +42,14 @@ object BotMod { FORGE_BUS.addListener(::onPlayerLeave) } + // Unused parameter suppressed to keep type + // information about Server stop event. + @Suppress("UNUSED_PARAMETER") + private fun onConfigReloaded(event: ModConfigEvent.Reloading) { + // Updates the webhook client if the URL changed + minecraftHandler?.updateWebhookClient() + } + private fun onServerStart(event: ServerStartedEvent) { LOGGER.info("AstralBot starting on Forge") startAstralbot(event.server) diff --git a/gradle.properties b/gradle.properties index ebd8a0a..39ecaab 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ license=MIT title=AstralBot description=Discord Bot and Minecraft Mod in one bundle. credits=Erdragh -version=1.4.0 +version=1.5.0 group=dev.erdragh.astralbot modId=astralbot modAuthor=Erdragh @@ -13,9 +13,9 @@ modAuthor=Erdragh # Minecraft things enabledPlatforms=fabric,forge # Specified here because it's used in both the fabric and common subproject -fabricLoaderVersion=0.15.10 +fabricLoaderVersion=0.15.11 fabricApiVersion=0.91.0 -fabricKotlinVersion=1.10.19+kotlin.1.9.23 +fabricKotlinVersion=1.11.0+kotlin.2.0.0 minecraftVersion=1.20.1 parchmentVersion=2023.09.03 @@ -23,11 +23,12 @@ parchmentVersion=2023.09.03 forgeConfigAPIVersion=8.0.0 # Discord Interactions -jdaVersion=5.0.0-beta.23 +jdaVersion=5.0.0-beta.24 +dcWebhooksVersion=0.8.4 # Database Interactions -exposedVersion=0.49.0 -sqliteJDBCVersion=3.44.1.0 +exposedVersion=0.51.0 +sqliteJDBCVersion=3.46.0.0 # Message parsing commonmarkVersion=0.22.0 \ No newline at end of file