From 6a23f85a43582b86a1c7ce66b7f1cccd6848e476 Mon Sep 17 00:00:00 2001 From: Tommy Schmidt Date: Wed, 7 Jun 2023 21:34:41 +0200 Subject: [PATCH] feat: release version 2.2.0 (#79) feat(gh-73): `/get-server-details` now lists up to 5 recent errors (#74) feat: add optional support for DarkAtra/v-rising-discord-bot-companion ([ae17f2d](https://github.com/DarkAtra/v-rising-discord-bot/commit/ae17f2da71fc9da665d5c0f4ee87a726b40c4542)) fix: ignore casing when sorting the player list ([fdf0d70](https://github.com/DarkAtra/v-rising-discord-bot/commit/fdf0d708672298e0dc2310aad8da329101955564)) chore(deps): update dependency org.mockito.kotlin:mockito-kotlin to v5 chore(deps): update dependency org.springframework.boot:spring-boot-starter-parent to v3.1.0 --- .github/workflows/build.yml | 1 + README.md | 71 +++++---- pom.xml | 23 ++- .../kotlin/de/darkatra/vrising/discord/Bot.kt | 8 +- .../darkatra/vrising/discord/BotProperties.kt | 8 + .../vrising/discord/BotRuntimeHints.kt | 1 + .../vrising/discord/ServerStatusMonitor.kt | 71 --------- .../discord/ServerStatusMonitorService.kt | 148 ------------------ .../botcompanion/BotCompanionClient.kt | 37 +++++ .../botcompanion/model/CharacterResponse.kt | 61 ++++++++ .../serverquery}/ServerQueryClient.kt | 2 +- .../discord/command/AddServerCommand.kt | 44 ++++-- .../command/GetServerDetailsCommand.kt | 44 +++++- .../discord/command/ListServersCommand.kt | 8 +- .../discord/command/RemoveServerCommand.kt | 6 +- .../discord/command/UpdateServerCommand.kt | 45 +++++- .../parameter/DisplayGearLevelParameter.kt | 20 +++ .../DisplayServerDescriptionParameter.kt | 2 +- .../parameter/ServerApiHostnameParameter.kt | 34 ++++ .../parameter/ServerApiPortParameter.kt | 20 +++ .../ServerStatusMonitorStatusParameter.kt | 2 +- .../discord/migration/DatabaseMigration.kt | 5 +- .../migration/DatabaseMigrationService.kt | 46 ++++-- .../InvalidDiscordChannelException.kt | 3 + .../serverstatus/ServerInfoResolver.kt | 46 ++++++ .../{ => serverstatus}/ServerStatusEmbed.kt | 35 +++-- .../ServerStatusMonitorRepository.kt | 55 +++++++ .../ServerStatusMonitorService.kt | 122 +++++++++++++++ .../discord/serverstatus/model/Error.kt | 6 + .../discord/serverstatus/model/Player.kt | 8 + .../discord/serverstatus/model/ServerInfo.kt | 12 ++ .../serverstatus/model/ServerStatusMonitor.kt | 54 +++++++ .../model/ServerStatusMonitorBuilder.kt | 40 +++++ .../model}/ServerStatusMonitorStatus.kt | 2 +- src/main/resources/application.yml | 3 + .../vrising/discord/RuntimeHintsTest.kt | 1 + .../discord/ServerStatusMonitorTestUtils.kt | 7 +- .../botcompanion/BotCompanionClientTest.kt | 98 ++++++++++++ .../migration/DatabaseMigrationServiceTest.kt | 48 +++++- .../ServerStatusMonitorRepositoryTest.kt} | 25 ++- 40 files changed, 940 insertions(+), 332 deletions(-) delete mode 100644 src/main/kotlin/de/darkatra/vrising/discord/ServerStatusMonitor.kt delete mode 100644 src/main/kotlin/de/darkatra/vrising/discord/ServerStatusMonitorService.kt create mode 100644 src/main/kotlin/de/darkatra/vrising/discord/clients/botcompanion/BotCompanionClient.kt create mode 100644 src/main/kotlin/de/darkatra/vrising/discord/clients/botcompanion/model/CharacterResponse.kt rename src/main/kotlin/de/darkatra/vrising/discord/{ => clients/serverquery}/ServerQueryClient.kt (96%) create mode 100644 src/main/kotlin/de/darkatra/vrising/discord/command/parameter/DisplayGearLevelParameter.kt create mode 100644 src/main/kotlin/de/darkatra/vrising/discord/command/parameter/ServerApiHostnameParameter.kt create mode 100644 src/main/kotlin/de/darkatra/vrising/discord/command/parameter/ServerApiPortParameter.kt create mode 100644 src/main/kotlin/de/darkatra/vrising/discord/serverstatus/InvalidDiscordChannelException.kt create mode 100644 src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerInfoResolver.kt rename src/main/kotlin/de/darkatra/vrising/discord/{ => serverstatus}/ServerStatusEmbed.kt (61%) create mode 100644 src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusMonitorRepository.kt create mode 100644 src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusMonitorService.kt create mode 100644 src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/Error.kt create mode 100644 src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/Player.kt create mode 100644 src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerInfo.kt create mode 100644 src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerStatusMonitor.kt create mode 100644 src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerStatusMonitorBuilder.kt rename src/main/kotlin/de/darkatra/vrising/discord/{ => serverstatus/model}/ServerStatusMonitorStatus.kt (54%) create mode 100644 src/test/kotlin/de/darkatra/vrising/discord/botcompanion/BotCompanionClientTest.kt rename src/test/kotlin/de/darkatra/vrising/discord/{ServerStatusMonitorServiceTest.kt => serverstatus/ServerStatusMonitorRepositoryTest.kt} (61%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 90450d1..1078478 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - next paths: - 'src/**' - 'pom.xml' diff --git a/README.md b/README.md index 02cbd93..14dfee9 100644 --- a/README.md +++ b/README.md @@ -22,60 +22,75 @@ Lists all server status monitors. Adds a server to the status monitor. -| Parameter | Description | Required | -|------------------------------|--------------------------------------------------------------------------------------------------------------------------|----------| -| `server-hostname` | The hostname of the server to add a status monitor for. | `true` | -| `server-query-port` | The query port of the server to add a status monitor for. | `true` | -| `display-server-description` | Whether or not to display the v rising server description on discord. Defaults to not displaying the server description. | `false` | +| Parameter | Description | Required | Default value | +|------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|---------------| +| `server-hostname` | The hostname of the server to add a status monitor for. | `true` | `null` | +| `server-query-port` | The query port of the server to add a status monitor for. | `true` | `null` | +| `server-api-hostname` | The hostname to use when querying the server's api. Use `~` to set the value to `null`. This is required to integrate with [v-rising-discord-bot-companion](https://github.com/DarkAtra/v-rising-discord-bot-companion) | `false` | `null` | +| `server-api-port` | The api port of the server. Use `-1` to set the value to `null`. This is required to integrate with [v-rising-discord-bot-companion](https://github.com/DarkAtra/v-rising-discord-bot-companion) | `false` | `null` | +| `display-server-description` | Whether or not to display the v rising server description on discord. | `false` | `true` | +| `display-player-gear-level` | Whether or not to display each player's gear level. Only honored if the `server-api-port` is set. | `false` | `true` | ### `/update-server` Updates the given server status monitor. Only the parameters that were specified when the command was executed are updated. All other parameters remain untouched. -| Parameter | Description | Required | -|------------------------------|-----------------------------------------------------------------------|----------| -| `server-status-monitor-id` | The id of the server status monitor. | `true` | -| `server-hostname` | The hostname of the server to add a status monitor for. | `false` | -| `server-query-port` | The query port of the server to add a status monitor for. | `false` | -| `status` | The status of the server status monitor. Either ACTIVE or INACTIVE. | `false` | -| `display-server-description` | Whether or not to display the v rising server description on discord. | `false` | +| Parameter | Description | Required | Default value | +|------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|---------------| +| `server-status-monitor-id` | The id of the server status monitor. | `true` | `null` | +| `server-hostname` | The hostname of the server to add a status monitor for. | `false` | `null` | +| `server-query-port` | The query port of the server to add a status monitor for. | `false` | `null` | +| `server-api-hostname` | The hostname to use when querying the server's api. Use `~` to set the value to `null`. This is required to integrate with [v-rising-discord-bot-companion](https://github.com/DarkAtra/v-rising-discord-bot-companion) | `false` | `null` | +| `server-api-port` | The api port of the server. Use `-1` to set the value to `null`. This is required to integrate with [v-rising-discord-bot-companion](https://github.com/DarkAtra/v-rising-discord-bot-companion) | `false` | `null` | +| `status` | The status of the server status monitor. Either `ACTIVE` or `INACTIVE`. | `false` | `null` | +| `display-server-description` | Whether or not to display the v rising server description on discord. | `false` | `null` | +| `display-player-gear-level` | Whether or not to display each player's gear level. Only honored if the `server-api-port` is set. | `false` | `null` | ### `/remove-server` Removes a server from the status monitor. -| Parameter | Description | Required | -|----------------------------|--------------------------------------|----------| -| `server-status-monitor-id` | The id of the server status monitor. | `true` | +| Parameter | Description | Required | Default value | +|----------------------------|--------------------------------------|----------|---------------| +| `server-status-monitor-id` | The id of the server status monitor. | `true` | `null` | ### `/get-server-details` Gets all the configuration details for the specified server. -| Parameter | Description | Required | -|----------------------------|--------------------------------------|----------| -| `server-status-monitor-id` | The id of the server status monitor. | `true` | +| Parameter | Description | Required | Default value | +|----------------------------|--------------------------------------|----------|---------------| +| `server-status-monitor-id` | The id of the server status monitor. | `true` | `null` | ## Configuration Properties -| Property | Type | Description | Default value | -|---------------------------|----------|----------------------------------------------------------------------------------------------------------------------------------|------------------------| -| `bot.discord-bot-token` | String | The token for the discord bot. You can find this in the [discord developer portal](https://discord.com/developers/applications). | `null` | -| `bot.database-path` | Path | The path to the database file. Should be overwritten when running inside a docker container. | `./bot.db` | -| `bot.database-username` | String | The username for the database. | `v-rising-discord-bot` | -| `bot.database-password` | String | The password for the database. | `null` | -| `bot.update-delay` | Duration | The delay between status monitor updates. At least 30 seconds. | `1m` | -| `bot.max-failed-attempts` | Int | The maximum amount of attempts to be made until a server is disabled. Use `0` if you don't want to use this feature. | `0` | +| Property | Type | Description | Default value | +|--------------------------------|----------|----------------------------------------------------------------------------------------------------------------------------------|------------------------| +| `bot.discord-bot-token` | String | The token for the discord bot. You can find this in the [discord developer portal](https://discord.com/developers/applications). | `null` | +| `bot.database-path` | Path | The path to the database file. Should be overwritten when running inside a docker container. | `./bot.db` | +| `bot.database-username` | String | The username for the database. | `v-rising-discord-bot` | +| `bot.database-password` | String | The password for the database. | `null` | +| `bot.update-delay` | Duration | The delay between status monitor updates. At least 30 seconds. | `1m` | +| `bot.max-failed-attempts` | Int | The maximum number of attempts to be made until a server is disabled. Use `0` if you don't want to use this feature. | `0` | +| `bot.max-recent-errors` | Int | The maximum number of errors to keep for debugging via `/get-server-details`. Use `0` if you don't want to use this feature. | `5` | +| `bot.max-characters-per-error` | Int | The maximum number of errors to keep for debugging via `/get-server-details`. Use `0` if you don't want to use this feature. | `200` | + +## [v-rising-discord-bot-companion](https://github.com/DarkAtra/v-rising-discord-bot-companion) Integration + +The v-rising-discord-bot is able to fetch additional data about players, such as the gear level, if +the [v-rising-discord-bot-companion](https://github.com/DarkAtra/v-rising-discord-bot-companion) is installed on the v rising server and the api port of that +server is accessible from where the bot runs. I highly recommend **not** exposing the api port to the internet. ## How to run it yourself using docker-compose -Find the latest docker image [here](https://github.com/DarkAtra/v-rising-discord-bot/pkgs/container/v-rising-discord-bot). If you prefer to use the JVM based version of this bot, remove the `-native` suffix from the `image` name in the example below. +Find the latest docker image [here](https://github.com/DarkAtra/v-rising-discord-bot/pkgs/container/v-rising-discord-bot). If you prefer to use the JVM based +version of this bot, remove the `-native` suffix from the `image` name in the example below. ```yaml services: v-rising-discord-bot: - image: ghcr.io/darkatra/v-rising-discord-bot:2.1.5-native + image: ghcr.io/darkatra/v-rising-discord-bot:2.2.0-native command: -Dagql.nativeTransport=false mem_reservation: 128M mem_limit: 256M diff --git a/pom.xml b/pom.xml index 22abb7a..8cce7bb 100644 --- a/pom.xml +++ b/pom.xml @@ -6,13 +6,13 @@ org.springframework.boot spring-boot-starter-parent - 3.0.6 + 3.1.0 de.darkatra v-rising-discord-bot - 2.1.5 + 2.2.0-next.11 jar @@ -41,7 +41,8 @@ 3.4.4 1.7 - 4.1.0 + 5.0.0 + 3.0.0-beta-8 @@ -71,6 +72,16 @@ true + + + org.springframework.boot + spring-boot-starter-json + + + com.fasterxml.jackson.module + jackson-module-kotlin + + dev.kord @@ -127,6 +138,12 @@ kotlinx-coroutines-test test + + com.github.tomakehurst + wiremock + ${wiremock.version} + test + diff --git a/src/main/kotlin/de/darkatra/vrising/discord/Bot.kt b/src/main/kotlin/de/darkatra/vrising/discord/Bot.kt index 7a9f00d..bedc45f 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/Bot.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/Bot.kt @@ -1,9 +1,13 @@ package de.darkatra.vrising.discord +import de.darkatra.vrising.discord.clients.botcompanion.model.CharacterResponse import de.darkatra.vrising.discord.command.Command import de.darkatra.vrising.discord.command.ValidationException import de.darkatra.vrising.discord.migration.DatabaseMigrationService import de.darkatra.vrising.discord.migration.Schema +import de.darkatra.vrising.discord.serverstatus.ServerStatusMonitorService +import de.darkatra.vrising.discord.serverstatus.model.Error +import de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitor import dev.kord.core.Kord import dev.kord.core.behavior.interaction.response.respond import dev.kord.core.event.gateway.ReadyEvent @@ -32,7 +36,7 @@ import java.util.concurrent.atomic.AtomicBoolean @SpringBootApplication @ImportRuntimeHints(BotRuntimeHints::class) @EnableConfigurationProperties(BotProperties::class) -@RegisterReflectionForBinding(BotProperties::class, Schema::class, ServerStatusMonitor::class) +@RegisterReflectionForBinding(BotProperties::class, Schema::class, ServerStatusMonitor::class, Error::class, CharacterResponse::class) class Bot( private val database: Nitrite, private val botProperties: BotProperties, @@ -95,7 +99,7 @@ class Bot( taskRegistrar.addFixedDelayTask(IntervalTask({ if (isReady.get() && kord.isActive) { runBlocking { - serverStatusMonitorService.updateServerStatusMonitor(kord) + serverStatusMonitorService.updateServerStatusMonitors(kord) } } }, botProperties.updateDelay, Duration.ofSeconds(5))) diff --git a/src/main/kotlin/de/darkatra/vrising/discord/BotProperties.kt b/src/main/kotlin/de/darkatra/vrising/discord/BotProperties.kt index 06bc1a0..bb1719a 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/BotProperties.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/BotProperties.kt @@ -32,4 +32,12 @@ class BotProperties { @field:Min(0) @field:NotNull var maxFailedAttempts: Int = 0 + + @field:Min(0) + @field:NotNull + var maxRecentErrors: Int = 5 + + @field:Min(1) + @field:NotNull + var maxCharactersPerError: Int = 200 } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/BotRuntimeHints.kt b/src/main/kotlin/de/darkatra/vrising/discord/BotRuntimeHints.kt index 3046030..815f539 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/BotRuntimeHints.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/BotRuntimeHints.kt @@ -45,6 +45,7 @@ class BotRuntimeHints : RuntimeHintsRegistrar { // required by nitrite to create a database with password hints.serialization().registerType(TypeReference.of("org.dizitart.no2.Security\$UserCredential")) // required by nitrite for serialization + hints.serialization().registerType(TypeReference.of("java.util.ArrayList")) hints.serialization().registerType(Attributes::class.java) hints.serialization().registerType(AtomicBoolean::class.java) hints.serialization().registerType(TypeReference.of("java.lang.Boolean")) diff --git a/src/main/kotlin/de/darkatra/vrising/discord/ServerStatusMonitor.kt b/src/main/kotlin/de/darkatra/vrising/discord/ServerStatusMonitor.kt deleted file mode 100644 index 5cd1939..0000000 --- a/src/main/kotlin/de/darkatra/vrising/discord/ServerStatusMonitor.kt +++ /dev/null @@ -1,71 +0,0 @@ -package de.darkatra.vrising.discord - -import org.dizitart.no2.IndexType -import org.dizitart.no2.objects.Id -import org.dizitart.no2.objects.Index -import org.dizitart.no2.objects.Indices - -@Indices( - value = [ - Index(value = "discordServerId", type = IndexType.NonUnique), - Index(value = "status", type = IndexType.NonUnique) - ] -) -data class ServerStatusMonitor( - @Id - val id: String, - val discordServerId: String, - val discordChannelId: String, - - val hostName: String, - val queryPort: Int, - val status: ServerStatusMonitorStatus, - val displayServerDescription: Boolean, - - var currentEmbedMessageId: String? = null, - var currentFailedAttempts: Int = 0, -) { - - fun builder(): ServerStatusMonitorBuilder { - return ServerStatusMonitorBuilder( - id = id, - discordServerId = discordServerId, - discordChannelId = discordChannelId, - hostName = hostName, - queryPort = queryPort, - status = status, - displayServerDescription = displayServerDescription, - currentEmbedMessageId = currentEmbedMessageId, - currentFailedAttempts = currentFailedAttempts - ) - } -} - -class ServerStatusMonitorBuilder( - var id: String, - var discordServerId: String, - var discordChannelId: String, - - var hostName: String, - var queryPort: Int, - var status: ServerStatusMonitorStatus, - var displayServerDescription: Boolean, - - var currentEmbedMessageId: String? = null, - var currentFailedAttempts: Int, -) { - - fun build(): ServerStatusMonitor { - return ServerStatusMonitor( - id = id, - discordServerId = discordServerId, - discordChannelId = discordChannelId, - hostName = hostName, - queryPort = queryPort, - status = status, - displayServerDescription = displayServerDescription, - currentEmbedMessageId = currentEmbedMessageId, - currentFailedAttempts = currentFailedAttempts - ) - } -} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/ServerStatusMonitorService.kt b/src/main/kotlin/de/darkatra/vrising/discord/ServerStatusMonitorService.kt deleted file mode 100644 index dfbd0be..0000000 --- a/src/main/kotlin/de/darkatra/vrising/discord/ServerStatusMonitorService.kt +++ /dev/null @@ -1,148 +0,0 @@ -package de.darkatra.vrising.discord - -import dev.kord.common.entity.Snowflake -import dev.kord.core.Kord -import dev.kord.core.behavior.channel.MessageChannelBehavior -import dev.kord.core.behavior.channel.createEmbed -import dev.kord.core.behavior.edit -import dev.kord.core.exception.EntityNotFoundException -import dev.kord.rest.builder.message.EmbedBuilder -import dev.kord.rest.builder.message.modify.embed -import org.dizitart.kno2.filters.and -import org.dizitart.no2.Nitrite -import org.dizitart.no2.objects.ObjectFilter -import org.dizitart.no2.objects.filters.ObjectFilters -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Service - -@Service -class ServerStatusMonitorService( - database: Nitrite, - private val serverQueryClient: ServerQueryClient, - private val botProperties: BotProperties -) { - - private val logger = LoggerFactory.getLogger(javaClass) - - private var repository = database.getRepository(ServerStatusMonitor::class.java) - - fun putServerStatusMonitor(serverStatusMonitor: ServerStatusMonitor) { - repository.update(serverStatusMonitor, true) - } - - fun removeServerStatusMonitor(id: String, discordServerId: String): Boolean { - return repository.remove(ObjectFilters.eq("id", id).and(ObjectFilters.eq("discordServerId", discordServerId))).affectedCount > 0 - } - - fun getServerStatusMonitor(id: String, discordServerId: String): ServerStatusMonitor? { - return repository.find(ObjectFilters.eq("id", id).and(ObjectFilters.eq("discordServerId", discordServerId))).firstOrNull() - } - - fun getServerStatusMonitors(discordServerId: String? = null, status: ServerStatusMonitorStatus? = null): List { - - var objectFilter: ObjectFilter? = null - - // apply filters - if (discordServerId != null) { - objectFilter = ObjectFilters.eq("discordServerId", discordServerId) - } - if (status != null) { - objectFilter += ObjectFilters.eq("status", status) - } - - return when { - objectFilter != null -> repository.find(objectFilter).toList() - else -> repository.find().toList() - } - } - - fun disableServerStatusMonitor(serverStatusMonitor: ServerStatusMonitor) { - putServerStatusMonitor( - serverStatusMonitor.builder().also { - it.status = ServerStatusMonitorStatus.INACTIVE - }.build() - ) - } - - suspend fun updateServerStatusMonitor(kord: Kord) { - getServerStatusMonitors(status = ServerStatusMonitorStatus.ACTIVE).forEach { serverStatusMonitor -> - updateServerStatusMonitor(kord, serverStatusMonitor) - } - } - - suspend fun updateServerStatusMonitor(kord: Kord, serverStatusMonitor: ServerStatusMonitor) { - runCatching { - - val channel = kord.getChannel(Snowflake(serverStatusMonitor.discordChannelId)) - if (channel == null || channel !is MessageChannelBehavior) { - logger.debug( - """Disabling server monitor '${serverStatusMonitor.id}' because the channel - |'${serverStatusMonitor.discordChannelId}' does not seem to exist""".trimMargin() - ) - disableServerStatusMonitor(serverStatusMonitor) - return - } - - val serverInfo = serverQueryClient.getServerInfo(serverStatusMonitor.hostName, serverStatusMonitor.queryPort) - val players = serverQueryClient.getPlayerList(serverStatusMonitor.hostName, serverStatusMonitor.queryPort) - val rules = serverQueryClient.getRules(serverStatusMonitor.hostName, serverStatusMonitor.queryPort) - - val embedCustomizer: (embedBuilder: EmbedBuilder) -> Unit = { embedBuilder -> - ServerStatusEmbed.buildEmbed( - serverInfo, - players, - rules, - serverStatusMonitor.displayServerDescription, - embedBuilder - ) - } - - val currentEmbedMessageId = serverStatusMonitor.currentEmbedMessageId - if (currentEmbedMessageId != null) { - try { - channel.getMessage(Snowflake(currentEmbedMessageId)) - .edit { embed(embedCustomizer) } - - serverStatusMonitor.currentFailedAttempts = 0 - putServerStatusMonitor(serverStatusMonitor) - - logger.debug("Successfully updated the status of server monitor: ${serverStatusMonitor.id}") - return - } catch (e: EntityNotFoundException) { - serverStatusMonitor.currentEmbedMessageId = null - } - } - - serverStatusMonitor.currentEmbedMessageId = channel.createEmbed(embedCustomizer).id.toString() - serverStatusMonitor.currentFailedAttempts = 0 - putServerStatusMonitor(serverStatusMonitor) - - logger.debug("Successfully updated the status and persisted the embedId of server monitor: ${serverStatusMonitor.id}") - - }.onFailure { throwable -> - - logger.error("Exception while fetching the status of ${serverStatusMonitor.id}", throwable) - serverStatusMonitor.currentFailedAttempts += 1 - putServerStatusMonitor(serverStatusMonitor) - - if (botProperties.maxFailedAttempts != 0 && serverStatusMonitor.currentFailedAttempts >= botProperties.maxFailedAttempts) { - logger.debug("Disabling server monitor '${serverStatusMonitor.id}' because it exceeded the max failed attempts.") - disableServerStatusMonitor(serverStatusMonitor) - - val channel = kord.getChannel(Snowflake(serverStatusMonitor.discordChannelId)) - if (channel == null || channel !is MessageChannelBehavior) { - return - } - - channel.createMessage( - """Disabled server status monitor '${serverStatusMonitor.id}' because the server did not - |respond after ${botProperties.maxFailedAttempts} attempts. - |Please make sure the server is running and is accessible from the internet to use this bot. - |You can re-enable the server status monitor with the update-server command.""".trimMargin() - ) - - return - } - } - } -} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/clients/botcompanion/BotCompanionClient.kt b/src/main/kotlin/de/darkatra/vrising/discord/clients/botcompanion/BotCompanionClient.kt new file mode 100644 index 0000000..dc85282 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/clients/botcompanion/BotCompanionClient.kt @@ -0,0 +1,37 @@ +package de.darkatra.vrising.discord.clients.botcompanion + +import de.darkatra.vrising.discord.clients.botcompanion.model.CharacterResponse +import org.slf4j.LoggerFactory +import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClientException +import org.springframework.web.client.RestTemplate +import java.net.InetSocketAddress +import java.net.URI +import java.time.Duration + +@Service +class BotCompanionClient { + + private val logger = LoggerFactory.getLogger(javaClass) + + private val restTemplate: RestTemplate = RestTemplateBuilder() + .setConnectTimeout(Duration.ofSeconds(10)) + .setReadTimeout(Duration.ofSeconds(10)) + .build() + + fun getCharacters(serverHostName: String, serverApiPort: Int): List { + + val address = InetSocketAddress(serverHostName, serverApiPort) + + @Suppress("HttpUrlsUsage") // the v risings http server does not support https + val requestURI = URI.create("http://${address.hostString}:${address.port}/v-rising-discord-bot/characters") + + return try { + restTemplate.getForObject(requestURI, Array::class.java)?.toList() ?: emptyList() + } catch (e: RestClientException) { + logger.warn("Could not resolve characters for '${address.hostString}:${address.port}'. Falling back to an empty list.", e) + emptyList() + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/clients/botcompanion/model/CharacterResponse.kt b/src/main/kotlin/de/darkatra/vrising/discord/clients/botcompanion/model/CharacterResponse.kt new file mode 100644 index 0000000..b49c5ab --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/clients/botcompanion/model/CharacterResponse.kt @@ -0,0 +1,61 @@ +package de.darkatra.vrising.discord.clients.botcompanion.model + +data class CharacterResponse( + val name: String, + val gearLevel: Int, + val clan: String?, + val killedVBloods: List +) + +enum class VBlood( + val displayName: String +) { + FOREST_WOLF("Alpha Wolf"), + BANDIT_STONEBREAKER("Errol the Stonebreaker"), + BANDIT_DEADEYE_FROSTARROW("Keely the Frost Archer"), + BANDIT_FOREMAN("Rufus the Foreman"), + UNDEAD_BISHOPOFDEATH("Goreswine the Ravager"), + BANDIT_STALKER("Grayson the Armourer"), + VERMIN_DIRERAT("Putrid Rat"), + BANDIT_DEADEYE_CHAOSARROW("Lidia the Chaos Archer"), + BANDIT_BOMBER("Clive the Firestarter"), + FOREST_BEAR_DIRE("Ferocious Bear"), + POLOMA("Polora the Feywalker"), + UNDEAD_PRIEST("Nicholaus the Fallen"), + BANDIT_TOUROK("Quincey the Bandit King"), + VILLAGER_TAILOR("Beatrice the Tailor"), + VHUNTER_LEADER("Tristan the Vampire Hunter"), + UNDEAD_LEADER("Kriig the Undead General"), + MILITIA_NUN("Christina the Sun Priestess"), + MILITIA_GUARD("Vincent the Frostbringer"), + UNDEAD_INFILTRATOR("Bane the Shadowblade"), + MILITIA_GLASSBLOWER("Grethel the Glassblower"), + UNDEAD_BISHOPOFSHADOWS("Leandra the Shadow Priestess"), + MILITIA_SCRIBE("Maja the Dark Savant"), + GEOMANCER_HUMAN("Terah the Geomancer"), + MILITIA_LONGBOWMAN_LIGHTARROW("Meredith the Bright Archer"), + VHUNTER_JADE("Jade the Vampire Hunter"), + MILITIA_BISHOPOFDUNLEY("Raziel the Shepherd"), + WENDIGO("Frostmaw the Mountain Terror"), + MILITIA_LEADER("Octavian the Militia Captain"), + GLOOMROT_VOLTAGE("Domina the Blade Dancer"), + GLOOMROT_PURIFIER("Angram the Purifier"), + GLOOMROT_IVA("Ziva the Engineer"), + SPIDER_QUEEN("Ungora the Spider Queen"), + VILLAGER_CURSEDWANDERER("The Old Wanderer"), + UNDEAD_ZEALOUSCULTIST("Foulrot the Soultaker"), + WEREWOLFCHIEFTAIN("Willfred the Werewolf Chief"), + CURSED_TOADKING("The Duke of Balaton"), + UNDEAD_CURSEDSMITH("Cyril the Cursed Smith"), + CHURCHOFLIGHT_OVERSEER("Sir Magnus the Overseer"), + ARCHMAGE("Mairwyn the Elementalist"), + CHURCHOFLIGHT_SOMMELIER("Baron du Bouchon the Sommelier"), + HARPY_MATRIARCH("Morian the Stormwing Matriarch"), + WINTER_YETI("Terrorclaw the Ogre"), + CHURCHOFLIGHT_CARDINAL("Azariel the Sunbringer"), + GLOOMROT_THEPROFESSOR("Henry Blackbrew the Doctor"), + CURSED_WITCH("Matka the Curse Weaver"), + GLOOMROT_RAILGUNSERGEANT("Voltatia the Power Master"), + BATVAMPIRE("Nightmarshal Styx the Sunderer"), + CHURCHOFLIGHT_PALADIN("Solarus the Immaculate") +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/ServerQueryClient.kt b/src/main/kotlin/de/darkatra/vrising/discord/clients/serverquery/ServerQueryClient.kt similarity index 96% rename from src/main/kotlin/de/darkatra/vrising/discord/ServerQueryClient.kt rename to src/main/kotlin/de/darkatra/vrising/discord/clients/serverquery/ServerQueryClient.kt index fd4fe50..f812104 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/ServerQueryClient.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/clients/serverquery/ServerQueryClient.kt @@ -1,4 +1,4 @@ -package de.darkatra.vrising.discord +package de.darkatra.vrising.discord.clients.serverquery import com.ibasco.agql.core.util.GeneralOptions import com.ibasco.agql.protocols.valve.source.query.SourceQueryClient diff --git a/src/main/kotlin/de/darkatra/vrising/discord/command/AddServerCommand.kt b/src/main/kotlin/de/darkatra/vrising/discord/command/AddServerCommand.kt index 01cc4da..645ec1c 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/command/AddServerCommand.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/command/AddServerCommand.kt @@ -1,16 +1,23 @@ package de.darkatra.vrising.discord.command import com.fasterxml.uuid.Generators -import de.darkatra.vrising.discord.ServerStatusMonitor -import de.darkatra.vrising.discord.ServerStatusMonitorService -import de.darkatra.vrising.discord.ServerStatusMonitorStatus +import de.darkatra.vrising.discord.command.parameter.ServerApiHostnameParameter import de.darkatra.vrising.discord.command.parameter.ServerHostnameParameter +import de.darkatra.vrising.discord.command.parameter.addDisplayPlayerGearLevelParameter import de.darkatra.vrising.discord.command.parameter.addDisplayServerDescriptionParameter +import de.darkatra.vrising.discord.command.parameter.addServerApiHostnameParameter +import de.darkatra.vrising.discord.command.parameter.addServerApiPortParameter import de.darkatra.vrising.discord.command.parameter.addServerHostnameParameter import de.darkatra.vrising.discord.command.parameter.addServerQueryPortParameter +import de.darkatra.vrising.discord.command.parameter.getDisplayPlayerGearLevelParameter import de.darkatra.vrising.discord.command.parameter.getDisplayServerDescriptionParameter +import de.darkatra.vrising.discord.command.parameter.getServerApiHostnameParameter +import de.darkatra.vrising.discord.command.parameter.getServerApiPortParameter import de.darkatra.vrising.discord.command.parameter.getServerHostnameParameter import de.darkatra.vrising.discord.command.parameter.getServerQueryPortParameter +import de.darkatra.vrising.discord.serverstatus.ServerStatusMonitorRepository +import de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitor +import de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitorStatus import dev.kord.core.Kord import dev.kord.core.behavior.interaction.response.respond import dev.kord.core.entity.interaction.ChatInputCommandInteraction @@ -19,7 +26,7 @@ import org.springframework.stereotype.Component @Component class AddServerCommand( - private val serverStatusMonitorService: ServerStatusMonitorService, + private val serverStatusMonitorRepository: ServerStatusMonitorRepository, ) : Command { private val name: String = "add-server" @@ -39,36 +46,49 @@ class AddServerCommand( addServerHostnameParameter() addServerQueryPortParameter() + addServerApiHostnameParameter(required = false) + addServerApiPortParameter(required = false) addDisplayServerDescriptionParameter(required = false) + addDisplayPlayerGearLevelParameter(required = false) } } override suspend fun handle(interaction: ChatInputCommandInteraction) { - val hostName = interaction.getServerHostnameParameter()!! + val hostname = interaction.getServerHostnameParameter()!! val queryPort = interaction.getServerQueryPortParameter()!! - val displayServerDescription = interaction.getDisplayServerDescriptionParameter() ?: false + val apiHostname = interaction.getServerApiHostnameParameter() + val apiPort = interaction.getServerApiPortParameter() + + val displayServerDescription = interaction.getDisplayServerDescriptionParameter() ?: true + val displayPlayerGearLevel = interaction.getDisplayPlayerGearLevelParameter() ?: true val discordServerId = (interaction as GuildChatInputCommandInteraction).guildId val channelId = interaction.channelId - ServerHostnameParameter.validate(hostName) + ServerHostnameParameter.validate(hostname) + ServerApiHostnameParameter.validate(apiHostname) - serverStatusMonitorService.putServerStatusMonitor( + val serverStatusMonitorId = Generators.timeBasedGenerator().generate() + serverStatusMonitorRepository.putServerStatusMonitor( ServerStatusMonitor( - id = Generators.timeBasedGenerator().generate().toString(), + id = serverStatusMonitorId.toString(), discordServerId = discordServerId.toString(), discordChannelId = channelId.toString(), - hostName = hostName, + hostname = hostname, queryPort = queryPort, + apiHostname = apiHostname, + apiPort = apiPort, status = ServerStatusMonitorStatus.ACTIVE, - displayServerDescription = displayServerDescription + displayServerDescription = displayServerDescription, + displayPlayerGearLevel = displayPlayerGearLevel, ) ) interaction.deferEphemeralResponse().respond { - content = "Added monitor for '${hostName}:${queryPort}' to channel '$channelId'. It may take some time until the status message appears." + content = """Added monitor with id '${serverStatusMonitorId}' for '${hostname}:${queryPort}' to channel '$channelId'. + |It may take some time until the status message appears.""".trimMargin() } } } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/command/GetServerDetailsCommand.kt b/src/main/kotlin/de/darkatra/vrising/discord/command/GetServerDetailsCommand.kt index aadaa46..bbbb92d 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/command/GetServerDetailsCommand.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/command/GetServerDetailsCommand.kt @@ -1,18 +1,21 @@ package de.darkatra.vrising.discord.command -import de.darkatra.vrising.discord.ServerStatusMonitorService +import de.darkatra.vrising.discord.BotProperties import de.darkatra.vrising.discord.command.parameter.addServerStatusMonitorIdParameter import de.darkatra.vrising.discord.command.parameter.getServerStatusMonitorIdParameter +import de.darkatra.vrising.discord.serverstatus.ServerStatusMonitorRepository import dev.kord.core.Kord import dev.kord.core.behavior.interaction.response.respond import dev.kord.core.entity.interaction.ChatInputCommandInteraction import dev.kord.core.entity.interaction.GuildChatInputCommandInteraction import dev.kord.rest.builder.message.modify.embed import org.springframework.stereotype.Component +import org.springframework.util.StringUtils @Component class GetServerDetailsCommand( - private val serverStatusMonitorService: ServerStatusMonitorService, + private val serverStatusMonitorRepository: ServerStatusMonitorRepository, + private val botProperties: BotProperties ) : Command { private val name: String = "get-server-details" @@ -38,7 +41,7 @@ class GetServerDetailsCommand( val serverStatusMonitorId = interaction.getServerStatusMonitorIdParameter() val discordServerId = (interaction as GuildChatInputCommandInteraction).guildId - val serverStatusMonitor = serverStatusMonitorService.getServerStatusMonitor(serverStatusMonitorId, discordServerId.toString()) + val serverStatusMonitor = serverStatusMonitorRepository.getServerStatusMonitor(serverStatusMonitorId, discordServerId.toString()) if (serverStatusMonitor == null) { interaction.deferEphemeralResponse().respond { content = "No server with id '$serverStatusMonitorId' was found." @@ -52,7 +55,7 @@ class GetServerDetailsCommand( field { name = "Hostname" - value = serverStatusMonitor.hostName + value = serverStatusMonitor.hostname inline = true } @@ -61,6 +64,23 @@ class GetServerDetailsCommand( value = "${serverStatusMonitor.queryPort}" inline = true } + field { + name = "Api Hostname" + value = when (serverStatusMonitor.apiHostname != null) { + true -> "${serverStatusMonitor.apiHostname}" + false -> "-" + } + inline = true + } + + field { + name = "Api Port" + value = when (serverStatusMonitor.apiPort != null) { + true -> "${serverStatusMonitor.apiPort}" + false -> "-" + } + inline = true + } field { name = "Display Server Description" @@ -68,6 +88,12 @@ class GetServerDetailsCommand( inline = true } + field { + name = "Display Player Gear Level" + value = "${serverStatusMonitor.displayPlayerGearLevel}" + inline = true + } + field { name = "Status" value = serverStatusMonitor.status.name @@ -97,6 +123,16 @@ class GetServerDetailsCommand( value = "${serverStatusMonitor.currentFailedAttempts}" inline = true } + + field { + name = "Most recent Errors" + value = when (serverStatusMonitor.recentErrors.isEmpty()) { + true -> "-" + false -> serverStatusMonitor.recentErrors.joinToString("\n") { + "${it.timestamp}```${StringUtils.truncate(it.message, botProperties.maxCharactersPerError)}```" + } + } + } } } } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/command/ListServersCommand.kt b/src/main/kotlin/de/darkatra/vrising/discord/command/ListServersCommand.kt index 87ca826..a0e98c1 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/command/ListServersCommand.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/command/ListServersCommand.kt @@ -1,6 +1,6 @@ package de.darkatra.vrising.discord.command -import de.darkatra.vrising.discord.ServerStatusMonitorService +import de.darkatra.vrising.discord.serverstatus.ServerStatusMonitorRepository import dev.kord.core.Kord import dev.kord.core.behavior.interaction.response.respond import dev.kord.core.entity.interaction.ChatInputCommandInteraction @@ -9,7 +9,7 @@ import org.springframework.stereotype.Component @Component class ListServersCommand( - private val serverStatusMonitorService: ServerStatusMonitorService, + private val serverStatusMonitorRepository: ServerStatusMonitorRepository, ) : Command { private val name: String = "list-servers" @@ -32,13 +32,13 @@ class ListServersCommand( val discordServerId = (interaction as GuildChatInputCommandInteraction).guildId - val serverStatusConfigurations = serverStatusMonitorService.getServerStatusMonitors(discordServerId.toString()) + val serverStatusConfigurations = serverStatusMonitorRepository.getServerStatusMonitors(discordServerId.toString()) interaction.deferEphemeralResponse().respond { content = when (serverStatusConfigurations.isEmpty()) { true -> "No servers found." false -> serverStatusConfigurations.joinToString(separator = "\n") { serverStatusConfiguration -> - "${serverStatusConfiguration.id} - ${serverStatusConfiguration.hostName}:${serverStatusConfiguration.queryPort} - ${serverStatusConfiguration.status.name}" + "${serverStatusConfiguration.id} - ${serverStatusConfiguration.hostname}:${serverStatusConfiguration.queryPort} - ${serverStatusConfiguration.status.name}" } } } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/command/RemoveServerCommand.kt b/src/main/kotlin/de/darkatra/vrising/discord/command/RemoveServerCommand.kt index 5285e11..bcc8ade 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/command/RemoveServerCommand.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/command/RemoveServerCommand.kt @@ -1,8 +1,8 @@ package de.darkatra.vrising.discord.command -import de.darkatra.vrising.discord.ServerStatusMonitorService import de.darkatra.vrising.discord.command.parameter.addServerStatusMonitorIdParameter import de.darkatra.vrising.discord.command.parameter.getServerStatusMonitorIdParameter +import de.darkatra.vrising.discord.serverstatus.ServerStatusMonitorRepository import dev.kord.core.Kord import dev.kord.core.behavior.interaction.response.respond import dev.kord.core.entity.interaction.ChatInputCommandInteraction @@ -11,7 +11,7 @@ import org.springframework.stereotype.Component @Component class RemoveServerCommand( - private val serverStatusMonitorService: ServerStatusMonitorService, + private val serverStatusMonitorRepository: ServerStatusMonitorRepository, ) : Command { private val name: String = "remove-server" @@ -37,7 +37,7 @@ class RemoveServerCommand( val serverStatusMonitorId = interaction.getServerStatusMonitorIdParameter() val discordServerId = (interaction as GuildChatInputCommandInteraction).guildId - val wasSuccessful = serverStatusMonitorService.removeServerStatusMonitor(serverStatusMonitorId, discordServerId.toString()) + val wasSuccessful = serverStatusMonitorRepository.removeServerStatusMonitor(serverStatusMonitorId, discordServerId.toString()) interaction.deferEphemeralResponse().respond { content = when (wasSuccessful) { diff --git a/src/main/kotlin/de/darkatra/vrising/discord/command/UpdateServerCommand.kt b/src/main/kotlin/de/darkatra/vrising/discord/command/UpdateServerCommand.kt index b4cb02b..f0df06d 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/command/UpdateServerCommand.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/command/UpdateServerCommand.kt @@ -1,17 +1,24 @@ package de.darkatra.vrising.discord.command -import de.darkatra.vrising.discord.ServerStatusMonitorService +import de.darkatra.vrising.discord.command.parameter.ServerApiHostnameParameter import de.darkatra.vrising.discord.command.parameter.ServerHostnameParameter +import de.darkatra.vrising.discord.command.parameter.addDisplayPlayerGearLevelParameter import de.darkatra.vrising.discord.command.parameter.addDisplayServerDescriptionParameter +import de.darkatra.vrising.discord.command.parameter.addServerApiHostnameParameter +import de.darkatra.vrising.discord.command.parameter.addServerApiPortParameter import de.darkatra.vrising.discord.command.parameter.addServerHostnameParameter import de.darkatra.vrising.discord.command.parameter.addServerQueryPortParameter import de.darkatra.vrising.discord.command.parameter.addServerStatusMonitorIdParameter import de.darkatra.vrising.discord.command.parameter.addServerStatusMonitorStatusParameter +import de.darkatra.vrising.discord.command.parameter.getDisplayPlayerGearLevelParameter import de.darkatra.vrising.discord.command.parameter.getDisplayServerDescriptionParameter +import de.darkatra.vrising.discord.command.parameter.getServerApiHostnameParameter +import de.darkatra.vrising.discord.command.parameter.getServerApiPortParameter import de.darkatra.vrising.discord.command.parameter.getServerHostnameParameter import de.darkatra.vrising.discord.command.parameter.getServerQueryPortParameter import de.darkatra.vrising.discord.command.parameter.getServerStatusMonitorIdParameter import de.darkatra.vrising.discord.command.parameter.getServerStatusMonitorStatusParameter +import de.darkatra.vrising.discord.serverstatus.ServerStatusMonitorRepository import dev.kord.core.Kord import dev.kord.core.behavior.interaction.response.respond import dev.kord.core.entity.interaction.ChatInputCommandInteraction @@ -20,7 +27,7 @@ import org.springframework.stereotype.Component @Component class UpdateServerCommand( - private val serverStatusMonitorService: ServerStatusMonitorService, + private val serverStatusMonitorRepository: ServerStatusMonitorRepository, ) : Command { private val name: String = "update-server" @@ -41,22 +48,30 @@ class UpdateServerCommand( addServerHostnameParameter(required = false) addServerQueryPortParameter(required = false) + addServerApiHostnameParameter(required = false) + addServerApiPortParameter(required = false) addServerStatusMonitorStatusParameter(required = false) + addDisplayServerDescriptionParameter(required = false) + addDisplayPlayerGearLevelParameter(required = false) } } override suspend fun handle(interaction: ChatInputCommandInteraction) { val serverStatusMonitorId = interaction.getServerStatusMonitorIdParameter() - val hostName = interaction.getServerHostnameParameter() + val hostname = interaction.getServerHostnameParameter() val queryPort = interaction.getServerQueryPortParameter() + val apiHostname = interaction.getServerApiHostnameParameter() + val apiPort = interaction.getServerApiPortParameter() val status = interaction.getServerStatusMonitorStatusParameter() + val displayServerDescription = interaction.getDisplayServerDescriptionParameter() + val displayPlayerGearLevel = interaction.getDisplayPlayerGearLevelParameter() val discordServerId = (interaction as GuildChatInputCommandInteraction).guildId - val serverStatusMonitor = serverStatusMonitorService.getServerStatusMonitor(serverStatusMonitorId, discordServerId.toString()) + val serverStatusMonitor = serverStatusMonitorRepository.getServerStatusMonitor(serverStatusMonitorId, discordServerId.toString()) if (serverStatusMonitor == null) { interaction.deferEphemeralResponse().respond { content = "No server with id '$serverStatusMonitorId' was found." @@ -65,21 +80,35 @@ class UpdateServerCommand( } val serverStatusMonitorBuilder = serverStatusMonitor.builder() - if (hostName != null) { - ServerHostnameParameter.validate(hostName) - serverStatusMonitorBuilder.hostName = hostName + if (hostname != null) { + ServerHostnameParameter.validate(hostname) + serverStatusMonitorBuilder.hostname = hostname } if (queryPort != null) { serverStatusMonitorBuilder.queryPort = queryPort } + if (apiHostname != null) { + if (apiHostname == "~") { + serverStatusMonitorBuilder.apiHostname = null + } else { + ServerApiHostnameParameter.validate(apiHostname) + serverStatusMonitorBuilder.apiHostname = apiHostname + } + } + if (apiPort != null) { + serverStatusMonitorBuilder.apiPort = if (apiPort == -1) null else apiPort + } if (status != null) { serverStatusMonitorBuilder.status = status } if (displayServerDescription != null) { serverStatusMonitorBuilder.displayServerDescription = displayServerDescription } + if (displayPlayerGearLevel != null) { + serverStatusMonitorBuilder.displayPlayerGearLevel = displayPlayerGearLevel + } - serverStatusMonitorService.putServerStatusMonitor(serverStatusMonitorBuilder.build()) + serverStatusMonitorRepository.putServerStatusMonitor(serverStatusMonitorBuilder.build()) interaction.deferEphemeralResponse().respond { content = "Updated server status monitor with id '${serverStatusMonitorId}'. It may take some time until the status message is updated." diff --git a/src/main/kotlin/de/darkatra/vrising/discord/command/parameter/DisplayGearLevelParameter.kt b/src/main/kotlin/de/darkatra/vrising/discord/command/parameter/DisplayGearLevelParameter.kt new file mode 100644 index 0000000..0f5b16e --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/command/parameter/DisplayGearLevelParameter.kt @@ -0,0 +1,20 @@ +package de.darkatra.vrising.discord.command.parameter + +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder +import dev.kord.rest.builder.interaction.boolean + +private const val PARAMETER_NAME = "display-player-gear-level" + +fun GlobalChatInputCreateBuilder.addDisplayPlayerGearLevelParameter(required: Boolean = true) { + boolean( + name = PARAMETER_NAME, + description = "Whether or not to display each player's gear level. Defaults to true." + ) { + this.required = required + } +} + +fun ChatInputCommandInteraction.getDisplayPlayerGearLevelParameter(): Boolean? { + return command.booleans[PARAMETER_NAME] +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/command/parameter/DisplayServerDescriptionParameter.kt b/src/main/kotlin/de/darkatra/vrising/discord/command/parameter/DisplayServerDescriptionParameter.kt index fe907b9..e219aa4 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/command/parameter/DisplayServerDescriptionParameter.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/command/parameter/DisplayServerDescriptionParameter.kt @@ -9,7 +9,7 @@ private const val PARAMETER_NAME = "display-server-description" fun GlobalChatInputCreateBuilder.addDisplayServerDescriptionParameter(required: Boolean = true) { boolean( name = PARAMETER_NAME, - description = "Whether or not to display the v rising server description on discord." + description = "Whether or not to display the v rising server description on discord. Defaults to true." ) { this.required = required } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/command/parameter/ServerApiHostnameParameter.kt b/src/main/kotlin/de/darkatra/vrising/discord/command/parameter/ServerApiHostnameParameter.kt new file mode 100644 index 0000000..122c8d9 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/command/parameter/ServerApiHostnameParameter.kt @@ -0,0 +1,34 @@ +package de.darkatra.vrising.discord.command.parameter + +import de.darkatra.vrising.discord.command.ValidationException +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder +import dev.kord.rest.builder.interaction.string +import org.apache.commons.validator.routines.DomainValidator +import org.apache.commons.validator.routines.InetAddressValidator + +private const val PARAMETER_NAME = "server-api-hostname" + +fun GlobalChatInputCreateBuilder.addServerApiHostnameParameter(required: Boolean = true) { + string( + name = PARAMETER_NAME, + description = "The hostname to use when querying the server's api." + ) { + this.required = required + } +} + +fun ChatInputCommandInteraction.getServerApiHostnameParameter(): String? { + return command.strings[PARAMETER_NAME] +} + +object ServerApiHostnameParameter { + fun validate(serverApiHostname: String?) { + if (serverApiHostname == null) { + return + } + if (!InetAddressValidator.getInstance().isValid(serverApiHostname) && !DomainValidator.getInstance(true).isValid(serverApiHostname)) { + throw ValidationException("'$PARAMETER_NAME' is not a valid ip address or domain name. Rejected: $serverApiHostname") + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/command/parameter/ServerApiPortParameter.kt b/src/main/kotlin/de/darkatra/vrising/discord/command/parameter/ServerApiPortParameter.kt new file mode 100644 index 0000000..9a2e8cc --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/command/parameter/ServerApiPortParameter.kt @@ -0,0 +1,20 @@ +package de.darkatra.vrising.discord.command.parameter + +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder +import dev.kord.rest.builder.interaction.integer + +private const val PARAMETER_NAME = "server-api-port" + +fun GlobalChatInputCreateBuilder.addServerApiPortParameter(required: Boolean = true) { + integer( + name = PARAMETER_NAME, + description = "The api port of the server." + ) { + this.required = required + } +} + +fun ChatInputCommandInteraction.getServerApiPortParameter(): Int? { + return command.integers[PARAMETER_NAME]?.let { Math.toIntExact(it) } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/command/parameter/ServerStatusMonitorStatusParameter.kt b/src/main/kotlin/de/darkatra/vrising/discord/command/parameter/ServerStatusMonitorStatusParameter.kt index 3e0e289..b3035c2 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/command/parameter/ServerStatusMonitorStatusParameter.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/command/parameter/ServerStatusMonitorStatusParameter.kt @@ -1,6 +1,6 @@ package de.darkatra.vrising.discord.command.parameter -import de.darkatra.vrising.discord.ServerStatusMonitorStatus +import de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitorStatus import dev.kord.core.entity.interaction.ChatInputCommandInteraction import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder import dev.kord.rest.builder.interaction.string diff --git a/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigration.kt b/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigration.kt index 1e4de94..68abb23 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigration.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigration.kt @@ -1,8 +1,11 @@ package de.darkatra.vrising.discord.migration import org.dizitart.no2.Document +import org.dizitart.no2.Nitrite class DatabaseMigration( + val description: String, val isApplicable: (currentSchemaVersion: SemanticVersion) -> Boolean, - val action: (document: Document) -> Unit + val documentAction: (document: Document) -> Unit = {}, + val databaseAction: (database: Nitrite) -> Unit = {} ) diff --git a/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigrationService.kt b/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigrationService.kt index 4223a88..edc092c 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigrationService.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigrationService.kt @@ -1,8 +1,9 @@ package de.darkatra.vrising.discord.migration -import de.darkatra.vrising.discord.ServerStatusMonitor -import de.darkatra.vrising.discord.ServerStatusMonitorStatus +import de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitor +import de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitorStatus import org.dizitart.no2.Nitrite +import org.dizitart.no2.objects.filters.ObjectFilters import org.dizitart.no2.util.ObjectUtils import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value @@ -21,29 +22,48 @@ class DatabaseMigrationService( private val currentAppVersion: SemanticVersion = Schema("V$appVersionFromPom").asSemanticVersion() private val migrations: List = listOf( DatabaseMigration( + description = "Set default value for displayPlayerGearLevel property.", isApplicable = { currentSchemaVersion -> currentSchemaVersion.major == 1 && currentSchemaVersion.minor <= 3 }, - action = { document -> document["displayPlayerGearLevel"] = true } + documentAction = { document -> document["displayPlayerGearLevel"] = true } ), DatabaseMigration( + description = "Set default value for status and displayServerDescription property.", isApplicable = { currentSchemaVersion -> currentSchemaVersion.major == 1 && currentSchemaVersion.minor <= 4 }, - action = { document -> + documentAction = { document -> document["status"] = ServerStatusMonitorStatus.ACTIVE.name document["displayServerDescription"] = true } ), - // Patch 0.5.42405 -> Gear Score will no longer be shown for online Vampires in the Steam Server List. DatabaseMigration( + description = "Remove the displayPlayerGearLevel property due to patch 0.5.42405.", isApplicable = { currentSchemaVersion -> currentSchemaVersion.major == 1 && currentSchemaVersion.minor <= 5 }, - action = { document -> + documentAction = { document -> // we can't remove the field completely due to how nitrites update function works // setting it to false instead (this was the default value in previous versions) document["displayPlayerGearLevel"] = false } ), DatabaseMigration( + description = "Set default value for currentFailedAttempts property.", isApplicable = { currentSchemaVersion -> currentSchemaVersion.major == 1 && currentSchemaVersion.minor <= 7 }, - action = { document -> document["currentFailedAttempts"] = 0 } + documentAction = { document -> document["currentFailedAttempts"] = 0 } ), + DatabaseMigration( + description = "Migrate the existing ServerStatusMonitor collection to the new collection name introduced by a package change and set defaults for displayClan, displayGearLevel and displayKilledVBloods.", + isApplicable = { currentSchemaVersion -> currentSchemaVersion.major < 2 || (currentSchemaVersion.major == 2 && currentSchemaVersion.minor <= 1) }, + databaseAction = { database -> + val oldCollection = database.getCollection("de.darkatra.vrising.discord.ServerStatusMonitor") + val newCollection = database.getCollection(ObjectUtils.findObjectStoreName(ServerStatusMonitor::class.java)) + oldCollection.find().forEach { document -> + newCollection.insert(document) + } + oldCollection.remove(ObjectFilters.ALL) + }, + documentAction = { document -> + document["hostname"] = document["hostName"] + document["displayPlayerGearLevel"] = true + } + ) ) fun migrateToLatestVersion(): Boolean { @@ -56,16 +76,22 @@ class DatabaseMigrationService( val migrationsToPerform = migrations.filter { migration -> migration.isApplicable(currentSchemaVersion) } if (migrationsToPerform.isEmpty()) { - logger.info("No migrations need to be performed.") + logger.info("No migrations need to be performed (V$currentSchemaVersion to V$currentAppVersion).") return false } logger.info("Will migrate from V$currentSchemaVersion to V$currentAppVersion by performing ${migrationsToPerform.size} migrations.") + migrationsToPerform.forEachIndexed { index, migration -> + logger.info("* $index: ${migration.description}") + } - val collection = database.getCollection(ObjectUtils.findObjectStoreName(ServerStatusMonitor::class.java)) + // perform migration that affect the whole database + migrationsToPerform.forEach { migration -> migration.databaseAction(database) } + // perform migration that affect documents in the ServerStatusMonitor collection + val collection = database.getCollection(ObjectUtils.findObjectStoreName(ServerStatusMonitor::class.java)) collection.find().forEach { document -> - migrationsToPerform.forEach { migration -> migration.action(document) } + migrationsToPerform.forEach { migration -> migration.documentAction(document) } collection.update(document) } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/InvalidDiscordChannelException.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/InvalidDiscordChannelException.kt new file mode 100644 index 0000000..c648bc6 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/InvalidDiscordChannelException.kt @@ -0,0 +1,3 @@ +package de.darkatra.vrising.discord.serverstatus + +class InvalidDiscordChannelException(message: String, val discordChannelId: String) : RuntimeException(message) diff --git a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerInfoResolver.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerInfoResolver.kt new file mode 100644 index 0000000..f7e5d14 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerInfoResolver.kt @@ -0,0 +1,46 @@ +package de.darkatra.vrising.discord.serverstatus + +import de.darkatra.vrising.discord.clients.botcompanion.BotCompanionClient +import de.darkatra.vrising.discord.clients.botcompanion.model.VBlood +import de.darkatra.vrising.discord.clients.serverquery.ServerQueryClient +import de.darkatra.vrising.discord.serverstatus.model.Player +import de.darkatra.vrising.discord.serverstatus.model.ServerInfo +import de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitor +import org.springframework.stereotype.Service + +@Service +class ServerInfoResolver( + private val serverQueryClient: ServerQueryClient, + private val botCompanionClient: BotCompanionClient, +) { + + suspend fun getServerInfo(serverStatusMonitor: ServerStatusMonitor): ServerInfo { + + val serverInfo = serverQueryClient.getServerInfo(serverStatusMonitor.hostname, serverStatusMonitor.queryPort) + val players = serverQueryClient.getPlayerList(serverStatusMonitor.hostname, serverStatusMonitor.queryPort) + val rules = serverQueryClient.getRules(serverStatusMonitor.hostname, serverStatusMonitor.queryPort) + val characters = when { + serverStatusMonitor.apiEnabled -> botCompanionClient.getCharacters(serverStatusMonitor.apiHostname!!, serverStatusMonitor.apiPort!!) + else -> emptyList() + } + + return ServerInfo( + name = serverInfo.name, + ip = serverInfo.hostAddress, + gamePort = serverInfo.gamePort, + queryPort = serverInfo.port, + numberOfPlayers = serverInfo.numOfPlayers, + maxPlayers = serverInfo.maxPlayers, + players = players.map { sourcePlayer -> + val character = characters.find { character -> character.name == sourcePlayer.name } + Player( + name = sourcePlayer.name, + gearLevel = character?.gearLevel, + clan = character?.clan, + killedVBloods = character?.killedVBloods?.map(VBlood::displayName) + ) + }, + rules = rules + ) + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/ServerStatusEmbed.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusEmbed.kt similarity index 61% rename from src/main/kotlin/de/darkatra/vrising/discord/ServerStatusEmbed.kt rename to src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusEmbed.kt index 9f95513..a0d374a 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/ServerStatusEmbed.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusEmbed.kt @@ -1,17 +1,17 @@ -package de.darkatra.vrising.discord +package de.darkatra.vrising.discord.serverstatus -import com.ibasco.agql.protocols.valve.source.query.info.SourceServer -import com.ibasco.agql.protocols.valve.source.query.players.SourcePlayer +import de.darkatra.vrising.discord.serverstatus.model.ServerInfo import dev.kord.common.Color import dev.kord.rest.builder.message.EmbedBuilder +import java.lang.String.CASE_INSENSITIVE_ORDER object ServerStatusEmbed { fun buildEmbed( - serverInfo: SourceServer, - players: List, - rules: Map, + serverInfo: ServerInfo, + apiEnabled: Boolean, displayServerDescription: Boolean, + displayPlayerGearLevel: Boolean, embedBuilder: EmbedBuilder ) { embedBuilder.apply { @@ -23,7 +23,7 @@ object ServerStatusEmbed { ) if (displayServerDescription) { - val description = rules.filterKeys { key -> key.startsWith("desc") } + val description = serverInfo.rules.filterKeys { key -> key.startsWith("desc") } .mapKeys { (key, _) -> key.removePrefix("desc").toInt() } .toList() .sortedBy { (key, _) -> key } @@ -38,36 +38,43 @@ object ServerStatusEmbed { field { name = "Ip and Port" - value = "${serverInfo.hostAddress}:${serverInfo.gamePort}" + value = "${serverInfo.ip}:${serverInfo.gamePort}" inline = true } field { name = "Online count" - value = "${serverInfo.numOfPlayers}/${serverInfo.maxPlayers}" + value = "${serverInfo.numberOfPlayers}/${serverInfo.maxPlayers}" inline = true } // days-runningv2 -> for how many days the server has been running in real-time days (introduced in 0.5.42553) // days-running -> for how many days the server has been running in in-game days (pre 0.5.42553) - val currentDay = rules["days-runningv2"] + val currentDay = serverInfo.rules["days-runningv2"] field { name = when (currentDay != null) { true -> "Days running" false -> "Ingame days" } // fallback to the old field for older servers and "-" if both fields are absent - value = "${currentDay ?: rules["days-running"] ?: "-"}" + value = "${currentDay ?: serverInfo.rules["days-running"] ?: "-"}" inline = true } - if (players.isNotEmpty()) { - players.sortedBy { player -> player.name } + if (serverInfo.players.isNotEmpty()) { + serverInfo.players.sortedWith(compareBy(CASE_INSENSITIVE_ORDER) { player -> player.name }) .chunked(20) .forEach { chunk -> field { name = "Online players" - value = chunk.joinToString(separator = "\n") { player -> "**${player.name}**" } + value = chunk.joinToString(separator = "\n") { player -> + buildString { + append("**${player.name}**") + if (apiEnabled && displayPlayerGearLevel) { + append(" - ${player.gearLevel ?: 0}") + } + } + } inline = true } } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusMonitorRepository.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusMonitorRepository.kt new file mode 100644 index 0000000..633b7d6 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusMonitorRepository.kt @@ -0,0 +1,55 @@ +package de.darkatra.vrising.discord.serverstatus + +import de.darkatra.vrising.discord.plus +import de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitor +import de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitorStatus +import org.dizitart.kno2.filters.and +import org.dizitart.no2.Nitrite +import org.dizitart.no2.objects.ObjectFilter +import org.dizitart.no2.objects.filters.ObjectFilters +import org.springframework.stereotype.Service + +@Service +class ServerStatusMonitorRepository( + database: Nitrite, +) { + private var repository = database.getRepository(ServerStatusMonitor::class.java) + + fun putServerStatusMonitor(serverStatusMonitor: ServerStatusMonitor) { + repository.update(serverStatusMonitor, true) + } + + fun removeServerStatusMonitor(id: String, discordServerId: String): Boolean { + return repository.remove(ObjectFilters.eq("id", id).and(ObjectFilters.eq("discordServerId", discordServerId))).affectedCount > 0 + } + + fun getServerStatusMonitor(id: String, discordServerId: String): ServerStatusMonitor? { + return repository.find(ObjectFilters.eq("id", id).and(ObjectFilters.eq("discordServerId", discordServerId))).firstOrNull() + } + + fun getServerStatusMonitors(discordServerId: String? = null, status: ServerStatusMonitorStatus? = null): List { + + var objectFilter: ObjectFilter? = null + + // apply filters + if (discordServerId != null) { + objectFilter = ObjectFilters.eq("discordServerId", discordServerId) + } + if (status != null) { + objectFilter += ObjectFilters.eq("status", status) + } + + return when { + objectFilter != null -> repository.find(objectFilter).toList() + else -> repository.find().toList() + } + } + + fun disableServerStatusMonitor(serverStatusMonitor: ServerStatusMonitor) { + putServerStatusMonitor( + serverStatusMonitor.builder().apply { + status = ServerStatusMonitorStatus.INACTIVE + }.build() + ) + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusMonitorService.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusMonitorService.kt new file mode 100644 index 0000000..8bdf852 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusMonitorService.kt @@ -0,0 +1,122 @@ +package de.darkatra.vrising.discord.serverstatus + +import de.darkatra.vrising.discord.BotProperties +import de.darkatra.vrising.discord.serverstatus.model.Error +import de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitor +import de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitorStatus +import dev.kord.common.entity.Snowflake +import dev.kord.core.Kord +import dev.kord.core.behavior.channel.MessageChannelBehavior +import dev.kord.core.behavior.channel.createEmbed +import dev.kord.core.behavior.edit +import dev.kord.core.exception.EntityNotFoundException +import dev.kord.rest.builder.message.EmbedBuilder +import dev.kord.rest.builder.message.modify.embed +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +class ServerStatusMonitorService( + private val serverStatusMonitorRepository: ServerStatusMonitorRepository, + private val serverInfoResolver: ServerInfoResolver, + private val botProperties: BotProperties +) { + + private val logger = LoggerFactory.getLogger(javaClass) + + suspend fun updateServerStatusMonitors(kord: Kord) { + serverStatusMonitorRepository.getServerStatusMonitors(status = ServerStatusMonitorStatus.ACTIVE).forEach { serverStatusMonitor -> + updateServerStatusMonitor(kord, serverStatusMonitor) + } + } + + suspend fun updateServerStatusMonitor(kord: Kord, serverStatusMonitor: ServerStatusMonitor) { + + val serverStatusMonitorBuilder = serverStatusMonitor.builder() + + try { + + val channel = getDiscordChannel(kord, serverStatusMonitor.discordChannelId) + val serverInfo = serverInfoResolver.getServerInfo(serverStatusMonitor) + + val embedCustomizer: (embedBuilder: EmbedBuilder) -> Unit = { embedBuilder -> + ServerStatusEmbed.buildEmbed( + serverInfo, + serverStatusMonitor.apiEnabled, + serverStatusMonitor.displayServerDescription, + serverStatusMonitor.displayPlayerGearLevel, + embedBuilder + ) + } + + val currentEmbedMessageId = serverStatusMonitor.currentEmbedMessageId + if (currentEmbedMessageId != null) { + try { + channel.getMessage(Snowflake(currentEmbedMessageId)) + .edit { embed(embedCustomizer) } + + serverStatusMonitorBuilder.currentFailedAttempts = 0 + serverStatusMonitorRepository.putServerStatusMonitor(serverStatusMonitorBuilder.build()) + + logger.debug("Successfully updated the status of server monitor: ${serverStatusMonitor.id}") + return + } catch (e: EntityNotFoundException) { + serverStatusMonitorBuilder.currentEmbedMessageId = null + } + } + + serverStatusMonitorBuilder.currentEmbedMessageId = channel.createEmbed(embedCustomizer).id.toString() + serverStatusMonitorBuilder.currentFailedAttempts = 0 + + serverStatusMonitorRepository.putServerStatusMonitor(serverStatusMonitorBuilder.build()) + + logger.debug("Successfully updated the status and persisted the embedId of server monitor: ${serverStatusMonitor.id}") + + } catch (e: InvalidDiscordChannelException) { + logger.debug("Disabling server monitor '${serverStatusMonitor.id}' because the channel '${e.discordChannelId}' does not seem to exist") + serverStatusMonitorRepository.disableServerStatusMonitor(serverStatusMonitor) + } catch (e: Exception) { + + logger.error("Exception while fetching the status of ${serverStatusMonitor.id}", e) + serverStatusMonitorBuilder.currentFailedAttempts += 1 + + if (botProperties.maxRecentErrors > 0) { + serverStatusMonitorBuilder.recentErrors = serverStatusMonitorBuilder.recentErrors + .takeLast((botProperties.maxRecentErrors - 1).coerceAtLeast(0)) + .toMutableList() + .apply { + add( + Error( + message = "${e::class.simpleName}: ${e.message}", + timestamp = Instant.now().toString() + ) + ) + } + } + + serverStatusMonitorRepository.putServerStatusMonitor(serverStatusMonitorBuilder.build()) + + if (botProperties.maxFailedAttempts != 0 && serverStatusMonitor.currentFailedAttempts >= botProperties.maxFailedAttempts) { + logger.debug("Disabling server monitor '${serverStatusMonitor.id}' because it exceeded the max failed attempts.") + serverStatusMonitorRepository.disableServerStatusMonitor(serverStatusMonitor) + + val channel = getDiscordChannel(kord, serverStatusMonitor.discordChannelId) + channel.createMessage( + """Disabled server status monitor '${serverStatusMonitor.id}' because the server did not + |respond after ${botProperties.maxFailedAttempts} attempts. + |Please make sure the server is running and is accessible from the internet to use this bot. + |You can re-enable the server status monitor with the update-server command.""".trimMargin() + ) + } + } + } + + private suspend fun getDiscordChannel(kord: Kord, discordChannelId: String): MessageChannelBehavior { + val channel = kord.getChannel(Snowflake(discordChannelId)) + if (channel == null || channel !is MessageChannelBehavior) { + throw InvalidDiscordChannelException("Discord Channel '$discordChannelId' does not exist.", discordChannelId) + } + return channel + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/Error.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/Error.kt new file mode 100644 index 0000000..a2a8aae --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/Error.kt @@ -0,0 +1,6 @@ +package de.darkatra.vrising.discord.serverstatus.model + +data class Error( + val message: String, + val timestamp: String +) diff --git a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/Player.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/Player.kt new file mode 100644 index 0000000..8b3b777 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/Player.kt @@ -0,0 +1,8 @@ +package de.darkatra.vrising.discord.serverstatus.model + +data class Player( + val name: String, + val gearLevel: Int?, + val clan: String?, + val killedVBloods: List? +) diff --git a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerInfo.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerInfo.kt new file mode 100644 index 0000000..3b5dc51 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerInfo.kt @@ -0,0 +1,12 @@ +package de.darkatra.vrising.discord.serverstatus.model + +data class ServerInfo( + val name: String, + val ip: String, + val gamePort: Int, + val queryPort: Int, + val numberOfPlayers: Int, + val maxPlayers: Int, + val players: List, + val rules: Map, +) diff --git a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerStatusMonitor.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerStatusMonitor.kt new file mode 100644 index 0000000..0724547 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerStatusMonitor.kt @@ -0,0 +1,54 @@ +package de.darkatra.vrising.discord.serverstatus.model + +import org.dizitart.no2.IndexType +import org.dizitart.no2.objects.Id +import org.dizitart.no2.objects.Index +import org.dizitart.no2.objects.Indices + +@Indices( + value = [ + Index(value = "discordServerId", type = IndexType.NonUnique), + Index(value = "status", type = IndexType.NonUnique) + ] +) +data class ServerStatusMonitor( + @Id + val id: String, + val discordServerId: String, + val discordChannelId: String, + + val hostname: String, + val queryPort: Int, + val apiHostname: String? = null, + val apiPort: Int? = null, + val status: ServerStatusMonitorStatus, + + val displayServerDescription: Boolean, + val displayPlayerGearLevel: Boolean, + + val currentEmbedMessageId: String? = null, + val currentFailedAttempts: Int = 0, + + val recentErrors: List = emptyList() +) { + + val apiEnabled = apiHostname != null && apiPort != null + + fun builder(): ServerStatusMonitorBuilder { + return ServerStatusMonitorBuilder( + id = id, + discordServerId = discordServerId, + discordChannelId = discordChannelId, + hostname = hostname, + queryPort = queryPort, + apiHostname = apiHostname, + apiPort = apiPort, + status = status, + displayServerDescription = displayServerDescription, + displayPlayerGearLevel = displayPlayerGearLevel, + currentEmbedMessageId = currentEmbedMessageId, + currentFailedAttempts = currentFailedAttempts, + recentErrors = recentErrors.toMutableList() + ) + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerStatusMonitorBuilder.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerStatusMonitorBuilder.kt new file mode 100644 index 0000000..1dde626 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerStatusMonitorBuilder.kt @@ -0,0 +1,40 @@ +package de.darkatra.vrising.discord.serverstatus.model + +class ServerStatusMonitorBuilder( + var id: String, + var discordServerId: String, + var discordChannelId: String, + + var hostname: String, + var queryPort: Int, + var apiHostname: String? = null, + var apiPort: Int? = null, + var status: ServerStatusMonitorStatus, + + var displayServerDescription: Boolean, + var displayPlayerGearLevel: Boolean, + + var currentEmbedMessageId: String? = null, + var currentFailedAttempts: Int, + + var recentErrors: MutableList +) { + + fun build(): ServerStatusMonitor { + return ServerStatusMonitor( + id = id, + discordServerId = discordServerId, + discordChannelId = discordChannelId, + hostname = hostname, + queryPort = queryPort, + apiHostname = apiHostname, + apiPort = apiPort, + status = status, + displayServerDescription = displayServerDescription, + displayPlayerGearLevel = displayPlayerGearLevel, + currentEmbedMessageId = currentEmbedMessageId, + currentFailedAttempts = currentFailedAttempts, + recentErrors = recentErrors + ) + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/ServerStatusMonitorStatus.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerStatusMonitorStatus.kt similarity index 54% rename from src/main/kotlin/de/darkatra/vrising/discord/ServerStatusMonitorStatus.kt rename to src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerStatusMonitorStatus.kt index c9953ce..5ecd524 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/ServerStatusMonitorStatus.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerStatusMonitorStatus.kt @@ -1,4 +1,4 @@ -package de.darkatra.vrising.discord +package de.darkatra.vrising.discord.serverstatus.model enum class ServerStatusMonitorStatus { INACTIVE, diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7de3525..7d5c98c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,3 +16,6 @@ bot: database-username: v-rising-discord-bot database-password: ~ update-delay: 1m + max-failed-attempts: 0 + max-recent-errors: 5 + max-characters-per-error: 200 diff --git a/src/test/kotlin/de/darkatra/vrising/discord/RuntimeHintsTest.kt b/src/test/kotlin/de/darkatra/vrising/discord/RuntimeHintsTest.kt index a33c90f..a02ea1c 100644 --- a/src/test/kotlin/de/darkatra/vrising/discord/RuntimeHintsTest.kt +++ b/src/test/kotlin/de/darkatra/vrising/discord/RuntimeHintsTest.kt @@ -1,6 +1,7 @@ package de.darkatra.vrising.discord import de.darkatra.vrising.discord.migration.Schema +import de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitor import org.junit.jupiter.api.Test import org.springframework.aot.hint.annotation.RegisterReflectionForBinding import org.springframework.boot.test.context.SpringBootTest diff --git a/src/test/kotlin/de/darkatra/vrising/discord/ServerStatusMonitorTestUtils.kt b/src/test/kotlin/de/darkatra/vrising/discord/ServerStatusMonitorTestUtils.kt index 4295a9f..7d45979 100644 --- a/src/test/kotlin/de/darkatra/vrising/discord/ServerStatusMonitorTestUtils.kt +++ b/src/test/kotlin/de/darkatra/vrising/discord/ServerStatusMonitorTestUtils.kt @@ -1,5 +1,7 @@ package de.darkatra.vrising.discord +import de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitor +import de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitorStatus import dev.kord.common.entity.Snowflake import kotlinx.datetime.toKotlinInstant import java.time.Instant @@ -17,10 +19,11 @@ object ServerStatusMonitorTestUtils { id = ID, discordServerId = DISCORD_SERVER_ID, discordChannelId = DISCORD_CHANNEL_ID, - hostName = HOST_NAME, + hostname = HOST_NAME, queryPort = QUERY_PORT, status = status, - displayServerDescription = false + displayServerDescription = true, + displayPlayerGearLevel = true ) } } diff --git a/src/test/kotlin/de/darkatra/vrising/discord/botcompanion/BotCompanionClientTest.kt b/src/test/kotlin/de/darkatra/vrising/discord/botcompanion/BotCompanionClientTest.kt new file mode 100644 index 0000000..bf71e1e --- /dev/null +++ b/src/test/kotlin/de/darkatra/vrising/discord/botcompanion/BotCompanionClientTest.kt @@ -0,0 +1,98 @@ +package de.darkatra.vrising.discord.botcompanion + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.core.WireMockConfiguration +import de.darkatra.vrising.discord.clients.botcompanion.BotCompanionClient +import de.darkatra.vrising.discord.clients.botcompanion.model.VBlood +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.DisabledInNativeImage +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType + +@DisabledInNativeImage +class BotCompanionClientTest { + + // workaround for https://github.com/wiremock/wiremock/issues/2202 + companion object { + private var wireMock: WireMockServer? = null + + @JvmStatic + @BeforeAll + fun beforeAll() { + wireMock = WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()) + wireMock!!.start() + } + + @JvmStatic + @AfterAll + fun afterAll() { + wireMock?.stop() + } + } + + private val botCompanionClient = BotCompanionClient() + + @Test + fun `should get characters`() { + + wireMock!!.stubFor( + WireMock.get("/v-rising-discord-bot/characters") + .willReturn( + WireMock.aResponse() + .withStatus(HttpStatus.OK.value()) + .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .withBody( + """ + [ + { + "name": "Atra", + "gearLevel": 83, + "clan": "Test", + "killedVBloods": [ + "FOREST_WOLF", + "BANDIT_STONEBREAKER" + ] + } + ]""".trimIndent() + ) + ) + ) + + val characters = botCompanionClient.getCharacters("localhost", wireMock!!.port()) + assertThat(characters).isNotEmpty() + + val character = characters.first() + assertThat(character.name).isEqualTo("Atra") + assertThat(character.gearLevel).isEqualTo(83) + assertThat(character.clan).isEqualTo("Test") + assertThat(character.killedVBloods).containsExactlyInAnyOrder(VBlood.FOREST_WOLF, VBlood.BANDIT_STONEBREAKER) + } + + @Test + fun `should handle errors getting characters`() { + + wireMock!!.stubFor( + WireMock.get("/v-rising-discord-bot/characters") + .willReturn( + WireMock.aResponse() + .withStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()) + .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .withBody( + """ + { + "type": "about:blank", + "title": "Internal Server Error" + }""".trimIndent() + ) + ) + ) + + val characters = botCompanionClient.getCharacters("localhost", wireMock!!.port()) + assertThat(characters).isEmpty() + } +} diff --git a/src/test/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigrationServiceTest.kt b/src/test/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigrationServiceTest.kt index 9a2e9e1..6a4a925 100644 --- a/src/test/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigrationServiceTest.kt +++ b/src/test/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigrationServiceTest.kt @@ -1,8 +1,11 @@ package de.darkatra.vrising.discord.migration import de.darkatra.vrising.discord.DatabaseConfigurationTestUtils +import de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitor import org.assertj.core.api.Assertions.assertThat +import org.dizitart.no2.Document import org.dizitart.no2.Nitrite +import org.dizitart.no2.util.ObjectUtils import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.DisabledInNativeImage @@ -25,7 +28,7 @@ class DatabaseMigrationServiceTest { appVersionFromPom = "1.5.0" ) - assertThat(databaseMigrationService.migrateToLatestVersion()).isTrue + assertThat(databaseMigrationService.migrateToLatestVersion()).isTrue() val repository = database.getRepository(Schema::class.java) @@ -42,15 +45,52 @@ class DatabaseMigrationServiceTest { repository.insert(Schema(appVersion = "V1.5.0")) repository.insert(Schema(appVersion = "V1.6.0")) repository.insert(Schema(appVersion = "V1.8.0")) + repository.insert(Schema(appVersion = "V2.2.0")) val databaseMigrationService = DatabaseMigrationService( database = database, - appVersionFromPom = "1.8.0" + appVersionFromPom = "2.2.0" ) - assertThat(databaseMigrationService.migrateToLatestVersion()).isFalse + assertThat(databaseMigrationService.migrateToLatestVersion()).isFalse() val schemas = repository.find().toList() - assertThat(schemas).hasSize(4) + assertThat(schemas).hasSize(5) + } + + @Test + fun `should migrate existing ServerStatusMonitor documents to new collection and cleanup obsolete data`() { + + val repository = database.getRepository(Schema::class.java) + repository.insert(Schema(appVersion = "V2.1.0")) + + val databaseMigrationService = DatabaseMigrationService( + database = database, + appVersionFromPom = "2.2.0" + ) + + val oldCollection = database.getCollection("de.darkatra.vrising.discord.ServerStatusMonitor") + val newCollection = database.getCollection(ObjectUtils.findObjectStoreName(ServerStatusMonitor::class.java)) + + oldCollection.insert( + arrayOf( + Document.createDocument("hostName", "test-hostname") + ) + ) + + assertThat(oldCollection.size()).isEqualTo(1) + assertThat(newCollection.size()).isEqualTo(0) + + assertThat(databaseMigrationService.migrateToLatestVersion()).isTrue() + + assertThat(oldCollection.size()).isEqualTo(0) + assertThat(newCollection.size()).isEqualTo(1) + + val migratedDocument = newCollection.find().first() + assertThat(migratedDocument["hostname"]).isEqualTo(migratedDocument["hostName"]) + assertThat(migratedDocument["displayPlayerGearLevel"]).isEqualTo(true) + + val schemas = repository.find().toList() + assertThat(schemas).hasSize(2) } } diff --git a/src/test/kotlin/de/darkatra/vrising/discord/ServerStatusMonitorServiceTest.kt b/src/test/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusMonitorRepositoryTest.kt similarity index 61% rename from src/test/kotlin/de/darkatra/vrising/discord/ServerStatusMonitorServiceTest.kt rename to src/test/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusMonitorRepositoryTest.kt index 18639c6..2c0de28 100644 --- a/src/test/kotlin/de/darkatra/vrising/discord/ServerStatusMonitorServiceTest.kt +++ b/src/test/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusMonitorRepositoryTest.kt @@ -1,23 +1,20 @@ -package de.darkatra.vrising.discord +package de.darkatra.vrising.discord.serverstatus +import de.darkatra.vrising.discord.DatabaseConfigurationTestUtils +import de.darkatra.vrising.discord.ServerStatusMonitorTestUtils +import de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitorStatus import org.assertj.core.api.Assertions.assertThat import org.dizitart.no2.Nitrite import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.DisabledInNativeImage -import org.mockito.Mockito -import java.time.Duration @DisabledInNativeImage -class ServerStatusMonitorServiceTest { +class ServerStatusMonitorRepositoryTest { private val nitrite: Nitrite = DatabaseConfigurationTestUtils.getTestDatabase() - private val serverQueryClient: ServerQueryClient = Mockito.mock(ServerQueryClient::class.java) - private val botProperties: BotProperties = BotProperties().apply { - updateDelay = Duration.ofSeconds(1) - } - private val serverStatusMonitorService = ServerStatusMonitorService(nitrite, serverQueryClient, botProperties) + private val serverStatusMonitorRepository = ServerStatusMonitorRepository(nitrite) @BeforeEach fun setUp() { @@ -27,11 +24,11 @@ class ServerStatusMonitorServiceTest { @Test fun `should get active server status monitors`() { - serverStatusMonitorService.putServerStatusMonitor( + serverStatusMonitorRepository.putServerStatusMonitor( ServerStatusMonitorTestUtils.getServerStatusMonitor(ServerStatusMonitorStatus.ACTIVE) ) - val serverStatusMonitors = serverStatusMonitorService.getServerStatusMonitors(status = ServerStatusMonitorStatus.ACTIVE) + val serverStatusMonitors = serverStatusMonitorRepository.getServerStatusMonitors(status = ServerStatusMonitorStatus.ACTIVE) assertThat(serverStatusMonitors).hasSize(1) @@ -39,18 +36,18 @@ class ServerStatusMonitorServiceTest { assertThat(serverStatusMonitor.id).isEqualTo(ServerStatusMonitorTestUtils.ID) assertThat(serverStatusMonitor.discordServerId).isEqualTo(ServerStatusMonitorTestUtils.DISCORD_SERVER_ID) assertThat(serverStatusMonitor.discordChannelId).isEqualTo(ServerStatusMonitorTestUtils.DISCORD_CHANNEL_ID) - assertThat(serverStatusMonitor.hostName).isEqualTo(ServerStatusMonitorTestUtils.HOST_NAME) + assertThat(serverStatusMonitor.hostname).isEqualTo(ServerStatusMonitorTestUtils.HOST_NAME) assertThat(serverStatusMonitor.queryPort).isEqualTo(ServerStatusMonitorTestUtils.QUERY_PORT) } @Test fun `should get no active server status monitors`() { - serverStatusMonitorService.putServerStatusMonitor( + serverStatusMonitorRepository.putServerStatusMonitor( ServerStatusMonitorTestUtils.getServerStatusMonitor(ServerStatusMonitorStatus.INACTIVE) ) - val serverStatusMonitors = serverStatusMonitorService.getServerStatusMonitors(status = ServerStatusMonitorStatus.ACTIVE) + val serverStatusMonitors = serverStatusMonitorRepository.getServerStatusMonitors(status = ServerStatusMonitorStatus.ACTIVE) assertThat(serverStatusMonitors).hasSize(0) }