diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8971aec..41fa3bb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,14 +13,7 @@ concurrency: group: ci-main permissions: - # used by semantic release - contents: write - issues: write - pull-requests: write - # used to publish the docker image - packages: write - # used by trivy - security-events: write + contents: read jobs: verify: @@ -28,8 +21,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -52,15 +43,11 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - uses: graalvm/setup-graalvm@v1.0.12 + - uses: graalvm/setup-graalvm@v1.2.1 with: - java-version: '17' - # distribution: 'graalvm' - version: '22.3.2' - components: 'native-image' + java-version: '21' + distribution: 'graalvm-community' - name: Run tests run: mvn -B -ntp -PnativeTest verify @@ -68,6 +55,11 @@ jobs: release: name: Build and Release runs-on: ubuntu-latest + permissions: + # required by semantic release + contents: write + # required to publish the docker image + packages: write needs: - verify - verify-native @@ -91,19 +83,3 @@ jobs: @semantic-release/exec env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Get Image Name - id: get-image-name - run: echo "image-name=$(mvn help:evaluate -Dexpression=image.name -q -DforceStdout)" >> $GITHUB_OUTPUT - - - name: Scan Docker Image for Vulnerabilities - uses: aquasecurity/trivy-action@0.12.0 - with: - image-ref: ${{ steps.get-image-name.outputs.image-name }} - format: sarif - output: trivy-results.sarif - - - name: Upload Trivy Results to GitHub Security Tab - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: trivy-results.sarif diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index c287b9b..17dee2a 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -41,12 +41,10 @@ jobs: with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - uses: graalvm/setup-graalvm@v1.0.12 + - uses: graalvm/setup-graalvm@v1.2.1 with: - java-version: '17' - # distribution: 'graalvm' - version: '22.3.2' - components: 'native-image' + java-version: '21' + distribution: 'graalvm-community' - name: Run tests run: mvn -B -ntp -PnativeTest verify diff --git a/.github/workflows/scan-docker-image.yml b/.github/workflows/scan-docker-image.yml index 0cc90f6..e2339de 100644 --- a/.github/workflows/scan-docker-image.yml +++ b/.github/workflows/scan-docker-image.yml @@ -32,7 +32,7 @@ jobs: run: echo "image-name=$(mvn help:evaluate -Dexpression=image.name -q -DforceStdout)" >> $GITHUB_OUTPUT - name: Scan Docker Image for Vulnerabilities - uses: aquasecurity/trivy-action@0.12.0 + uses: aquasecurity/trivy-action@0.24.0 with: image-ref: ${{ steps.get-image-name.outputs.image-name }} format: sarif diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar deleted file mode 100644 index cb28b0e..0000000 Binary files a/.mvn/wrapper/maven-wrapper.jar and /dev/null differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index ac18401..57fed98 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -14,5 +14,6 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.4/apache-maven-3.9.4-bin.zip -wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.7/apache-maven-3.8.7-bin.zip diff --git a/docs/bot-as-a-service.md b/docs/bot-as-a-service.md index bcea981..45f9c8d 100644 --- a/docs/bot-as-a-service.md +++ b/docs/bot-as-a-service.md @@ -23,26 +23,28 @@ Please [reach out](https://discord.gg/KcMcYKa6Nt) if something doesn't work for ## Setup Guide 1. [Invite Jarvis (the bot) to your discord server](https://discord.com/oauth2/authorize?client_id=982682186207592470). -2. Create a new channel that is dedicated for the status embed. +2. Create a new channel that is designated for the status embed. 3. Restrict the permissions for the new channel so that members of your server can read messages but can not post anything in there. This includes the following permissions: * `View Channel` * `Read Message History` -4. Ensure that `Jarvis` is allowed to read and write to the channel. This includes the following permissions: +4. Ensure that `Jarvis` is allowed to read from and write to the channel. This includes the following permissions: * `View Channel` * `Send Messages` * `Embed Links` * `Manage Messages` * `Read Message History` 5. Ensure that `ListOnSteam` in `ServerHostSettings.json` is set to `true`. This is required for the status embed to work. -6. Find the `IP Address` and `Query Port` of your V Rising server. [Battlemetrics](https://www.battlemetrics.com/servers/vrising) might come in handy here. -7. Navigate to the channel that you created in step 2 and use the `/add-server` command. Discord will ask you for a `server-hostname` and `server-query-port`. +6. Find the `IP Address` and `Query Port` of your v rising server. [Battlemetrics](https://www.battlemetrics.com/servers/vrising) might come in handy here. +7. Use the `/add-server` command and specify the `server-hostname` and `server-query-port`. This is where you enter the `IP Address` and `Query Port` that you determined in the previous step. -8. `Jarvis` will respond with a message telling you that you've successfully added your first status monitor for your V Rising server. -9. Now you only need to wait a minute for the status display to appear. + You will receive a `server-id` when to command completed successfully. You will need this id in the next step. +8. Use the `/configure-status-monitor` command and specify the `server-id` and `channel-id`. + The `channel-id` determines in which channel the status embed is posted. +9. Wait for a few minutes for the status embed to appear. {: .note } -> If no status embed appears after 2 minutes, use the `/get-server-details` command with the id that `Jarvis` gave you in step 7. +> If no status embed appears after 3 minutes, use the `/get-server-details` command with the `server-id` that `Jarvis` gave you in step 7. > This will give you a detailed error message of what went wrong. In most cases, it simply means that the `IP Address` or `Query Port` are incorrect. > You can always change them using the `/update-server` command. Feel free to check [the commands documentation](commands.md) for details. diff --git a/docs/bot-companion.md b/docs/bot-companion.md index ea62097..3a4e281 100644 --- a/docs/bot-companion.md +++ b/docs/bot-companion.md @@ -12,9 +12,12 @@ It does this by exposing [additional http endpoints](#endpoints) on the servers > I highly recommend not exposing the api port to the internet in an unprotected manner. The setup guide below explains how the API port can be secured using > Basic Authentication. +{: .warning } +> It is **not** possible to use the bot-companion with GPortal hosted server as they don't allow exposing the API port to the internet. + ## Setup Guide -1. [Install BepInEx](https://github.com/decaprime/VRising-Modding/releases) on your V Rising server. +1. [Install BepInEx](https://github.com/decaprime/VRising-Modding/releases) on your v rising server. 2. Download the [v-rising-discord-bot-companion.dll](https://github.com/DarkAtra/v-rising-discord-bot-companion/releases) * **or** clone [this repo](https://github.com/DarkAtra/v-rising-discord-bot-companion) and build it via `dotnet build`. This requires [dotnet 6.0](https://dotnet.microsoft.com/en-us/download/dotnet/6.0). @@ -27,9 +30,8 @@ It does this by exposing [additional http endpoints](#endpoints) on the servers "BindPort": 25570 } ``` - **It is not recommended to expose the api port to the internet in an unprotected manner.** Consider protecting the api port using a reverse proxy that - supports basic authentication or by using a firewall rule. -5. Start the V Rising server and test if the mod works as expect by running the following command in your + **It is not recommended to expose the api port to the internet in an unprotected manner.** More on that in the next few steps. +5. Start the v rising server and test if the mod works as expect by running the following command in your terminal: `curl -v http://localhost:25570/v-rising-discord-bot/characters`. Expect status code `200 OK` as soon as the server has fully started. 6. Secure the API port using [Basic Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). Navigate to the `BepInEx/config` folder and open `v-rising-discord-bot-companion.cfg`. The file should look something like this: @@ -67,16 +69,17 @@ It does this by exposing [additional http endpoints](#endpoints) on the servers 1. Make sure you're using [the latest version](https://github.com/DarkAtra/v-rising-discord-bot/releases) of the discord bot. * **Tip**: You can also find the current docker image [here](https://github.com/DarkAtra/v-rising-discord-bot/pkgs/container/v-rising-discord-bot) -2. Use the `/update-server` command to update the status monitor and set both `server-api-hostname`, `server-api-port`, `server-api-username` - and `server-api-password`. If the V Rising server and the discord bot are hosted on the same machine, set `server-api-hostname` to `localhost` +2. Use the `/update-server` command to update the server and specify a `server-api-hostname`, `server-api-port`, `server-api-username` + and `server-api-password`. If the v rising server and the discord bot are hosted on the same machine, set `server-api-hostname` to `localhost` and `server-api-port` to `25570` if you used the `ServerHostSettings.json` from above. 3. You should see the gear level for each player in the status embed the next time it is updated. ### Enabling the activity or kill feed -1. Use the `/update-server` command to update the status monitor and set `player-activity-feed-channel-id` to the id of the discord channel you want the - activity feed to appear in. You can do the same for the kill feed by setting `pvp-kill-feed-channel-id`. -2. The bot will now post a message whenever a player joins or leaves the server or whenever someone was killed in a PvP battle. It looks something like this: +1. Use the `/configure-player-activity-feed` or `/configure-pvp-kill-feed` command and set `channel-id` + to the id of the discord channel you want the player activity feed or pvp kill feed to appear in. +2. The bot will now post a message whenever a player joins or leaves the server or whenever someone was killed in a PvP battle. + It looks something like this: Companion Preview diff --git a/docs/commands.md b/docs/commands.md index 6bae8dc..58b96a0 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -6,70 +6,129 @@ nav_order: 4 # Discord Commands Please note that all commands are [guild](https://discord.com/developers/docs/resources/guild) specific by default. -All optional command parameters can be reset by passing `~` as an argument. -## `/list-servers` +## Server -Lists all server status monitors. +### `/list-servers` + +Use this command to list all servers that you have previously added using the `/add-server` command. Admins can use this command in DMs, see [Configuration Properties](configuration-properties.md) for details. | Parameter | Description | Required | Default value | |-----------|------------------------------------|----------|---------------| | `page` | The page to request. Zero indexed. | `false` | `0` | -## `/add-server` +### `/add-server` + +Use this command to add a server. This is required in order to use any feature of the bot. + +| Parameter | Description | Required | Default value | +|-----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|---------------| +| `server-hostname` | The hostname of the server to add. | `true` | `null` | +| `server-query-port` | The query port of the server to add. | `true` | `null` | +| `server-api-hostname` | The hostname to use when querying the server's api. Only required if you're planing to use features of the [v-rising-discord-bot-companion](bot-companion.md). | `false` | `null` | +| `server-api-port` | The api port of the server. Only required if you're planing to use features of the [v-rising-discord-bot-companion](bot-companion.md). | `false` | `null` | +| `server-api-username` | The username used to authenticate to the api of the server. Only required if you're planing to use features of the [v-rising-discord-bot-companion](bot-companion.md). | `false` | `null` | +| `server-api-password` | The password used to authenticate to the api of the server. Only required if you're planing to use features of the [v-rising-discord-bot-companion](bot-companion.md). | `false` | `null` | + +### `/update-server` + +Use this command to update the given server. +Only parameters specified are updated, all other parameters remain unchanged. +Admins can use this command in DMs, see [Configuration Properties](configuration-properties.md) for details. + +| Parameter | Description | Required | Default value | +|-----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|---------------| +| `server-id` | The id of the server to update. | `true` | `null` | +| `server-hostname` | The hostname of the server to add. | `true` | `null` | +| `server-query-port` | The query port of the server to add. | `true` | `null` | +| `server-api-hostname` | The hostname to use when querying the server's api. Only required if you're planing to use features of the [v-rising-discord-bot-companion](bot-companion.md). | `false` | `null` | +| `server-api-port` | The api port of the server. Only required if you're planing to use features of the [v-rising-discord-bot-companion](bot-companion.md). | `false` | `null` | +| `server-api-username` | The username used to authenticate to the api of the server. Only required if you're planing to use features of the [v-rising-discord-bot-companion](bot-companion.md). | `false` | `null` | +| `server-api-password` | The password used to authenticate to the api of the server. Only required if you're planing to use features of the [v-rising-discord-bot-companion](bot-companion.md). | `false` | `null` | + +### `/remove-server` + +Use this command to remove the given server. This command can not be undone. +Admins can use this command in DMs, see [Configuration Properties](configuration-properties.md) for details. + +| Parameter | Description | Required | Default value | +|-------------|---------------------------------|----------|---------------| +| `server-id` | The id of the server to remove. | `true` | `null` | + +### `/get-server-details` + +Use this command to get all configuration details for the given server. +Admins can use this command in DMs, see [Configuration Properties](configuration-properties.md) for details. + +| Parameter | Description | Required | Default value | +|-------------|------------------------------------------|----------|---------------| +| `server-id` | The id of the server to get details for. | `true` | `null` | -Adds a server to the status monitor. +## Status Embed -| 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. Only required if you're planing to use the [v-rising-discord-bot-companion integration](bot-companion.md). | `false` | `null` | -| `server-api-port` | The api port of the server. Only required if you're planing to use the [v-rising-discord-bot-companion integration](bot-companion.md). | `false` | `null` | -| `server-api-username` | The username used to authenticate to the api of the server. Only required if you're planing to use the [v-rising-discord-bot-companion integration](bot-companion.md). | `false` | `null` | -| `server-api-password` | The password used to authenticate to the api of the server. Only required if you're planing to use the [v-rising-discord-bot-companion integration](bot-companion.md). | `false` | `null` | -| `embed-enabled` | Whether or not a discord status embed should be posted. | `false` | `true` | -| `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 has an effect if `server-api-hostname` and `server-api-port` are set. | `false` | `true` | -| `player-activity-feed-channel-id` | The id of the channel to post the player activity feed in. Only has an effect if `server-api-hostname` and `server-api-port` are set. | `false` | `null` | -| `pvp-kill-feed-channel-id` | The id of the channel to post the pvp kill feed in. Only has an effect if `server-api-hostname` and `server-api-port` are set. Requires at least version `0.4.0` of the [v-rising-discord-bot-companion](bot-companion.md). | `false` | `null` | +## `/configure-status-monitor` -## `/update-server` +Use this command to configure the status embed, aka. status monitor, for a given server. +Admins can use this command in DMs, see [Configuration Properties](configuration-properties.md) for details. + +| Parameter | Description | Required | Default value | +|------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|----------|---------------| +| `server-id` | The id of the server to configure the status embed for. | `true` | `null` | +| `channel-id` | The id of the channel to post the status embed in. | `true` | `null` | +| `status` | Determines if the status embed should be updated or not. Either `ACTIVE` or `INACTIVE`. | `false` | `ACTIVE` | +| `display-server-description` | Whether to display the v rising server description in the status embed. | `false` | `true` | +| `display-player-gear-level` | Whether to display each player's gear level. Only has an effect if the server has a `server-api-hostname` and `server-api-port` configured. | `false` | `true` | + +## `/get-status-monitor-details` + +Use this command to get all status monitor configuration details for the given server. +Admins can use this command in DMs, see [Configuration Properties](configuration-properties.md) for details. + +| Parameter | Description | Required | Default value | +|-------------|---------------------------------------------------------------------------|----------|---------------| +| `server-id` | The id of the server to get all status monitor configuration details for. | `true` | `null` | + +## Player Activity Feed + +### `/configure-player-activity-feed` -Updates the given server status monitor. Only parameters specified are updated, all other parameters remain unchanged. +Use this command to configure the player activity feed for a given server. Admins can use this command in DMs, see [Configuration Properties](configuration-properties.md) for details. -| Parameter | Description | Required | Default value | -|-----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|---------------| -| `server-status-monitor-id` | The id of the server status monitor to update. | `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. Only required if you're planing to use the [v-rising-discord-bot-companion integration](bot-companion.md). | `false` | `null` | -| `server-api-port` | The api port of the server. Only required if you're planing to use the [v-rising-discord-bot-companion integration](bot-companion.md). | `false` | `null` | -| `server-api-username` | The username used to authenticate to the api of the server. Only required if you're planing to use the [v-rising-discord-bot-companion integration](bot-companion.md). | `false` | `null` | -| `server-api-password` | The password used to authenticate to the api of the server. Only required if you're planing to use the [v-rising-discord-bot-companion integration](bot-companion.md). | `false` | `null` | -| `status` | Determines if a server status monitor should be updated or not. Either `ACTIVE` or `INACTIVE`. | `false` | `null` | -| `embed-enabled` | Whether or not a discord status embed should be posted. Set this to false if you only want to use the activity or kill feed feature of the bot. | `false` | `true` | -| `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 has an effect if `server-api-hostname` and `server-api-port` are set. | `false` | `true` | -| `player-activity-feed-channel-id` | The id of the channel to post the player activity feed in. Only has an effect if `server-api-hostname` and `server-api-port` are set. | `false` | `null` | -| `pvp-kill-feed-channel-id` | The id of the channel to post the pvp kill feed in. Only has an effect if `server-api-hostname` and `server-api-port` are set. Requires at least version `0.4.0` of the [v-rising-discord-bot-companion](bot-companion.md). | `false` | `null` | - -## `/remove-server` - -Removes a server from the status monitor. +| Parameter | Description | Required | Default value | +|--------------|-------------------------------------------------------------------------------------------------|----------|---------------| +| `server-id` | The id of the server to configure the player activity feed for. | `true` | `null` | +| `channel-id` | The id of the channel to post the player activity feed in. | `true` | `null` | +| `status` | Determines if the player activity feed should be updated or not. Either `ACTIVE` or `INACTIVE`. | `false` | `ACTIVE` | + +## `/get-player-activity-feed-details` + +Use this command to get all player activity feed configuration details for the given server. +Admins can use this command in DMs, see [Configuration Properties](configuration-properties.md) for details. + +| Parameter | Description | Required | Default value | +|-------------|---------------------------------------------------------------------------------|----------|---------------| +| `server-id` | The id of the server to get all player activity feed configuration details for. | `true` | `null` | + +## Pvp Kill Feed + +## `/configure-pvp-kill-feed` + +Use this command to configure the pvp kill feed for a given server. Admins can use this command in DMs, see [Configuration Properties](configuration-properties.md) for details. -| Parameter | Description | Required | Default value | -|----------------------------|------------------------------------------------|----------|---------------| -| `server-status-monitor-id` | The id of the server status monitor to remove. | `true` | `null` | +| Parameter | Description | Required | Default value | +|--------------|------------------------------------------------------------------------------------------|----------|---------------| +| `server-id` | The id of the server to configure the pvp kill feed for. | `true` | `null` | +| `channel-id` | The id of the channel to post the pvp kill feed in. | `true` | `null` | +| `status` | Determines if the pvp kill feed should be updated or not. Either `ACTIVE` or `INACTIVE`. | `false` | `ACTIVE` | -## `/get-server-details` +## `/get-pvp-kill-feed-details` -Gets all the configuration details for the specified server. +Use this command to get all pvp kill feed configuration details for the given server. Admins can use this command in DMs, see [Configuration Properties](configuration-properties.md) for details. -| Parameter | Description | Required | Default value | -|----------------------------|---------------------------------------------------------|----------|---------------| -| `server-status-monitor-id` | The id of the server status monitor to get details for. | `true` | `null` | +| Parameter | Description | Required | Default value | +|-------------|--------------------------------------------------------------------------|----------|---------------| +| `server-id` | The id of the server to get all pvp kill feed configuration details for. | `true` | `null` | diff --git a/docs/configuration-properties.md b/docs/configuration-properties.md index 6b61cef..b78bf1b 100644 --- a/docs/configuration-properties.md +++ b/docs/configuration-properties.md @@ -5,17 +5,158 @@ nav_order: 6 # 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 5 seconds. | `1m` | -| `bot.max-failed-attempts` | Int | After how many attempts the bot will automatically set the status for the server monitor to `INACTIVE`. Embeds, activity and pvp kill feeds are no longer updated for `INACTIVE` status monitors. Use `0` if you don't want to use this feature. | `0` | -| `bot.max-failed-api-attempts` | Int | After how many attempts the bot will automatically disable the failing bot companion feature. Only bot companion related features are affected by this change. 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` | -| `bot.allow-local-address-ranges` | Boolean | Whether or not addresses from reserved ip ranges are permitted when adding or updating status monitors. | `true` | -| `bot.admin-user-ids` | Set | A list of admin user ids. Admins are allowed to DM the bot directly to issue commands. Commands are no longer guild specific in this context. For example, if an admin uses the `/list-servers` command in a DM, the bot responds with a list of all server status monitors and not only the ones for a specific discord guild. | `emptySet()` | -| `bot.cleanup-job-enabled` | Boolean | Whether or not the bot should automatically delete `INACTIVE` server monitors once a day. The cleanup job will run at midnight UTC. | `false` | +All properties listed in the following section can be set via environment variables, as command-line arguments or in properties files. +For example, the `bot.discord-bot-token` property can be set using: + +* Environment: `BOT_DISCORD_BOT_TOKEN=example` +* Command-line argument: `--bot.discord-bot-token=example` +* `application.yml`: + +### `bot.discord-bot-token` + +The token that the bot uses to authenticate to discord. +You can find this in the [discord developer portal](https://discord.com/developers/applications). + +| Type | Default value | +|--------|---------------| +| String | `null` | + +### `bot.database-path` + +The path to the database file. The bot will attempt to create this file if it does not exist. +All data stored in the database is encrypted using the `bot.database-username` and `bot.database-password` properties. +Should be overwritten when running inside a docker container. + +| Type | Default value | +|------|---------------| +| Path | `./bot.db` | + +### `bot.database-username` + +The username for the database. + +| Type | Default value | +|--------|------------------------| +| String | `v-rising-discord-bot` | + +### `bot.database-password` + +The password for the database. It is recommended to use a strong password. + +| Type | Default value | +|--------|---------------| +| String | `null` | + +### `bot.update-delay` + +The delay between update attempts for the status embed, player activity feed, pvp kill feed and leaderboards. +Cannot be less than 5 seconds. + +| Type | Default value | +|----------|---------------| +| Duration | `1m` | + +### `bot.max-failed-attempts` + +Defines after how many attempts the bot will automatically set the status for the status embed to `INACTIVE`. +The status embed is no longer updated if it is in status `INACTIVE`. +Use `0` to disable this functionality. + +| Type | Default value | +|------|---------------| +| Int | `0` | + +### `bot.max-failed-api-attempts` + +Defines after how many attempts the bot will automatically set the status for the failing bot companion feature to `INACTIVE`. +The player activity feed, pvp kill feed and leaderboards are no longer updated if they are in status `INACTIVE`. +Use `0` to disable this functionality. + +| Type | Default value | +|------|---------------| +| Int | `0` | + +### `bot.max-recent-errors` + +The maximum number of error messages to keep for debugging issues. +This limit applies to the following commands: + +* `/get-player-activity-feed-details` +* `/get-pvp-kill-feed-details` +* `/get-status-monitor-details` + +Use `0` if you don't want to persist any error messages for debugging purposes. + +| Type | Default value | +|------|---------------| +| Int | `5` | + +### `bot.max-characters-per-error` + +The character limit for each error message that is persisted for debugging purposes. +This limit applies to the following commands: + +* `/get-player-activity-feed-details` +* `/get-pvp-kill-feed-details` +* `/get-status-monitor-details` + +| Type | Default value | +|------|---------------| +| Int | `200` | + +### `bot.allow-local-address-ranges` + +Whether addresses from reserved ip ranges are permitted when adding or updating servers. +It's recommended to set this to `false` if you attempt to provide this bot as a service for others. + +| Type | Default value | +|---------|---------------| +| Boolean | `true` | + +### `bot.admin-user-ids` + +A list of admin user ids. +Admins are allowed to send direct messages to the bot to issue commands. +Commands are no longer guild specific in this context. + +For example, if an admin uses the `/list-servers` command in a DM, the bot responds +with a list of all server status monitors and not only the ones for a specific discord guild. + +| Type | Default value | +|-------------|---------------| +| Set | `[]` | + +### `bot.cleanup-job-enabled` + +Whether the bot should automatically delete `INACTIVE` server monitors once a day. +The cleanup job will run at midnight UTC and deletes all server that have been in status `INACTIVE` for more than 7 days. + +| Type | Default value | +|---------|---------------| +| Boolean | `false` | + +### `bot.database-backup-job-enabled` + +Whether the bot should automatically create a database backup once a day. +The backup job will run at `23:45` UTC. + +| Type | Default value | +|---------|---------------| +| Boolean | `false` | + +### `bot.database-backup-directory` + +Which directory to store database backups in. +Should be overwritten when running inside a docker container. + +| Type | Default value | +|------|-----------------------| +| Path | `./database-backups/` | + +### `bot.database-backup-max-files` + +The maximum amount of recent backups to keep. + +| Type | Default value | +|------|---------------| +| Int | `10` | diff --git a/docs/index.md b/docs/index.md index 0cdb35d..a0a648f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,13 +8,13 @@ nav_order: 1 Welcome to the documentation for the [v-rising-discord-bot](https://github.com/DarkAtra/v-rising-discord-bot). The bot allows you to display some information about your v rising server on discord. -This is what an embed looks like: +This is what the status embed looks like: Preview The bot consists of two parts, the `v-rising-discord-bot` (`bot` from here on) and the [v-rising-discord-bot-companion](https://github.com/DarkAtra/v-rising-discord-bot-companion) (`bot-companion` from here on), a server-side mod, which is -required if you want to have any of the following features: +required if you want to use any of the following features: * displaying the current gear level of all players in the status embed * posting a message in a discord channel when a player joins or leaves the V Rising server @@ -26,11 +26,12 @@ Here's an example of what that would look like: ## Using the bot -Now that you know the features of the bot, let's see how you can use it. The simplest is to use the instance of the bot that I host for you. +Now that you know the features of the bot, let's see how you can use it. +The simplest is to use the instance of the bot that I host for you. Refer to [this step-by-step guide](bot-as-a-service.md#setup-guide) if you want to get started. -If you are experienced in running and maintaining software, there's also the option of self-hosting the bot. This allows you to tweak -all [configuration-properties](configuration-properties.md) to your liking. Refer to [this page](self-hosting.md) for details. +If you are experienced in running and maintaining software, there's also the option of self-hosting the bot. +This allows you to tweak all [configuration-properties](configuration-properties.md) to your liking. Refer to [this page](self-hosting.md) for details. ## Support diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 8f5a99d..0e1b558 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -8,8 +8,8 @@ nav_order: 3 {: .warning } > I only recommend self-hosting the bot if you are experienced in running and maintaining software. -You have two options for running the bot, using docker and running on bare metal. I generally recommend running it as a docker container as it reduces the -maintenance effort and simplifies the configuration on your end. +You have two options for running the bot, the first uses docker and the second is running it on bare metal. +I generally recommend running it as a docker container as it reduces the maintenance effort and simplifies the configuration on your end. ## Hosting the bot using docker-compose @@ -25,7 +25,7 @@ You can also build it from scratch by cloning the repository and then running `m ```yaml services: v-rising-discord-bot: - image: ghcr.io/darkatra/v-rising-discord-bot:2.8.0-native + image: ghcr.io/darkatra/v-rising-discord-bot:2.11.0-native # find the latest version here: https://github.com/DarkAtra/v-rising-discord-bot/releases command: -Dagql.nativeTransport=false mem_reservation: 128M mem_limit: 256M @@ -42,8 +42,8 @@ services: {: .note } > The container uses user `1000:1000`. Make sure that this user has read and write permissions on the volume, in this -> case `/opt/v-rising-discord-bot`. Also, if you're on windows, please replace `/opt/v-rising-discord-bot` in the example above with any valid window path, -> e.g. `/C/Users//Desktop/v-rising-discord-bot`. +> case `/opt/v-rising-discord-bot`. Also, if you're on windows, please replace `/opt/v-rising-discord-bot` in the +> example above with any valid window path, e.g. `/C/Users//Desktop/v-rising-discord-bot`. ## Hosting the bot without docker @@ -57,9 +57,10 @@ services: ``` 4. Run the application using `java -jar v-rising-discord-bot-.jar` -If you run the application in a Linux environment, make sure that you use a separate user. -This user only needs read and write permissions for the `bot.db` database file and read permissions for the `application.yml`, both of which are located in the -applications working directory by default. +If you run the application in a Linux environment, make sure to use a separate user. +This user only needs read and write permissions for the `bot.db` database file and read +permissions for the `application.yml`, both of which are located in the applications +working directory by default. You can change the location of the database file by modifying the `application.yml` slightly: diff --git a/mvnw b/mvnw index 8d937f4..19529dd 100755 --- a/mvnw +++ b/mvnw @@ -19,290 +19,241 @@ # ---------------------------------------------------------------------------- # ---------------------------------------------------------------------------- -# Apache Maven Wrapper startup batch script, version 3.2.0 -# -# Required ENV vars: -# ------------------ -# JAVA_HOME - location of a JDK home dir +# Apache Maven Wrapper startup batch script, version 3.3.2 # # Optional ENV vars # ----------------- -# MAVEN_OPTS - parameters passed to the Java VM when running Maven -# e.g. to debug Maven itself, use -# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 -# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output # ---------------------------------------------------------------------------- -if [ -z "$MAVEN_SKIP_RC" ] ; then - - if [ -f /usr/local/etc/mavenrc ] ; then - . /usr/local/etc/mavenrc - fi - - if [ -f /etc/mavenrc ] ; then - . /etc/mavenrc - fi - - if [ -f "$HOME/.mavenrc" ] ; then - . "$HOME/.mavenrc" - fi - -fi +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x -# OS specific support. $var _must_ be set to either true or false. -cygwin=false; -darwin=false; -mingw=false +# OS specific support. +native_path() { printf %s\\n "$1"; } case "$(uname)" in - CYGWIN*) cygwin=true ;; - MINGW*) mingw=true;; - Darwin*) darwin=true - # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home - # See https://developer.apple.com/library/mac/qa/qa1170/_index.html - if [ -z "$JAVA_HOME" ]; then - if [ -x "/usr/libexec/java_home" ]; then - JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME - else - JAVA_HOME="/Library/Java/Home"; export JAVA_HOME - fi - fi - ;; +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; esac -if [ -z "$JAVA_HOME" ] ; then - if [ -r /etc/gentoo-release ] ; then - JAVA_HOME=$(java-config --jre-home) - fi -fi - -# For Cygwin, ensure paths are in UNIX format before anything is touched -if $cygwin ; then - [ -n "$JAVA_HOME" ] && - JAVA_HOME=$(cygpath --unix "$JAVA_HOME") - [ -n "$CLASSPATH" ] && - CLASSPATH=$(cygpath --path --unix "$CLASSPATH") -fi - -# For Mingw, ensure paths are in UNIX format before anything is touched -if $mingw ; then - [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && - JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" -fi - -if [ -z "$JAVA_HOME" ]; then - javaExecutable="$(which javac)" - if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then - # readlink(1) is not available as standard on Solaris 10. - readLink=$(which readlink) - if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then - if $darwin ; then - javaHome="$(dirname "\"$javaExecutable\"")" - javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" - else - javaExecutable="$(readlink -f "\"$javaExecutable\"")" - fi - javaHome="$(dirname "\"$javaExecutable\"")" - javaHome=$(expr "$javaHome" : '\(.*\)/bin') - JAVA_HOME="$javaHome" - export JAVA_HOME - fi - fi -fi - -if [ -z "$JAVACMD" ] ; then - if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" else JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi fi else - JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" - fi -fi - -if [ ! -x "$JAVACMD" ] ; then - echo "Error: JAVA_HOME is not defined correctly." >&2 - echo " We cannot execute $JAVACMD" >&2 - exit 1 -fi + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : -if [ -z "$JAVA_HOME" ] ; then - echo "Warning: JAVA_HOME environment variable is not set." -fi - -# traverses directory structure from process work directory to filesystem root -# first directory with .mvn subdirectory is considered project base directory -find_maven_basedir() { - if [ -z "$1" ] - then - echo "Path not specified to find_maven_basedir" - return 1 + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi fi +} - basedir="$1" - wdir="$1" - while [ "$wdir" != '/' ] ; do - if [ -d "$wdir"/.mvn ] ; then - basedir=$wdir - break - fi - # workaround for JBEAP-8937 (on Solaris 10/Sparc) - if [ -d "${wdir}" ]; then - wdir=$(cd "$wdir/.." || exit 1; pwd) - fi - # end of workaround +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" done - printf '%s' "$(cd "$basedir" || exit 1; pwd)" + printf %x\\n $h } -# concatenates all lines of a file -concat_lines() { - if [ -f "$1" ]; then - # Remove \r in case we run on Windows within Git Bash - # and check out the repository with auto CRLF management - # enabled. Otherwise, we may read lines that are delimited with - # \r\n and produce $'-Xarg\r' rather than -Xarg due to word - # splitting rules. - tr -s '\r\n' ' ' < "$1" - fi +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 } -log() { - if [ "$MVNW_VERBOSE" = true ]; then - printf '%s\n' "$1" - fi +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" } -BASE_DIR=$(find_maven_basedir "$(dirname "$0")") -if [ -z "$BASE_DIR" ]; then - exit 1; +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" fi -MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR -log "$MAVEN_PROJECTBASEDIR" +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac -########################################################################################## -# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central -# This allows using the maven wrapper in projects that prohibit checking in binary data. -########################################################################################## -wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" -if [ -r "$wrapperJarPath" ]; then - log "Found $wrapperJarPath" +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT else - log "Couldn't find $wrapperJarPath, downloading it ..." + die "cannot create temp dir" +fi - if [ -n "$MVNW_REPOURL" ]; then - wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" - else - wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" - fi - while IFS="=" read -r key value; do - # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) - safeValue=$(echo "$value" | tr -d '\r') - case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; - esac - done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" - log "Downloading from: $wrapperUrl" +mkdir -p -- "${MAVEN_HOME%/*}" - if $cygwin; then - wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") - fi +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" - if command -v wget > /dev/null; then - log "Found wget ... using wget" - [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" - if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" - else - wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" - fi - elif command -v curl > /dev/null; then - log "Found curl ... using curl" - [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" - if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" - else - curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" - fi - else - log "Falling back to using Java to download" - javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" - javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" - # For Cygwin, switch paths to Windows format before running javac - if $cygwin; then - javaSource=$(cygpath --path --windows "$javaSource") - javaClass=$(cygpath --path --windows "$javaClass") - fi - if [ -e "$javaSource" ]; then - if [ ! -e "$javaClass" ]; then - log " - Compiling MavenWrapperDownloader.java ..." - ("$JAVA_HOME/bin/javac" "$javaSource") - fi - if [ -e "$javaClass" ]; then - log " - Running MavenWrapperDownloader.java ..." - ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" - fi - fi - fi +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" fi -########################################################################################## -# End of extension -########################################################################################## -# If specified, validate the SHA-256 sum of the Maven wrapper jar file -wrapperSha256Sum="" -while IFS="=" read -r key value; do - case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; - esac -done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" -if [ -n "$wrapperSha256Sum" ]; then - wrapperSha256Result=false - if command -v sha256sum > /dev/null; then - if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then - wrapperSha256Result=true +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true fi - elif command -v shasum > /dev/null; then - if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then - wrapperSha256Result=true + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true fi else - echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." - echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 exit 1 fi - if [ $wrapperSha256Result = false ]; then - echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 - echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 - echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 exit 1 fi fi -MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" - -# For Cygwin, switch paths to Windows format before running java -if $cygwin; then - [ -n "$JAVA_HOME" ] && - JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") - [ -n "$CLASSPATH" ] && - CLASSPATH=$(cygpath --path --windows "$CLASSPATH") - [ -n "$MAVEN_PROJECTBASEDIR" ] && - MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" -# Provide a "standardized" way to retrieve the CLI args that will -# work with both Windows and non-Windows executions. -MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" -export MAVEN_CMD_LINE_ARGS - -WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain - -# shellcheck disable=SC2086 # safe args -exec "$JAVACMD" \ - $MAVEN_OPTS \ - $MAVEN_DEBUG_OPTS \ - -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ - "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ - ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd index c4586b5..249bdf3 100644 --- a/mvnw.cmd +++ b/mvnw.cmd @@ -1,3 +1,4 @@ +<# : batch portion @REM ---------------------------------------------------------------------------- @REM Licensed to the Apache Software Foundation (ASF) under one @REM or more contributor license agreements. See the NOTICE file @@ -18,188 +19,131 @@ @REM ---------------------------------------------------------------------------- @REM ---------------------------------------------------------------------------- -@REM Apache Maven Wrapper startup batch script, version 3.2.0 -@REM -@REM Required ENV vars: -@REM JAVA_HOME - location of a JDK home dir +@REM Apache Maven Wrapper startup batch script, version 3.3.2 @REM @REM Optional ENV vars -@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands -@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending -@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven -@REM e.g. to debug Maven itself, use -@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 -@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output @REM ---------------------------------------------------------------------------- -@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' -@echo off -@REM set title of command window -title %0 -@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' -@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% - -@REM set %HOME% to equivalent of $HOME -if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") - -@REM Execute a user defined script before this one -if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre -@REM check for pre script, once with legacy .bat ending and once with .cmd ending -if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* -if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* -:skipRcPre - -@setlocal - -set ERROR_CODE=0 - -@REM To isolate internal variables from possible post scripts, we use another setlocal -@setlocal - -@REM ==== START VALIDATION ==== -if not "%JAVA_HOME%" == "" goto OkJHome - -echo. -echo Error: JAVA_HOME not found in your environment. >&2 -echo Please set the JAVA_HOME variable in your environment to match the >&2 -echo location of your Java installation. >&2 -echo. -goto error - -:OkJHome -if exist "%JAVA_HOME%\bin\java.exe" goto init - -echo. -echo Error: JAVA_HOME is set to an invalid directory. >&2 -echo JAVA_HOME = "%JAVA_HOME%" >&2 -echo Please set the JAVA_HOME variable in your environment to match the >&2 -echo location of your Java installation. >&2 -echo. -goto error - -@REM ==== END VALIDATION ==== - -:init - -@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". -@REM Fallback to current working directory if not found. - -set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% -IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir - -set EXEC_DIR=%CD% -set WDIR=%EXEC_DIR% -:findBaseDir -IF EXIST "%WDIR%"\.mvn goto baseDirFound -cd .. -IF "%WDIR%"=="%CD%" goto baseDirNotFound -set WDIR=%CD% -goto findBaseDir - -:baseDirFound -set MAVEN_PROJECTBASEDIR=%WDIR% -cd "%EXEC_DIR%" -goto endDetectBaseDir - -:baseDirNotFound -set MAVEN_PROJECTBASEDIR=%EXEC_DIR% -cd "%EXEC_DIR%" - -:endDetectBaseDir - -IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig - -@setlocal EnableExtensions EnableDelayedExpansion -for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a -@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% - -:endReadAdditionalConfig - -SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" -set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" -set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain - -set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" - -FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( - IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B -) - -@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central -@REM This allows using the maven wrapper in projects that prohibit checking in binary data. -if exist %WRAPPER_JAR% ( - if "%MVNW_VERBOSE%" == "true" ( - echo Found %WRAPPER_JAR% - ) -) else ( - if not "%MVNW_REPOURL%" == "" ( - SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" - ) - if "%MVNW_VERBOSE%" == "true" ( - echo Couldn't find %WRAPPER_JAR%, downloading it ... - echo Downloading from: %WRAPPER_URL% - ) - - powershell -Command "&{"^ - "$webclient = new-object System.Net.WebClient;"^ - "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ - "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ - "}"^ - "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ - "}" - if "%MVNW_VERBOSE%" == "true" ( - echo Finished downloading %WRAPPER_JAR% - ) -) -@REM End of extension - -@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file -SET WRAPPER_SHA_256_SUM="" -FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( - IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) ) -IF NOT %WRAPPER_SHA_256_SUM%=="" ( - powershell -Command "&{"^ - "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ - "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ - " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ - " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ - " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ - " exit 1;"^ - "}"^ - "}" - if ERRORLEVEL 1 goto error -) - -@REM Provide a "standardized" way to retrieve the CLI args that will -@REM work with both Windows and non-Windows executions. -set MAVEN_CMD_LINE_ARGS=%* - -%MAVEN_JAVA_EXE% ^ - %JVM_CONFIG_MAVEN_PROPS% ^ - %MAVEN_OPTS% ^ - %MAVEN_DEBUG_OPTS% ^ - -classpath %WRAPPER_JAR% ^ - "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ - %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* -if ERRORLEVEL 1 goto error -goto end - -:error -set ERROR_CODE=1 - -:end -@endlocal & set ERROR_CODE=%ERROR_CODE% - -if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost -@REM check for post script, once with legacy .bat ending and once with .cmd ending -if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" -if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" -:skipRcPost - -@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' -if "%MAVEN_BATCH_PAUSE%"=="on" pause - -if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% - -cmd /C exit /B %ERROR_CODE% +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml index 3474638..b2ecb30 100644 --- a/pom.xml +++ b/pom.xml @@ -6,13 +6,13 @@ org.springframework.boot spring-boot-starter-parent - 3.1.3 + 3.3.2 de.darkatra v-rising-discord-bot - 2.10.6 + 2.11.0-next.7 jar @@ -32,22 +32,33 @@ ghcr.io/darkatra/${project.artifactId}:${project.version} 17 - 1.8.22 + 1.9.24 official 0.14.0 - 1.2.1 + + 2.3.11 + 1.2.2 5.0.0 - 3.4.4 - 1.7 + 4.3.1-SNAPSHOT + 1.8.0 - - 1.7.2 - - 5.0.0 - 3.4.2 + 5.3.1 + 3.5.4 + + + + io.ktor + ktor-bom + ${ktor.version} + pom + import + + + + org.jetbrains.kotlin @@ -85,6 +96,12 @@ jackson-module-kotlin + + + io.ktor + ktor-client-okhttp-jvm + + dev.kord @@ -112,6 +129,11 @@ potassium-nitrite ${potassium-nitrite.version} + + org.dizitart + nitrite-mvstore-adapter + ${potassium-nitrite.version} + @@ -192,18 +214,14 @@ ${image.name} - - paketobuildpacks/builder:0.1.384-tiny + paketobuildpacks/builder-jammy-tiny:0.0.262 ${project.scm.url} + 21 - - org.graalvm.buildtools - native-maven-plugin - maven-resources-plugin @@ -246,6 +264,47 @@ ghcr.io/darkatra/${project.artifactId}:${project.version}-native + + + + org.graalvm.buildtools + native-maven-plugin + + + -march=compatibility + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + -march=compatibility + + + + + + + + + nativeTest + + + + org.graalvm.buildtools + native-maven-plugin + + + + --strict-image-heap + + + + + diff --git a/src/main/kotlin/de/darkatra/vrising/discord/Bot.kt b/src/main/kotlin/de/darkatra/vrising/discord/Bot.kt index 7ea5846..5b435d4 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/Bot.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/Bot.kt @@ -1,15 +1,9 @@ package de.darkatra.vrising.discord -import de.darkatra.vrising.discord.clients.botcompanion.model.Character -import de.darkatra.vrising.discord.clients.botcompanion.model.PlayerActivity -import de.darkatra.vrising.discord.clients.botcompanion.model.PvpKill -import de.darkatra.vrising.discord.clients.botcompanion.model.VBlood import de.darkatra.vrising.discord.commands.Command import de.darkatra.vrising.discord.migration.DatabaseMigrationService -import de.darkatra.vrising.discord.migration.Schema -import de.darkatra.vrising.discord.persistence.model.Error -import de.darkatra.vrising.discord.persistence.model.ServerStatusMonitor -import de.darkatra.vrising.discord.serverstatus.ServerStatusMonitorService +import de.darkatra.vrising.discord.persistence.DatabaseBackupService +import de.darkatra.vrising.discord.serverstatus.ServerService import dev.kord.core.Kord import dev.kord.core.behavior.interaction.response.respond import dev.kord.core.event.gateway.ReadyEvent @@ -18,9 +12,7 @@ import dev.kord.core.on import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.isActive import kotlinx.coroutines.runBlocking -import org.dizitart.no2.Nitrite import org.slf4j.LoggerFactory -import org.springframework.aot.hint.annotation.RegisterReflectionForBinding import org.springframework.beans.factory.DisposableBean import org.springframework.boot.ApplicationArguments import org.springframework.boot.ApplicationRunner @@ -42,24 +34,12 @@ import java.util.concurrent.atomic.AtomicBoolean @SpringBootApplication @ImportRuntimeHints(BotRuntimeHints::class) @EnableConfigurationProperties(BotProperties::class) -@RegisterReflectionForBinding( - BotProperties::class, - Schema::class, - ServerStatusMonitor::class, - Error::class, - Character::class, - VBlood::class, - PlayerActivity::class, - PlayerActivity.Type::class, - PvpKill::class, - PvpKill.Player::class -) class Bot( - private val database: Nitrite, private val botProperties: BotProperties, private val commands: List, private val databaseMigrationService: DatabaseMigrationService, - private val serverStatusMonitorService: ServerStatusMonitorService + private val serverService: ServerService, + private val databaseBackupService: DatabaseBackupService ) : ApplicationRunner, DisposableBean, SchedulingConfigurer { private val logger = LoggerFactory.getLogger(javaClass) @@ -80,8 +60,7 @@ class Bot( val command = commands.find { command -> command.isSupported(interaction, botProperties.adminUserIds) } if (command == null) { interaction.deferEphemeralResponse().respond { - content = """This command is not supported here, please refer to the documentation. - |Be sure to use the commands in the channel where you want the status message to appear.""".trimMargin() + content = "This command is not supported here, please refer to the documentation." } return@on } @@ -117,7 +96,6 @@ class Bot( runBlocking { kord.shutdown() } - database.close() } override fun configureTasks(taskRegistrar: ScheduledTaskRegistrar) { @@ -127,7 +105,7 @@ class Bot( { if (isReady.get() && kord.isActive) { runBlocking { - serverStatusMonitorService.updateServerStatusMonitors(kord) + serverService.updateServers(kord) } } }, @@ -142,7 +120,7 @@ class Bot( { if (isReady.get() && kord.isActive) { runBlocking { - serverStatusMonitorService.cleanupInactiveServerStatusMonitors(kord) + serverService.cleanupInactiveServers(kord) } } }, @@ -150,6 +128,19 @@ class Bot( ) ) } + + if (botProperties.databaseBackupJobEnabled) { + taskRegistrar.addCronTask( + CronTask( + { + if (isReady.get()) { + databaseBackupService.performDatabaseBackup() + } + }, + CronTrigger("0 45 23 * * *", ZoneOffset.UTC) + ) + ) + } } } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/BotProperties.kt b/src/main/kotlin/de/darkatra/vrising/discord/BotProperties.kt index dd5703d..17a7321 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/BotProperties.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/BotProperties.kt @@ -53,4 +53,14 @@ class BotProperties { @field:NotNull var cleanupJobEnabled: Boolean = false + + @field:NotNull + var databaseBackupJobEnabled: Boolean = false + + @field:NotNull + var databaseBackupDirectory: Path = Path.of("./database-backups/") + + @field:Min(1) + @field:NotNull + var databaseBackupMaxFiles: Int = 10 } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/BotRuntimeHints.kt b/src/main/kotlin/de/darkatra/vrising/discord/BotRuntimeHints.kt index d775400..9bd31f2 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/BotRuntimeHints.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/BotRuntimeHints.kt @@ -1,5 +1,18 @@ package de.darkatra.vrising.discord +import de.darkatra.vrising.discord.clients.botcompanion.model.Character +import de.darkatra.vrising.discord.clients.botcompanion.model.PlayerActivity +import de.darkatra.vrising.discord.clients.botcompanion.model.PvpKill +import de.darkatra.vrising.discord.clients.botcompanion.model.VBlood +import de.darkatra.vrising.discord.persistence.model.Error +import de.darkatra.vrising.discord.persistence.model.Leaderboard +import de.darkatra.vrising.discord.persistence.model.PlayerActivityFeed +import de.darkatra.vrising.discord.persistence.model.PvpKillFeed +import de.darkatra.vrising.discord.persistence.model.Server +import de.darkatra.vrising.discord.persistence.model.Status +import de.darkatra.vrising.discord.persistence.model.StatusMonitor +import de.darkatra.vrising.discord.persistence.model.Version +import dev.kord.common.entity.optional.Optional import dev.kord.core.cache.data.ApplicationCommandData import dev.kord.core.cache.data.AutoModerationRuleData import dev.kord.core.cache.data.ChannelData @@ -17,51 +30,49 @@ import dev.kord.core.cache.data.UserData import dev.kord.core.cache.data.VoiceStateData import dev.kord.core.cache.data.WebhookData import io.ktor.utils.io.pool.DefaultPool -import org.dizitart.no2.Document -import org.dizitart.no2.Index -import org.dizitart.no2.NitriteId -import org.dizitart.no2.meta.Attributes -import org.h2.store.fs.FilePathDisk -import org.h2.store.fs.FilePathNio +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject import org.springframework.aot.hint.BindingReflectionHintsRegistrar import org.springframework.aot.hint.MemberCategory import org.springframework.aot.hint.RuntimeHints import org.springframework.aot.hint.RuntimeHintsRegistrar import org.springframework.aot.hint.TypeReference -import java.util.concurrent.ConcurrentSkipListMap -import java.util.concurrent.ConcurrentSkipListSet -import java.util.concurrent.atomic.AtomicBoolean -/** - * Runtime hints for dependencies. Should be removed when each dependency has official support for GraalVM Native Image. - */ class BotRuntimeHints : RuntimeHintsRegistrar { private val bindingReflectionHintsRegistrar = BindingReflectionHintsRegistrar() override fun registerHints(hints: RuntimeHints, classLoader: ClassLoader?) { - // 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")) - hints.serialization().registerType(ConcurrentSkipListSet::class.java) - hints.serialization().registerType(ConcurrentSkipListMap::class.java) - hints.serialization().registerType(Document::class.java) - hints.serialization().registerType(HashMap::class.java) - hints.serialization().registerType(Index::class.java) - hints.serialization().registerType(TypeReference.of("org.dizitart.no2.internals.IndexMetaService\$IndexMeta")) - hints.serialization().registerType(TypeReference.of("java.lang.Integer")) - hints.serialization().registerType(LinkedHashMap::class.java) - hints.serialization().registerType(TypeReference.of("java.lang.Long")) - hints.serialization().registerType(TypeReference.of("java.lang.Number")) - hints.serialization().registerType(NitriteId::class.java) - hints.serialization().registerType(TypeReference.of("java.lang.String")) + // required by the bot + bindingReflectionHintsRegistrar.registerReflectionHints( + hints.reflection(), + BotProperties::class.java, + Character::class.java, + PlayerActivity::class.java, + PlayerActivity.Type::class.java, + PvpKill::class.java, + PvpKill.Player::class.java, + VBlood::class.java, + ) + hints.reflection() + .registerType(Error::class.java, MemberCategory.DECLARED_FIELDS) + .registerType(Leaderboard::class.java, MemberCategory.DECLARED_FIELDS) + .registerType(PlayerActivityFeed::class.java, MemberCategory.DECLARED_FIELDS) + .registerType(PvpKillFeed::class.java, MemberCategory.DECLARED_FIELDS) + .registerType(Server::class.java, MemberCategory.DECLARED_FIELDS) + .registerType(Status::class.java, MemberCategory.DECLARED_FIELDS) + .registerType(StatusMonitor::class.java, MemberCategory.DECLARED_FIELDS) + .registerType(Version::class.java, MemberCategory.DECLARED_FIELDS) + hints.serialization() + .registerType(java.lang.Boolean::class.java) + .registerType(TypeReference.of("kotlin.collections.EmptyList")) + + // required by jackson + hints.reflection() + .registerType(java.lang.Enum.EnumDesc::class.java) - // reflection hints for kord (remove once https://github.com/kordlib/kord/issues/786 is merged) + // required for kord (remove once https://github.com/kordlib/kord/issues/786 is merged) bindingReflectionHintsRegistrar.registerReflectionHints( hints.reflection(), ApplicationCommandData::class.java, @@ -77,19 +88,22 @@ class BotRuntimeHints : RuntimeHintsRegistrar { ThreadMemberData::class.java, UserData::class.java, VoiceStateData::class.java, - WebhookData::class.java + WebhookData::class.java, ) - hints.reflection() - // required by nitrite to create and open file based databases - .registerType(FilePathDisk::class.java, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS) - .registerType(FilePathNio::class.java, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS) - // required by kord (remove once https://github.com/kordlib/kord/issues/786 is merged) .registerType(GuildApplicationCommandPermissionsData::class.java) .registerType(StickerPackData::class.java) - // required by kotlin coroutines (dependency of kord) - .registerType(TypeReference.of("kotlin.internal.jdk8.JDK8PlatformImplementations"), MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS) - // required by ktor (dependency of kord) + .registerType(Optional.Missing.Companion::class.java) + .registerType(Optional.Null.Companion::class.java) + + // required by ktor (dependency of kord) + hints.reflection() .registerType(DefaultPool::class.java, MemberCategory.DECLARED_FIELDS) + .registerType(StickerPackData::class.java) + + // required for kotlinx serialization (dependency of kord) + hints.reflection() + .registerType(JsonArray.Companion::class.java) + .registerType(JsonObject.Companion::class.java) } } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/DatabaseUtils.kt b/src/main/kotlin/de/darkatra/vrising/discord/DatabaseUtils.kt deleted file mode 100644 index af24d53..0000000 --- a/src/main/kotlin/de/darkatra/vrising/discord/DatabaseUtils.kt +++ /dev/null @@ -1,11 +0,0 @@ -package de.darkatra.vrising.discord - -import org.dizitart.kno2.filters.and -import org.dizitart.no2.objects.ObjectFilter - -operator fun ObjectFilter.plus(other: ObjectFilter?): ObjectFilter { - if (other == null) { - return this - } - return this.and(other) -} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/KordExtensions.kt b/src/main/kotlin/de/darkatra/vrising/discord/KordExtensions.kt index 7d4d21c..6fb1959 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/KordExtensions.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/KordExtensions.kt @@ -1,27 +1,42 @@ package de.darkatra.vrising.discord -import de.darkatra.vrising.discord.serverstatus.exceptions.InvalidDiscordChannelException import dev.kord.common.entity.Snowflake import dev.kord.core.Kord import dev.kord.core.behavior.channel.MessageChannelBehavior import dev.kord.core.entity.interaction.InteractionCommand +class UnexpectedDiscordException(message: String, cause: Throwable? = null) : BotException(message, cause) +class InvalidDiscordChannelException(message: String) : BotException(message) + suspend fun Kord.getDiscordChannel(discordChannelId: String): Result { val channel = try { getChannel(Snowflake(discordChannelId)) } catch (e: Exception) { - return Result.failure(InvalidDiscordChannelException("Exception getting the Discord Channel for '$discordChannelId'.", e)) + return Result.failure(UnexpectedDiscordException("Exception getting the Discord Channel for '$discordChannelId'.", e)) } if (channel == null || channel !is MessageChannelBehavior) { - return Result.failure(InvalidDiscordChannelException("Discord Channel '$discordChannelId' does not exist.")) + return Result.failure(InvalidDiscordChannelException("Discord Channel with id '$discordChannelId' does not exist.")) } return Result.success(channel) } -private val channelPattern = Regex("<#([0-9]+)>") +private val CHANNEL_PATTERN = Regex("^<#(\\d+)>").toPattern() fun InteractionCommand.getChannelIdFromStringParameter(parameterName: String): String? { val value = strings[parameterName] ?: return null - val match = channelPattern.find(value) ?: return value - return match.groups[1]!!.value + val matcher = CHANNEL_PATTERN.matcher(value) + if (!matcher.find()) { + return value + } + val result = matcher.toMatchResult() + return result.group(1) +} + +suspend fun MessageChannelBehavior.tryCreateMessage(message: String): Boolean { + try { + createMessage(message) + return true + } catch (e: Exception) { + return false + } } 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 index 51ee036..079cdcc 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/clients/botcompanion/BotCompanionClient.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/clients/botcompanion/BotCompanionClient.kt @@ -1,70 +1,116 @@ package de.darkatra.vrising.discord.clients.botcompanion +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonTypeRef import de.darkatra.vrising.discord.clients.botcompanion.model.Character import de.darkatra.vrising.discord.clients.botcompanion.model.PlayerActivity import de.darkatra.vrising.discord.clients.botcompanion.model.PvpKill -import org.springframework.boot.web.client.RestTemplateBuilder -import org.springframework.http.client.ClientHttpRequestInterceptor +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.accept +import io.ktor.client.request.basicAuth +import io.ktor.client.request.get +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.appendPathSegments +import io.ktor.http.headers +import io.ktor.http.userAgent +import org.springframework.beans.factory.DisposableBean +import org.springframework.context.ApplicationContext +import org.springframework.http.MediaType import org.springframework.stereotype.Service -import org.springframework.web.client.RestTemplate import java.net.InetSocketAddress -import java.net.URI +import java.net.URL import java.time.Duration @Service -class BotCompanionClient { +class BotCompanionClient( + private val applicationContext: ApplicationContext +) : DisposableBean { - fun getCharacters(serverApiHostName: String, serverApiPort: Int, interceptors: List): Result> { + private val objectMapper by lazy { + jacksonObjectMapper().registerModule(JavaTimeModule()) + } + private val httpClient by lazy { + HttpClient(OkHttp) { + install(HttpTimeout) { + connectTimeoutMillis = Duration.ofSeconds(1).toMillis() + requestTimeoutMillis = Duration.ofSeconds(5).toMillis() + socketTimeoutMillis = Duration.ofSeconds(5).toMillis() + } + } + } + + suspend fun getCharacters( + serverApiHostName: String, + serverApiPort: Int, + serverApiUsername: String? = null, + serverApiPassword: String? = null + ): Result> { - val restTemplate = getRestTemplate(serverApiHostName, serverApiPort, interceptors) + val response = performRequest(getRequestUrl(serverApiHostName, serverApiPort), "/characters", serverApiUsername, serverApiPassword) - return try { - Result.success( - restTemplate.getForObject("/characters", Array::class.java)?.toList() ?: emptyList() - ) - } catch (e: Exception) { - Result.failure(e) + return when (response.status) { + HttpStatusCode.OK -> Result.success(objectMapper.readValue(response.bodyAsText(), jacksonTypeRef>())) + else -> Result.failure(BotCompanionClientException("Unexpected response status '${response.status.value}' during ${this::getCharacters.name} request.")) } } - fun getPlayerActivities(serverApiHostName: String, serverApiPort: Int, interceptors: List): Result> { + suspend fun getPlayerActivities( + serverApiHostName: String, + serverApiPort: Int, + serverApiUsername: String? = null, + serverApiPassword: String? = null + ): Result> { - val restTemplate = getRestTemplate(serverApiHostName, serverApiPort, interceptors) + val response = performRequest(getRequestUrl(serverApiHostName, serverApiPort), "/player-activities", serverApiUsername, serverApiPassword) - return try { - Result.success( - restTemplate.getForObject("/player-activities", Array::class.java)?.toList() ?: emptyList() - ) - } catch (e: Exception) { - Result.failure(e) + return when (response.status) { + HttpStatusCode.OK -> Result.success(objectMapper.readValue(response.bodyAsText(), jacksonTypeRef>())) + else -> Result.failure(BotCompanionClientException("Unexpected response status '${response.status.value}' during ${this::getPlayerActivities.name} request.")) } } - fun getPvpKills(serverApiHostName: String, serverApiPort: Int, interceptors: List): Result> { + suspend fun getPvpKills( + serverApiHostName: String, + serverApiPort: Int, + serverApiUsername: String? = null, + serverApiPassword: String? = null + ): Result> { - val restTemplate = getRestTemplate(serverApiHostName, serverApiPort, interceptors) + val response = performRequest(getRequestUrl(serverApiHostName, serverApiPort), "/pvp-kills", serverApiUsername, serverApiPassword) - return try { - Result.success( - restTemplate.getForObject("/pvp-kills", Array::class.java)?.toList() ?: emptyList() - ) - } catch (e: Exception) { - Result.failure(e) + return when (response.status) { + HttpStatusCode.OK -> Result.success(objectMapper.readValue(response.bodyAsText(), jacksonTypeRef>())) + else -> Result.failure(BotCompanionClientException("Unexpected response status '${response.status.value}' during ${this::getPvpKills.name} request.")) } } - private fun getRestTemplate(serverApiHostName: String, serverApiPort: Int, interceptors: List): RestTemplate { + private suspend fun performRequest(url: URL, path: String, serverApiUsername: String?, serverApiPassword: String?): HttpResponse { + return httpClient.get(url) { + url { + appendPathSegments(path) + } + headers { + accept(ContentType.parse(MediaType.APPLICATION_JSON_VALUE)) + applicationContext.id?.let { userAgent(it) } + if (serverApiUsername != null && serverApiPassword != null) { + basicAuth(serverApiUsername, serverApiPassword) + } + } + } + } + private fun getRequestUrl(serverApiHostName: String, serverApiPort: Int): URL { val address = InetSocketAddress(serverApiHostName, serverApiPort) + return URL("http://${address.hostString}:${address.port}/v-rising-discord-bot") + } - @Suppress("HttpUrlsUsage") // the v risings http server does not support https - val requestURI = URI.create("http://${address.hostString}:${address.port}/v-rising-discord-bot") - - return RestTemplateBuilder() - .setConnectTimeout(Duration.ofSeconds(5)) - .setReadTimeout(Duration.ofSeconds(5)) - .rootUri(requestURI.toString()) - .interceptors(interceptors) - .build() + override fun destroy() { + httpClient.close() } } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/clients/botcompanion/BotCompanionClientException.kt b/src/main/kotlin/de/darkatra/vrising/discord/clients/botcompanion/BotCompanionClientException.kt new file mode 100644 index 0000000..41c36ea --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/clients/botcompanion/BotCompanionClientException.kt @@ -0,0 +1,5 @@ +package de.darkatra.vrising.discord.clients.botcompanion + +import de.darkatra.vrising.discord.BotException + +class BotCompanionClientException(message: String) : BotException(message) diff --git a/src/main/kotlin/de/darkatra/vrising/discord/clients/serverquery/ServerQueryClient.kt b/src/main/kotlin/de/darkatra/vrising/discord/clients/serverquery/ServerQueryClient.kt index b11be75..0dd0f7a 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/clients/serverquery/ServerQueryClient.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/clients/serverquery/ServerQueryClient.kt @@ -1,5 +1,6 @@ package de.darkatra.vrising.discord.clients.serverquery +import com.ibasco.agql.core.util.FailsafeOptions import com.ibasco.agql.core.util.GeneralOptions import com.ibasco.agql.protocols.valve.source.query.SourceQueryClient import com.ibasco.agql.protocols.valve.source.query.SourceQueryOptions @@ -7,26 +8,44 @@ import de.darkatra.vrising.discord.clients.serverquery.model.ServerStatus import org.springframework.beans.factory.DisposableBean import org.springframework.stereotype.Service import java.net.InetSocketAddress +import java.util.concurrent.CancellationException import java.util.concurrent.CompletableFuture @Service class ServerQueryClient : DisposableBean { - private val client by lazy { - SourceQueryClient( - SourceQueryOptions.builder() - .option(GeneralOptions.CONNECTION_POOLING, true) - .build() - ) - } + private val client = SourceQueryClient( + SourceQueryOptions.builder() + .option(GeneralOptions.CONNECTION_POOLING, true) + .option(GeneralOptions.READ_TIMEOUT, 5000) + .option(FailsafeOptions.FAILSAFE_RETRY_MAX_ATTEMPTS, 3) + .option(FailsafeOptions.FAILSAFE_RETRY_DELAY, 200) + .option(FailsafeOptions.FAILSAFE_CIRCBREAKER_ENABLED, false) + .build() + ) fun getServerStatus(serverHostName: String, serverQueryPort: Int): Result { val address = InetSocketAddress(serverHostName, serverQueryPort) - val getInfo = client.getInfo(address) - val getPlayers = client.getPlayers(address) - val getRules = client.getRules(address) + val getInfo = client.getInfo(address).handle { r, e -> + if (e != null) { + throw ServerQueryClientException("Exception performing getInfo query", e) + } + return@handle r + } + val getPlayers = client.getPlayers(address).handle { r, e -> + if (e != null) { + throw ServerQueryClientException("Exception performing getPlayers query", e) + } + return@handle r + } + val getRules = client.getRules(address).handle { r, e -> + if (e != null) { + throw ServerQueryClientException("Exception performing getRules query", e) + } + return@handle r + } return try { Result.success( @@ -38,8 +57,10 @@ class ServerQueryClient : DisposableBean { ) }.join() ) + } catch (e: CancellationException) { + Result.failure(CancellationException("Server query aborted.", e)) } catch (e: Exception) { - Result.failure(e) + Result.failure(ServerQueryClientException("Exception performing server query", e)) } } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/clients/serverquery/ServerQueryClientException.kt b/src/main/kotlin/de/darkatra/vrising/discord/clients/serverquery/ServerQueryClientException.kt new file mode 100644 index 0000000..c330e2c --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/clients/serverquery/ServerQueryClientException.kt @@ -0,0 +1,7 @@ +package de.darkatra.vrising.discord.clients.serverquery + +import de.darkatra.vrising.discord.BotException + +open class ServerQueryClientException(message: String, cause: Throwable? = null) : BotException(message, cause) + +class CancellationException(message: String, cause: Throwable? = null) : ServerQueryClientException(message, cause) diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/AddServerCommand.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/AddServerCommand.kt index 6f42484..442d23e 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/commands/AddServerCommand.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/commands/AddServerCommand.kt @@ -4,31 +4,20 @@ import com.fasterxml.uuid.Generators import de.darkatra.vrising.discord.BotProperties import de.darkatra.vrising.discord.commands.parameters.ServerApiHostnameParameter import de.darkatra.vrising.discord.commands.parameters.ServerHostnameParameter -import de.darkatra.vrising.discord.commands.parameters.addDisplayPlayerGearLevelParameter -import de.darkatra.vrising.discord.commands.parameters.addDisplayServerDescriptionParameter -import de.darkatra.vrising.discord.commands.parameters.addEmbedEnabledParameter -import de.darkatra.vrising.discord.commands.parameters.addPlayerActivityFeedChannelIdParameter -import de.darkatra.vrising.discord.commands.parameters.addPvpKillFeedChannelIdParameter import de.darkatra.vrising.discord.commands.parameters.addServerApiHostnameParameter import de.darkatra.vrising.discord.commands.parameters.addServerApiPasswordParameter import de.darkatra.vrising.discord.commands.parameters.addServerApiPortParameter import de.darkatra.vrising.discord.commands.parameters.addServerApiUsernameParameter import de.darkatra.vrising.discord.commands.parameters.addServerHostnameParameter import de.darkatra.vrising.discord.commands.parameters.addServerQueryPortParameter -import de.darkatra.vrising.discord.commands.parameters.getDisplayPlayerGearLevelParameter -import de.darkatra.vrising.discord.commands.parameters.getDisplayServerDescriptionParameter -import de.darkatra.vrising.discord.commands.parameters.getEmbedEnabledParameter -import de.darkatra.vrising.discord.commands.parameters.getPlayerActivityFeedChannelIdParameter -import de.darkatra.vrising.discord.commands.parameters.getPvpKillFeedChannelIdParameter import de.darkatra.vrising.discord.commands.parameters.getServerApiHostnameParameter import de.darkatra.vrising.discord.commands.parameters.getServerApiPasswordParameter import de.darkatra.vrising.discord.commands.parameters.getServerApiPortParameter import de.darkatra.vrising.discord.commands.parameters.getServerApiUsernameParameter import de.darkatra.vrising.discord.commands.parameters.getServerHostnameParameter import de.darkatra.vrising.discord.commands.parameters.getServerQueryPortParameter -import de.darkatra.vrising.discord.persistence.ServerStatusMonitorRepository -import de.darkatra.vrising.discord.persistence.model.ServerStatusMonitor -import de.darkatra.vrising.discord.persistence.model.ServerStatusMonitorStatus +import de.darkatra.vrising.discord.persistence.ServerRepository +import de.darkatra.vrising.discord.persistence.model.Server import dev.kord.core.Kord import dev.kord.core.behavior.interaction.response.respond import dev.kord.core.entity.interaction.ChatInputCommandInteraction @@ -41,14 +30,14 @@ import org.springframework.stereotype.Component @Component @EnableConfigurationProperties(BotProperties::class) class AddServerCommand( - private val serverStatusMonitorRepository: ServerStatusMonitorRepository, + private val serverRepository: ServerRepository, private val botProperties: BotProperties ) : Command { - private val logger = LoggerFactory.getLogger(javaClass) + private val logger by lazy { LoggerFactory.getLogger(javaClass) } private val name: String = "add-server" - private val description: String = "Adds a server to the status monitor." + private val description: String = "Adds a server." override fun getCommandName(): String = name @@ -69,13 +58,6 @@ class AddServerCommand( addServerApiPortParameter(required = false) addServerApiUsernameParameter(required = false) addServerApiPasswordParameter(required = false) - - addEmbedEnabledParameter(required = false) - addDisplayServerDescriptionParameter(required = false) - addDisplayPlayerGearLevelParameter(required = false) - - addPlayerActivityFeedChannelIdParameter(required = false) - addPvpKillFeedChannelIdParameter(required = false) } } @@ -96,45 +78,29 @@ class AddServerCommand( val apiUsername = interaction.getServerApiUsernameParameter() val apiPassword = interaction.getServerApiPasswordParameter() - val embedEnabled = interaction.getEmbedEnabledParameter() ?: true - val displayServerDescription = interaction.getDisplayServerDescriptionParameter() ?: true - val displayPlayerGearLevel = interaction.getDisplayPlayerGearLevelParameter() ?: true - - val playerActivityChannelId = interaction.getPlayerActivityFeedChannelIdParameter() - val pvpKillFeedChannelId = interaction.getPvpKillFeedChannelIdParameter() - val discordServerId = (interaction as GuildChatInputCommandInteraction).guildId - val channelId = interaction.channelId ServerHostnameParameter.validate(hostname, botProperties.allowLocalAddressRanges) ServerApiHostnameParameter.validate(apiHostname, botProperties.allowLocalAddressRanges) - val serverStatusMonitorId = Generators.timeBasedGenerator().generate() - serverStatusMonitorRepository.addServerStatusMonitor( - ServerStatusMonitor( - id = serverStatusMonitorId.toString(), + val serverId = Generators.timeBasedGenerator().generate() + serverRepository.addServer( + Server( + id = serverId.toString(), discordServerId = discordServerId.toString(), - discordChannelId = channelId.toString(), - playerActivityDiscordChannelId = playerActivityChannelId, - pvpKillFeedDiscordChannelId = pvpKillFeedChannelId, hostname = hostname, queryPort = queryPort, apiHostname = apiHostname, apiPort = apiPort, apiUsername = apiUsername, apiPassword = apiPassword, - status = ServerStatusMonitorStatus.ACTIVE, - embedEnabled = embedEnabled, - displayServerDescription = displayServerDescription, - displayPlayerGearLevel = displayPlayerGearLevel, ) ) - logger.info("Successfully added monitor with id '${serverStatusMonitorId}' for '${hostname}:${queryPort}' to channel '$channelId'.") + logger.info("Successfully added server '$serverId' for '$hostname:$queryPort' for discord server '$discordServerId'.") interaction.deferEphemeralResponse().respond { - content = """Added monitor with id '${serverStatusMonitorId}' for '${hostname}:${queryPort}' to channel '$channelId'. - |It may take a minute for the status message to appear.""".trimMargin() + content = "Added server with id '$serverId' for '$hostname:$queryPort' for discord server '$discordServerId'." } } } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/ConfigurePlayerActivityFeedCommand.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/ConfigurePlayerActivityFeedCommand.kt new file mode 100644 index 0000000..f3bfebc --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/commands/ConfigurePlayerActivityFeedCommand.kt @@ -0,0 +1,110 @@ +package de.darkatra.vrising.discord.commands + +import de.darkatra.vrising.discord.commands.parameters.ChannelIdParameter +import de.darkatra.vrising.discord.commands.parameters.addChannelIdParameter +import de.darkatra.vrising.discord.commands.parameters.addServerIdParameter +import de.darkatra.vrising.discord.commands.parameters.addStatusParameter +import de.darkatra.vrising.discord.commands.parameters.getChannelIdParameter +import de.darkatra.vrising.discord.commands.parameters.getServerIdParameter +import de.darkatra.vrising.discord.commands.parameters.getStatusParameter +import de.darkatra.vrising.discord.persistence.ServerRepository +import de.darkatra.vrising.discord.persistence.model.PlayerActivityFeed +import de.darkatra.vrising.discord.persistence.model.Status +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.GlobalChatInputCommandInteraction +import dev.kord.core.entity.interaction.GuildChatInputCommandInteraction +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component + +@Component +class ConfigurePlayerActivityFeedCommand( + private val serverRepository: ServerRepository +) : Command { + + private val logger by lazy { LoggerFactory.getLogger(javaClass) } + + private val name: String = "configure-player-activity-feed" + private val description: String = "Configures the player activity feed for a given server." + + override fun getCommandName(): String = name + + override suspend fun register(kord: Kord) { + + kord.createGlobalChatInputCommand( + name = name, + description = description + ) { + + dmPermission = false + disableCommandInGuilds() + + addServerIdParameter() + + addChannelIdParameter(required = false) + addStatusParameter(required = false) + } + } + + override suspend fun handle(interaction: ChatInputCommandInteraction) { + + val serverId = interaction.getServerIdParameter() + + val channelId = interaction.getChannelIdParameter() + val status = interaction.getStatusParameter() + + val server = when (interaction) { + is GuildChatInputCommandInteraction -> serverRepository.getServer(serverId, interaction.guildId.toString()) + is GlobalChatInputCommandInteraction -> serverRepository.getServer(serverId) + } + + if (server == null) { + interaction.deferEphemeralResponse().respond { + content = "No server with id '$serverId' was found." + } + return + } + + val playerActivityFeed = server.playerActivityFeed + if (playerActivityFeed == null) { + + if (channelId == null) { + interaction.deferEphemeralResponse().respond { + content = "'${ChannelIdParameter.NAME}' is required when using this command for the first time." + } + return + } + + server.playerActivityFeed = PlayerActivityFeed( + status = status ?: Status.ACTIVE, + discordChannelId = channelId, + lastUpdated = server.lastUpdated + ) + + serverRepository.updateServer(server) + + logger.info("Successfully configured the player activity feed for server '$serverId'.") + + interaction.deferEphemeralResponse().respond { + content = "Successfully configured the player activity feed for server with id '$serverId'." + } + return + } + + if (channelId != null) { + playerActivityFeed.discordChannelId = channelId + } + if (status != null) { + playerActivityFeed.status = status + } + + serverRepository.updateServer(server) + + logger.info("Successfully updated the player activity feed for server '$serverId'.") + + interaction.deferEphemeralResponse().respond { + content = "Successfully updated the player activity feed for server with id '$serverId'." + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/ConfigurePvpKillFeedCommand.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/ConfigurePvpKillFeedCommand.kt new file mode 100644 index 0000000..cf36301 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/commands/ConfigurePvpKillFeedCommand.kt @@ -0,0 +1,110 @@ +package de.darkatra.vrising.discord.commands + +import de.darkatra.vrising.discord.commands.parameters.ChannelIdParameter +import de.darkatra.vrising.discord.commands.parameters.addChannelIdParameter +import de.darkatra.vrising.discord.commands.parameters.addServerIdParameter +import de.darkatra.vrising.discord.commands.parameters.addStatusParameter +import de.darkatra.vrising.discord.commands.parameters.getChannelIdParameter +import de.darkatra.vrising.discord.commands.parameters.getServerIdParameter +import de.darkatra.vrising.discord.commands.parameters.getStatusParameter +import de.darkatra.vrising.discord.persistence.ServerRepository +import de.darkatra.vrising.discord.persistence.model.PvpKillFeed +import de.darkatra.vrising.discord.persistence.model.Status +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.GlobalChatInputCommandInteraction +import dev.kord.core.entity.interaction.GuildChatInputCommandInteraction +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component + +@Component +class ConfigurePvpKillFeedCommand( + private val serverRepository: ServerRepository +) : Command { + + private val logger by lazy { LoggerFactory.getLogger(javaClass) } + + private val name: String = "configure-pvp-kill-feed" + private val description: String = "Configures the pvp kill feed for a given server." + + override fun getCommandName(): String = name + + override suspend fun register(kord: Kord) { + + kord.createGlobalChatInputCommand( + name = name, + description = description + ) { + + dmPermission = false + disableCommandInGuilds() + + addServerIdParameter() + + addChannelIdParameter(required = false) + addStatusParameter(required = false) + } + } + + override suspend fun handle(interaction: ChatInputCommandInteraction) { + + val serverId = interaction.getServerIdParameter() + + val channelId = interaction.getChannelIdParameter() + val status = interaction.getStatusParameter() + + val server = when (interaction) { + is GuildChatInputCommandInteraction -> serverRepository.getServer(serverId, interaction.guildId.toString()) + is GlobalChatInputCommandInteraction -> serverRepository.getServer(serverId) + } + + if (server == null) { + interaction.deferEphemeralResponse().respond { + content = "No server with id '$serverId' was found." + } + return + } + + val pvpKillFeed = server.pvpKillFeed + if (pvpKillFeed == null) { + + if (channelId == null) { + interaction.deferEphemeralResponse().respond { + content = "'${ChannelIdParameter.NAME}' is required when using this command for the first time." + } + return + } + + server.pvpKillFeed = PvpKillFeed( + status = status ?: Status.ACTIVE, + discordChannelId = channelId, + lastUpdated = server.lastUpdated + ) + + serverRepository.updateServer(server) + + logger.info("Successfully configured the pvp kill feed for server '$serverId'.") + + interaction.deferEphemeralResponse().respond { + content = "Successfully configured the pvp kill feed for server with id '$serverId'." + } + return + } + + if (channelId != null) { + pvpKillFeed.discordChannelId = channelId + } + if (status != null) { + pvpKillFeed.status = status + } + + serverRepository.updateServer(server) + + logger.info("Successfully updated the pvp kill feed for server '$serverId'.") + + interaction.deferEphemeralResponse().respond { + content = "Successfully updated the pvp kill feed for server with id '$serverId'." + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/ConfigureStatusMonitorCommand.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/ConfigureStatusMonitorCommand.kt new file mode 100644 index 0000000..6212437 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/commands/ConfigureStatusMonitorCommand.kt @@ -0,0 +1,129 @@ +package de.darkatra.vrising.discord.commands + +import de.darkatra.vrising.discord.commands.parameters.ChannelIdParameter +import de.darkatra.vrising.discord.commands.parameters.addChannelIdParameter +import de.darkatra.vrising.discord.commands.parameters.addDisplayPlayerGearLevelParameter +import de.darkatra.vrising.discord.commands.parameters.addDisplayServerDescriptionParameter +import de.darkatra.vrising.discord.commands.parameters.addServerIdParameter +import de.darkatra.vrising.discord.commands.parameters.addStatusParameter +import de.darkatra.vrising.discord.commands.parameters.getChannelIdParameter +import de.darkatra.vrising.discord.commands.parameters.getDisplayPlayerGearLevelParameter +import de.darkatra.vrising.discord.commands.parameters.getDisplayServerDescriptionParameter +import de.darkatra.vrising.discord.commands.parameters.getServerIdParameter +import de.darkatra.vrising.discord.commands.parameters.getStatusParameter +import de.darkatra.vrising.discord.persistence.ServerRepository +import de.darkatra.vrising.discord.persistence.model.Status +import de.darkatra.vrising.discord.persistence.model.StatusMonitor +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.GlobalChatInputCommandInteraction +import dev.kord.core.entity.interaction.GuildChatInputCommandInteraction +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component + +@Component +class ConfigureStatusMonitorCommand( + private val serverRepository: ServerRepository +) : Command { + + private val logger by lazy { LoggerFactory.getLogger(javaClass) } + + private val name: String = "configure-status-monitor" + private val description: String = "Configures the status monitor for a given server." + + override fun getCommandName(): String = name + + override suspend fun register(kord: Kord) { + + kord.createGlobalChatInputCommand( + name = name, + description = description + ) { + + dmPermission = false + disableCommandInGuilds() + + addServerIdParameter() + + addChannelIdParameter(required = false) + addStatusParameter(required = false) + + addDisplayServerDescriptionParameter(required = false) + addDisplayPlayerGearLevelParameter(required = false) + } + } + + override suspend fun handle(interaction: ChatInputCommandInteraction) { + + val serverId = interaction.getServerIdParameter() + + val channelId = interaction.getChannelIdParameter() + val status = interaction.getStatusParameter() + + val displayServerDescription = interaction.getDisplayServerDescriptionParameter() + val displayPlayerGearLevel = interaction.getDisplayPlayerGearLevelParameter() + + val server = when (interaction) { + is GuildChatInputCommandInteraction -> serverRepository.getServer(serverId, interaction.guildId.toString()) + is GlobalChatInputCommandInteraction -> serverRepository.getServer(serverId) + } + + if (server == null) { + interaction.deferEphemeralResponse().respond { + content = "No server with id '$serverId' was found." + } + return + } + + val statusMonitor = server.statusMonitor + if (statusMonitor == null) { + + if (channelId == null) { + interaction.deferEphemeralResponse().respond { + content = "'${ChannelIdParameter.NAME}' is required when using this command for the first time." + } + return + } + + server.statusMonitor = StatusMonitor( + status = status ?: Status.ACTIVE, + discordChannelId = channelId, + displayServerDescription = displayServerDescription ?: true, + displayPlayerGearLevel = displayPlayerGearLevel ?: true + ) + + serverRepository.updateServer(server) + + logger.info("Successfully configured the status monitor for server '$serverId'.") + + interaction.deferEphemeralResponse().respond { + content = """Successfully configured the status monitor for server with id '$serverId'. + |It may take a few minutes for the status embed to appear.""".trimMargin() + } + return + } + + if (channelId != null) { + statusMonitor.discordChannelId = channelId + } + if (status != null) { + statusMonitor.status = status + } + if (displayServerDescription != null) { + statusMonitor.displayServerDescription = displayServerDescription + } + if (displayPlayerGearLevel != null) { + statusMonitor.displayPlayerGearLevel = displayPlayerGearLevel + } + + serverRepository.updateServer(server) + + logger.info("Successfully updated the status monitor for server '$serverId'.") + + interaction.deferEphemeralResponse().respond { + content = """Successfully updated the status monitor for server with id '$serverId'. + |It may take a few minutes for the status embed to reflect the changes.""".trimMargin() + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/ErrorHelper.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/ErrorHelper.kt new file mode 100644 index 0000000..f7b2b9b --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/commands/ErrorHelper.kt @@ -0,0 +1,19 @@ +package de.darkatra.vrising.discord.commands + +import de.darkatra.vrising.discord.persistence.model.ErrorAware +import dev.kord.rest.builder.message.EmbedBuilder +import org.springframework.util.StringUtils + +fun EmbedBuilder.renderRecentErrors(errorAware: ErrorAware, maxCharactersPerError: Int) { + val recentErrors = errorAware.recentErrors + if (recentErrors.isNotEmpty()) { + recentErrors.chunked(5).forEachIndexed { i, chunk -> + field { + name = "Errors - Page ${i + 1}" + value = chunk.joinToString("\n") { + "```${StringUtils.truncate(it.message, maxCharactersPerError)}```" + } + } + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/GetPlayerActivityFeedDetailsCommand.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/GetPlayerActivityFeedDetailsCommand.kt new file mode 100644 index 0000000..05063b8 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/commands/GetPlayerActivityFeedDetailsCommand.kt @@ -0,0 +1,89 @@ +package de.darkatra.vrising.discord.commands + +import de.darkatra.vrising.discord.BotProperties +import de.darkatra.vrising.discord.commands.parameters.addServerIdParameter +import de.darkatra.vrising.discord.persistence.ServerRepository +import dev.kord.core.Kord +import dev.kord.core.behavior.interaction.response.respond +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import dev.kord.rest.builder.message.embed +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.stereotype.Component + +@Component +@EnableConfigurationProperties(BotProperties::class) +class GetPlayerActivityFeedDetailsCommand( + private val serverRepository: ServerRepository, + private val botProperties: BotProperties +) : Command { + + private val name: String = "get-player-activity-feed-details" + private val description: String = "Gets all details of the player activity feed for the specified server." + + override fun getCommandName(): String = name + + override suspend fun register(kord: Kord) { + + kord.createGlobalChatInputCommand( + name = name, + description = description + ) { + dmPermission = true + disableCommandInGuilds() + + addServerIdParameter() + } + } + + override suspend fun handle(interaction: ChatInputCommandInteraction) { + + val server = interaction.getServer(serverRepository) + ?: return + + val playerActivityFeed = server.playerActivityFeed + if (playerActivityFeed == null) { + interaction.deferEphemeralResponse().respond { + content = "No player activity feed is configured for server with id '${server.id}'." + } + return + } + + interaction.deferEphemeralResponse().respond { + embed { + title = "Player Activity Feed Details for ${server.id}" + + field { + name = "Status" + value = playerActivityFeed.status.name + inline = true + } + + field { + name = "Discord Server Id" + value = server.discordServerId + inline = true + } + + field { + name = "Discord Channel Id" + value = playerActivityFeed.discordChannelId + inline = true + } + + field { + name = "Current Failed Attempts" + value = "${playerActivityFeed.currentFailedAttempts}" + inline = true + } + + field { + name = "Last Updated" + value = "" + inline = true + } + + renderRecentErrors(playerActivityFeed, botProperties.maxCharactersPerError) + } + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/GetPvpKillFeedDetailsCommand.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/GetPvpKillFeedDetailsCommand.kt new file mode 100644 index 0000000..9293a3e --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/commands/GetPvpKillFeedDetailsCommand.kt @@ -0,0 +1,89 @@ +package de.darkatra.vrising.discord.commands + +import de.darkatra.vrising.discord.BotProperties +import de.darkatra.vrising.discord.commands.parameters.addServerIdParameter +import de.darkatra.vrising.discord.persistence.ServerRepository +import dev.kord.core.Kord +import dev.kord.core.behavior.interaction.response.respond +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import dev.kord.rest.builder.message.embed +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.stereotype.Component + +@Component +@EnableConfigurationProperties(BotProperties::class) +class GetPvpKillFeedDetailsCommand( + private val serverRepository: ServerRepository, + private val botProperties: BotProperties +) : Command { + + private val name: String = "get-pvp-kill-feed-details" + private val description: String = "Gets all details of the pvp kill feed for the specified server." + + override fun getCommandName(): String = name + + override suspend fun register(kord: Kord) { + + kord.createGlobalChatInputCommand( + name = name, + description = description + ) { + dmPermission = true + disableCommandInGuilds() + + addServerIdParameter() + } + } + + override suspend fun handle(interaction: ChatInputCommandInteraction) { + + val server = interaction.getServer(serverRepository) + ?: return + + val pvpKillFeed = server.pvpKillFeed + if (pvpKillFeed == null) { + interaction.deferEphemeralResponse().respond { + content = "No pvp kill feed is configured for server with id '${server.id}'." + } + return + } + + interaction.deferEphemeralResponse().respond { + embed { + title = "Pvp Kill Feed Details for ${server.id}" + + field { + name = "Status" + value = pvpKillFeed.status.name + inline = true + } + + field { + name = "Discord Server Id" + value = server.discordServerId + inline = true + } + + field { + name = "Discord Channel Id" + value = pvpKillFeed.discordChannelId + inline = true + } + + field { + name = "Current Failed Attempts" + value = "${pvpKillFeed.currentFailedAttempts}" + inline = true + } + + field { + name = "Last Updated" + value = "" + inline = true + } + + renderRecentErrors(pvpKillFeed, botProperties.maxCharactersPerError) + } + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/GetServerDetailsCommand.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/GetServerDetailsCommand.kt index 13f15c7..60e30e0 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/commands/GetServerDetailsCommand.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/commands/GetServerDetailsCommand.kt @@ -1,24 +1,18 @@ package de.darkatra.vrising.discord.commands -import de.darkatra.vrising.discord.BotProperties -import de.darkatra.vrising.discord.commands.parameters.addServerStatusMonitorIdParameter -import de.darkatra.vrising.discord.commands.parameters.getServerStatusMonitorIdParameter -import de.darkatra.vrising.discord.persistence.ServerStatusMonitorRepository +import de.darkatra.vrising.discord.commands.parameters.addServerIdParameter +import de.darkatra.vrising.discord.persistence.ServerRepository 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.GlobalChatInputCommandInteraction -import dev.kord.core.entity.interaction.GuildChatInputCommandInteraction import dev.kord.rest.builder.message.embed -import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.stereotype.Component -import org.springframework.util.StringUtils +import java.util.Objects +import java.util.stream.Stream @Component -@EnableConfigurationProperties(BotProperties::class) class GetServerDetailsCommand( - private val serverStatusMonitorRepository: ServerStatusMonitorRepository, - private val botProperties: BotProperties + private val serverRepository: ServerRepository ) : Command { private val name: String = "get-server-details" @@ -35,142 +29,84 @@ class GetServerDetailsCommand( dmPermission = true disableCommandInGuilds() - addServerStatusMonitorIdParameter() + addServerIdParameter() } } override suspend fun handle(interaction: ChatInputCommandInteraction) { - val serverStatusMonitorId = interaction.getServerStatusMonitorIdParameter() - - val serverStatusMonitor = when (interaction) { - is GuildChatInputCommandInteraction -> serverStatusMonitorRepository.getServerStatusMonitor(serverStatusMonitorId, interaction.guildId.toString()) - is GlobalChatInputCommandInteraction -> serverStatusMonitorRepository.getServerStatusMonitor(serverStatusMonitorId) - } - - if (serverStatusMonitor == null) { - interaction.deferEphemeralResponse().respond { - content = "No server with id '$serverStatusMonitorId' was found." - } - return - } + val server = interaction.getServer(serverRepository) + ?: return interaction.deferEphemeralResponse().respond { embed { - title = "Details for ${serverStatusMonitor.id}" + title = "Details for ${server.id}" field { name = "Hostname" - value = serverStatusMonitor.hostname + value = server.hostname inline = true } field { name = "Query Port" - value = "${serverStatusMonitor.queryPort}" + value = "${server.queryPort}" inline = true } field { - name = "Status" - value = serverStatusMonitor.status.name + name = "Discord Server Id" + value = server.discordServerId inline = true } field { name = "Api Hostname" - value = when (serverStatusMonitor.apiHostname != null) { - true -> "${serverStatusMonitor.apiHostname}" - false -> "-" + value = when { + server.apiHostname != null -> "${server.apiHostname}" + else -> "-" } inline = true } field { name = "Api Port" - value = when (serverStatusMonitor.apiPort != null) { - true -> "${serverStatusMonitor.apiPort}" - false -> "-" + value = when { + server.apiPort != null -> "${server.apiPort}" + else -> "-" } inline = true } field { - name = "Embed Enabled" - value = "${serverStatusMonitor.embedEnabled}" - inline = true - } - - field { - name = "Display Server Description" - value = "${serverStatusMonitor.displayServerDescription}" - inline = true - } - - field { - name = "Display Player Gear Level" - value = "${serverStatusMonitor.displayPlayerGearLevel}" - inline = true - } - - field { - name = "Discord Server Id" - value = serverStatusMonitor.discordServerId - inline = true - } - - field { - name = "Discord Channel Id" - value = serverStatusMonitor.discordChannelId - inline = true - } - - field { - name = "Player Activity Feed Channel Id" - value = serverStatusMonitor.playerActivityDiscordChannelId ?: "-" + name = "Last Update Attempt" + value = "" inline = true } field { - name = "Pvp Kill Feed Channel Id" - value = serverStatusMonitor.pvpKillFeedDiscordChannelId ?: "-" + name = "Status Monitor Status" + value = server.statusMonitor?.status?.name ?: "-" inline = true } field { - name = "Current Embed Message Id" - value = serverStatusMonitor.currentEmbedMessageId ?: "-" + name = "Player Activity Feed Status" + value = server.playerActivityFeed?.status?.name ?: "-" inline = true } field { - name = "Current Failed Attempts" - value = "${serverStatusMonitor.currentFailedAttempts}" + name = "Pvp Kill Feed Status" + value = server.pvpKillFeed?.status?.name ?: "-" inline = true } field { - name = "Current Failed Api Attempts" - value = "${serverStatusMonitor.currentFailedApiAttempts}" + name = "Number of Leaderboards" + value = "${Stream.of(server.pvpLeaderboard).filter(Objects::nonNull).count()}" inline = true } - - field { - name = "Last Update Attempt" - value = "" - inline = true - } - - if (serverStatusMonitor.recentErrors.isNotEmpty()) { - serverStatusMonitor.recentErrors.chunked(5).forEachIndexed { i, chunk -> - field { - name = "Most recent Errors $i" - value = chunk.joinToString("\n") { - "```${StringUtils.truncate(it.message, botProperties.maxCharactersPerError)}```" - } - } - } - } } } } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/GetStatusMonitorDetailsCommand.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/GetStatusMonitorDetailsCommand.kt new file mode 100644 index 0000000..c6b04ed --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/commands/GetStatusMonitorDetailsCommand.kt @@ -0,0 +1,113 @@ +package de.darkatra.vrising.discord.commands + +import de.darkatra.vrising.discord.BotProperties +import de.darkatra.vrising.discord.commands.parameters.addServerIdParameter +import de.darkatra.vrising.discord.persistence.ServerRepository +import dev.kord.core.Kord +import dev.kord.core.behavior.interaction.response.respond +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import dev.kord.rest.builder.message.embed +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.stereotype.Component + +@Component +@EnableConfigurationProperties(BotProperties::class) +class GetStatusMonitorDetailsCommand( + private val serverRepository: ServerRepository, + private val botProperties: BotProperties +) : Command { + + private val name: String = "get-status-monitor-details" + private val description: String = "Gets all details of the status monitor for the specified server." + + override fun getCommandName(): String = name + + override suspend fun register(kord: Kord) { + + kord.createGlobalChatInputCommand( + name = name, + description = description + ) { + dmPermission = true + disableCommandInGuilds() + + addServerIdParameter() + } + } + + override suspend fun handle(interaction: ChatInputCommandInteraction) { + + val server = interaction.getServer(serverRepository) + ?: return + + val statusMonitor = server.statusMonitor + if (statusMonitor == null) { + interaction.deferEphemeralResponse().respond { + content = "No status monitor is configured for server with id '${server.id}'." + } + return + } + + interaction.deferEphemeralResponse().respond { + embed { + title = "Status Monitor Details for ${server.id}" + + field { + name = "Status" + value = statusMonitor.status.name + inline = true + } + + field { + name = "Discord Server Id" + value = server.discordServerId + inline = true + } + + field { + name = "Discord Channel Id" + value = statusMonitor.discordChannelId + inline = true + } + + field { + name = "Display Server Description" + value = "${statusMonitor.displayServerDescription}" + inline = true + } + + field { + name = "Display Player Gear Level" + value = "${statusMonitor.displayPlayerGearLevel}" + inline = true + } + + field { + name = "Current Embed Message Id" + value = statusMonitor.currentEmbedMessageId ?: "-" + inline = true + } + + field { + name = "Current Failed Attempts" + value = "${statusMonitor.currentFailedAttempts}" + inline = true + } + + field { + name = "Current Failed Api Attempts" + value = "${statusMonitor.currentFailedApiAttempts}" + inline = true + } + + field { + name = "Last Updated" + value = "" + inline = true + } + + renderRecentErrors(statusMonitor, botProperties.maxCharactersPerError) + } + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/ListServersCommand.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/ListServersCommand.kt index 675ee5e..0030e37 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/commands/ListServersCommand.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/commands/ListServersCommand.kt @@ -4,8 +4,8 @@ import de.darkatra.vrising.discord.BotProperties import de.darkatra.vrising.discord.commands.parameters.PageParameter import de.darkatra.vrising.discord.commands.parameters.addPageParameter import de.darkatra.vrising.discord.commands.parameters.getPageParameter -import de.darkatra.vrising.discord.persistence.ServerStatusMonitorRepository -import de.darkatra.vrising.discord.persistence.model.ServerStatusMonitor +import de.darkatra.vrising.discord.persistence.ServerRepository +import de.darkatra.vrising.discord.persistence.model.Server import dev.kord.core.Kord import dev.kord.core.behavior.interaction.response.respond import dev.kord.core.entity.interaction.ChatInputCommandInteraction @@ -14,16 +14,16 @@ import dev.kord.core.entity.interaction.GuildChatInputCommandInteraction import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.stereotype.Component -private const val PAGE_SIZE = 10 +private const val PAGE_SIZE = 10L @Component @EnableConfigurationProperties(BotProperties::class) class ListServersCommand( - private val serverStatusMonitorRepository: ServerStatusMonitorRepository + private val serverRepository: ServerRepository ) : Command { private val name: String = "list-servers" - private val description: String = "Lists all server status monitors." + private val description: String = "Lists all servers." override fun getCommandName(): String = name @@ -46,8 +46,8 @@ class ListServersCommand( PageParameter.validate(page) val totalElements = when (interaction) { - is GuildChatInputCommandInteraction -> serverStatusMonitorRepository.count(interaction.guildId.toString()) - is GlobalChatInputCommandInteraction -> serverStatusMonitorRepository.count() + is GuildChatInputCommandInteraction -> serverRepository.count(interaction.guildId.toString()) + is GlobalChatInputCommandInteraction -> serverRepository.count() } val totalPages = totalElements / PAGE_SIZE + 1 @@ -58,24 +58,24 @@ class ListServersCommand( return } - val serverStatusMonitors: List = when (interaction) { - is GuildChatInputCommandInteraction -> serverStatusMonitorRepository.getServerStatusMonitors( + val servers: List = when (interaction) { + is GuildChatInputCommandInteraction -> serverRepository.getServers( discordServerId = interaction.guildId.toString(), offset = page * PAGE_SIZE, limit = PAGE_SIZE ) - is GlobalChatInputCommandInteraction -> serverStatusMonitorRepository.getServerStatusMonitors( + is GlobalChatInputCommandInteraction -> serverRepository.getServers( offset = page * PAGE_SIZE, limit = PAGE_SIZE ) } interaction.deferEphemeralResponse().respond { - content = when (serverStatusMonitors.isEmpty()) { - true -> "No servers found." - false -> serverStatusMonitors.joinToString(separator = "\n") { serverStatusMonitor -> - "${serverStatusMonitor.id} - ${serverStatusMonitor.hostname}:${serverStatusMonitor.queryPort} - ${serverStatusMonitor.status.name}" + content = when { + servers.isEmpty() -> "No servers found." + else -> servers.joinToString(separator = "\n") { server -> + "${server.id} - ${server.hostname}:${server.queryPort} - ${server.status.name}" } + "\n*Current Page: $page, Total Pages: $totalPages*" } } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/RemoveServerCommand.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/RemoveServerCommand.kt index cbfa77c..25f891b 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/commands/RemoveServerCommand.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/commands/RemoveServerCommand.kt @@ -1,8 +1,8 @@ package de.darkatra.vrising.discord.commands -import de.darkatra.vrising.discord.commands.parameters.addServerStatusMonitorIdParameter -import de.darkatra.vrising.discord.commands.parameters.getServerStatusMonitorIdParameter -import de.darkatra.vrising.discord.persistence.ServerStatusMonitorRepository +import de.darkatra.vrising.discord.commands.parameters.addServerIdParameter +import de.darkatra.vrising.discord.commands.parameters.getServerIdParameter +import de.darkatra.vrising.discord.persistence.ServerRepository import dev.kord.core.Kord import dev.kord.core.behavior.interaction.response.respond import dev.kord.core.entity.interaction.ChatInputCommandInteraction @@ -13,13 +13,13 @@ import org.springframework.stereotype.Component @Component class RemoveServerCommand( - private val serverStatusMonitorRepository: ServerStatusMonitorRepository + private val serverRepository: ServerRepository ) : Command { - private val logger = LoggerFactory.getLogger(javaClass) + private val logger by lazy { LoggerFactory.getLogger(javaClass) } private val name: String = "remove-server" - private val description: String = "Removes a server from the status monitor." + private val description: String = "Removes a server." override fun getCommandName(): String = name @@ -32,29 +32,27 @@ class RemoveServerCommand( dmPermission = true disableCommandInGuilds() - addServerStatusMonitorIdParameter() + addServerIdParameter() } } override suspend fun handle(interaction: ChatInputCommandInteraction) { - val serverStatusMonitorId = interaction.getServerStatusMonitorIdParameter() + val serverId = interaction.getServerIdParameter() val wasSuccessful = when (interaction) { - is GuildChatInputCommandInteraction -> serverStatusMonitorRepository.removeServerStatusMonitor( - serverStatusMonitorId, - interaction.guildId.toString() - ) - - is GlobalChatInputCommandInteraction -> serverStatusMonitorRepository.removeServerStatusMonitor(serverStatusMonitorId) + is GuildChatInputCommandInteraction -> serverRepository.removeServer(serverId, interaction.guildId.toString()) + is GlobalChatInputCommandInteraction -> serverRepository.removeServer(serverId) } - logger.info("Successfully removed monitor with id '${serverStatusMonitorId}'.") + if (wasSuccessful) { + logger.info("Successfully removed server with id '$serverId'.") + } interaction.deferEphemeralResponse().respond { content = when (wasSuccessful) { - true -> "Removed monitor with id '$serverStatusMonitorId'." - false -> "No server with id '$serverStatusMonitorId' was found." + true -> "Removed server with id '$serverId'." + false -> "No server with id '$serverId' was found." } } } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/ServerHelper.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/ServerHelper.kt new file mode 100644 index 0000000..d81aae4 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/commands/ServerHelper.kt @@ -0,0 +1,28 @@ +package de.darkatra.vrising.discord.commands + +import de.darkatra.vrising.discord.commands.parameters.getServerIdParameter +import de.darkatra.vrising.discord.persistence.ServerRepository +import de.darkatra.vrising.discord.persistence.model.Server +import dev.kord.core.behavior.interaction.response.respond +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import dev.kord.core.entity.interaction.GlobalChatInputCommandInteraction +import dev.kord.core.entity.interaction.GuildChatInputCommandInteraction + +suspend fun ChatInputCommandInteraction.getServer(serverRepository: ServerRepository): Server? { + + val serverId = getServerIdParameter() + + val server = when (this) { + is GuildChatInputCommandInteraction -> serverRepository.getServer(serverId, guildId.toString()) + is GlobalChatInputCommandInteraction -> serverRepository.getServer(serverId) + } + + if (server == null) { + deferEphemeralResponse().respond { + content = "No server with id '$serverId' was found." + } + return null + } + + return server +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/UpdateServerCommand.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/UpdateServerCommand.kt index e26ae29..3462e91 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/commands/UpdateServerCommand.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/commands/UpdateServerCommand.kt @@ -3,38 +3,24 @@ package de.darkatra.vrising.discord.commands import de.darkatra.vrising.discord.BotProperties import de.darkatra.vrising.discord.commands.parameters.ServerApiHostnameParameter import de.darkatra.vrising.discord.commands.parameters.ServerHostnameParameter -import de.darkatra.vrising.discord.commands.parameters.addDisplayPlayerGearLevelParameter -import de.darkatra.vrising.discord.commands.parameters.addDisplayServerDescriptionParameter -import de.darkatra.vrising.discord.commands.parameters.addEmbedEnabledParameter -import de.darkatra.vrising.discord.commands.parameters.addPlayerActivityFeedChannelIdParameter -import de.darkatra.vrising.discord.commands.parameters.addPvpKillFeedChannelIdParameter import de.darkatra.vrising.discord.commands.parameters.addServerApiHostnameParameter import de.darkatra.vrising.discord.commands.parameters.addServerApiPasswordParameter import de.darkatra.vrising.discord.commands.parameters.addServerApiPortParameter import de.darkatra.vrising.discord.commands.parameters.addServerApiUsernameParameter import de.darkatra.vrising.discord.commands.parameters.addServerHostnameParameter +import de.darkatra.vrising.discord.commands.parameters.addServerIdParameter import de.darkatra.vrising.discord.commands.parameters.addServerQueryPortParameter -import de.darkatra.vrising.discord.commands.parameters.addServerStatusMonitorIdParameter -import de.darkatra.vrising.discord.commands.parameters.addServerStatusMonitorStatusParameter -import de.darkatra.vrising.discord.commands.parameters.getDisplayPlayerGearLevelParameter -import de.darkatra.vrising.discord.commands.parameters.getDisplayServerDescriptionParameter -import de.darkatra.vrising.discord.commands.parameters.getEmbedEnabledParameter -import de.darkatra.vrising.discord.commands.parameters.getPlayerActivityFeedChannelIdParameter -import de.darkatra.vrising.discord.commands.parameters.getPvpKillFeedChannelIdParameter import de.darkatra.vrising.discord.commands.parameters.getServerApiHostnameParameter import de.darkatra.vrising.discord.commands.parameters.getServerApiPasswordParameter import de.darkatra.vrising.discord.commands.parameters.getServerApiPortParameter import de.darkatra.vrising.discord.commands.parameters.getServerApiUsernameParameter import de.darkatra.vrising.discord.commands.parameters.getServerHostnameParameter +import de.darkatra.vrising.discord.commands.parameters.getServerIdParameter import de.darkatra.vrising.discord.commands.parameters.getServerQueryPortParameter -import de.darkatra.vrising.discord.commands.parameters.getServerStatusMonitorIdParameter -import de.darkatra.vrising.discord.commands.parameters.getServerStatusMonitorStatusParameter -import de.darkatra.vrising.discord.persistence.ServerStatusMonitorRepository +import de.darkatra.vrising.discord.persistence.ServerRepository 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.GlobalChatInputCommandInteraction -import dev.kord.core.entity.interaction.GuildChatInputCommandInteraction import org.slf4j.LoggerFactory import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.stereotype.Component @@ -42,14 +28,14 @@ import org.springframework.stereotype.Component @Component @EnableConfigurationProperties(BotProperties::class) class UpdateServerCommand( - private val serverStatusMonitorRepository: ServerStatusMonitorRepository, + private val serverRepository: ServerRepository, private val botProperties: BotProperties ) : Command { - private val logger = LoggerFactory.getLogger(javaClass) + private val logger by lazy { LoggerFactory.getLogger(javaClass) } private val name: String = "update-server" - private val description: String = "Updates the given server status monitor." + private val description: String = "Updates the given server." override fun getCommandName(): String = name @@ -62,7 +48,7 @@ class UpdateServerCommand( dmPermission = true disableCommandInGuilds() - addServerStatusMonitorIdParameter() + addServerIdParameter() addServerHostnameParameter(required = false) addServerQueryPortParameter(required = false) @@ -71,21 +57,15 @@ class UpdateServerCommand( addServerApiPortParameter(required = false) addServerApiUsernameParameter(required = false) addServerApiPasswordParameter(required = false) - - addServerStatusMonitorStatusParameter(required = false) - - addEmbedEnabledParameter(required = false) - addDisplayServerDescriptionParameter(required = false) - addDisplayPlayerGearLevelParameter(required = false) - - addPlayerActivityFeedChannelIdParameter(required = false) - addPvpKillFeedChannelIdParameter(required = false) } } override suspend fun handle(interaction: ChatInputCommandInteraction) { - val serverStatusMonitorId = interaction.getServerStatusMonitorIdParameter() + val server = interaction.getServer(serverRepository) + ?: return + + val serverId = interaction.getServerIdParameter() val hostname = interaction.getServerHostnameParameter() val queryPort = interaction.getServerQueryPortParameter() @@ -94,73 +74,35 @@ class UpdateServerCommand( val apiUsername = interaction.getServerApiUsernameParameter() val apiPassword = interaction.getServerApiPasswordParameter() - val status = interaction.getServerStatusMonitorStatusParameter() - - val embedEnabled = interaction.getEmbedEnabledParameter() - val displayServerDescription = interaction.getDisplayServerDescriptionParameter() - val displayPlayerGearLevel = interaction.getDisplayPlayerGearLevelParameter() - - val playerActivityFeedChannelId = interaction.getPlayerActivityFeedChannelIdParameter() - val pvpKillFeedChannelId = interaction.getPvpKillFeedChannelIdParameter() - - val serverStatusMonitor = when (interaction) { - is GuildChatInputCommandInteraction -> serverStatusMonitorRepository.getServerStatusMonitor(serverStatusMonitorId, interaction.guildId.toString()) - is GlobalChatInputCommandInteraction -> serverStatusMonitorRepository.getServerStatusMonitor(serverStatusMonitorId) - } - - if (serverStatusMonitor == null) { - interaction.deferEphemeralResponse().respond { - content = "No server with id '$serverStatusMonitorId' was found." - } - return - } - if (hostname != null) { ServerHostnameParameter.validate(hostname, botProperties.allowLocalAddressRanges) - serverStatusMonitor.hostname = hostname + server.hostname = hostname } if (queryPort != null) { - serverStatusMonitor.queryPort = queryPort + server.queryPort = queryPort } if (apiHostname != null) { - serverStatusMonitor.apiHostname = determineValueOfNullableStringParameter(apiHostname).also { + server.apiHostname = determineValueOfNullableStringParameter(apiHostname).also { ServerApiHostnameParameter.validate(it, botProperties.allowLocalAddressRanges) } } if (apiPort != null) { - serverStatusMonitor.apiPort = if (apiPort == -1) null else apiPort + server.apiPort = if (apiPort == -1) null else apiPort } if (apiUsername != null) { - serverStatusMonitor.apiUsername = determineValueOfNullableStringParameter(apiUsername) + server.apiUsername = determineValueOfNullableStringParameter(apiUsername) } if (apiPassword != null) { - serverStatusMonitor.apiPassword = determineValueOfNullableStringParameter(apiPassword) - } - if (status != null) { - serverStatusMonitor.status = status - } - if (embedEnabled != null) { - serverStatusMonitor.embedEnabled = embedEnabled - } - if (displayServerDescription != null) { - serverStatusMonitor.displayServerDescription = displayServerDescription - } - if (displayPlayerGearLevel != null) { - serverStatusMonitor.displayPlayerGearLevel = displayPlayerGearLevel - } - if (playerActivityFeedChannelId != null) { - serverStatusMonitor.playerActivityDiscordChannelId = determineValueOfNullableStringParameter(playerActivityFeedChannelId) - } - if (pvpKillFeedChannelId != null) { - serverStatusMonitor.pvpKillFeedDiscordChannelId = determineValueOfNullableStringParameter(pvpKillFeedChannelId) + server.apiPassword = determineValueOfNullableStringParameter(apiPassword) } - serverStatusMonitorRepository.updateServerStatusMonitor(serverStatusMonitor) + serverRepository.updateServer(server) - logger.info("Successfully updated monitor with id '${serverStatusMonitorId}'.") + logger.info("Successfully updated server '$serverId'.") interaction.deferEphemeralResponse().respond { - content = "Updated server status monitor with id '${serverStatusMonitorId}'. It may take some time until the status message is updated." + content = """Successfully updated server with id '$serverId'. + |Related status embeds, activity feeds, kill feeds and leaderboards may take some time to reflect the changes.""".trimMargin() } } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/ChannelIdParameter.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/ChannelIdParameter.kt new file mode 100644 index 0000000..0ec8b13 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/ChannelIdParameter.kt @@ -0,0 +1,23 @@ +package de.darkatra.vrising.discord.commands.parameters + +import de.darkatra.vrising.discord.getChannelIdFromStringParameter +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder +import dev.kord.rest.builder.interaction.string + +object ChannelIdParameter { + const val NAME = "channel-id" +} + +fun GlobalChatInputCreateBuilder.addChannelIdParameter(required: Boolean = true) { + string( + name = ChannelIdParameter.NAME, + description = "The id of the channel to post to." + ) { + this.required = required + } +} + +fun ChatInputCommandInteraction.getChannelIdParameter(): String? { + return command.getChannelIdFromStringParameter(ChannelIdParameter.NAME) +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/EmbedEnabledParameter.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/EmbedEnabledParameter.kt deleted file mode 100644 index 03da07d..0000000 --- a/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/EmbedEnabledParameter.kt +++ /dev/null @@ -1,22 +0,0 @@ -package de.darkatra.vrising.discord.commands.parameters - -import dev.kord.core.entity.interaction.ChatInputCommandInteraction -import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder -import dev.kord.rest.builder.interaction.boolean - -object EmbedEnabledParameter { - const val NAME = "embed-enabled" -} - -fun GlobalChatInputCreateBuilder.addEmbedEnabledParameter(required: Boolean = true) { - boolean( - name = EmbedEnabledParameter.NAME, - description = "Whether or not a discord status embed should be posted. Defaults to true." - ) { - this.required = required - } -} - -fun ChatInputCommandInteraction.getEmbedEnabledParameter(): Boolean? { - return command.booleans[EmbedEnabledParameter.NAME] -} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/PageParameter.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/PageParameter.kt index f9d4621..9a8d7e3 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/PageParameter.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/PageParameter.kt @@ -8,7 +8,7 @@ import dev.kord.rest.builder.interaction.integer object PageParameter { const val NAME = "page" - fun validate(page: Int) { + fun validate(page: Long) { if (page < 0) { throw ValidationException("'$NAME' must be greater than or equal to zero. Rejected: $page") } @@ -24,6 +24,6 @@ fun GlobalChatInputCreateBuilder.addPageParameter(required: Boolean = true) { } } -fun ChatInputCommandInteraction.getPageParameter(): Int? { - return command.integers[PageParameter.NAME]?.let { Math.toIntExact(it) } +fun ChatInputCommandInteraction.getPageParameter(): Long? { + return command.integers[PageParameter.NAME] } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/PlayerActivityFeedChannelIdParameter.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/PlayerActivityFeedChannelIdParameter.kt deleted file mode 100644 index c6f7b19..0000000 --- a/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/PlayerActivityFeedChannelIdParameter.kt +++ /dev/null @@ -1,23 +0,0 @@ -package de.darkatra.vrising.discord.commands.parameters - -import de.darkatra.vrising.discord.getChannelIdFromStringParameter -import dev.kord.core.entity.interaction.ChatInputCommandInteraction -import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder -import dev.kord.rest.builder.interaction.string - -object PlayerActivityFeedChannelIdParameter { - const val NAME = "player-activity-feed-channel-id" -} - -fun GlobalChatInputCreateBuilder.addPlayerActivityFeedChannelIdParameter(required: Boolean = true) { - string( - name = PlayerActivityFeedChannelIdParameter.NAME, - description = "The id of the channel to post the player activity feed in." - ) { - this.required = required - } -} - -fun ChatInputCommandInteraction.getPlayerActivityFeedChannelIdParameter(): String? { - return command.getChannelIdFromStringParameter(PlayerActivityFeedChannelIdParameter.NAME) -} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/PvpKillFeedChannelIdParameter.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/PvpKillFeedChannelIdParameter.kt deleted file mode 100644 index ffbf8f0..0000000 --- a/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/PvpKillFeedChannelIdParameter.kt +++ /dev/null @@ -1,23 +0,0 @@ -package de.darkatra.vrising.discord.commands.parameters - -import de.darkatra.vrising.discord.getChannelIdFromStringParameter -import dev.kord.core.entity.interaction.ChatInputCommandInteraction -import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder -import dev.kord.rest.builder.interaction.string - -object PvpKillFeedChannelIdParameter { - const val NAME = "pvp-kill-feed-channel-id" -} - -fun GlobalChatInputCreateBuilder.addPvpKillFeedChannelIdParameter(required: Boolean = true) { - string( - name = PvpKillFeedChannelIdParameter.NAME, - description = "The id of the channel to post the pvp kill feed in." - ) { - this.required = required - } -} - -fun ChatInputCommandInteraction.getPvpKillFeedChannelIdParameter(): String? { - return command.getChannelIdFromStringParameter(PvpKillFeedChannelIdParameter.NAME) -} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/ServerIdParameter.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/ServerIdParameter.kt new file mode 100644 index 0000000..039814c --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/ServerIdParameter.kt @@ -0,0 +1,22 @@ +package de.darkatra.vrising.discord.commands.parameters + +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder +import dev.kord.rest.builder.interaction.string + +object ServerIdParameter { + const val NAME = "server-id" +} + +fun GlobalChatInputCreateBuilder.addServerIdParameter() { + string( + name = ServerIdParameter.NAME, + description = "The id of the server." + ) { + required = true + } +} + +fun ChatInputCommandInteraction.getServerIdParameter(): String { + return command.strings[ServerIdParameter.NAME]!! +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/ServerStatusMonitorIdParameter.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/ServerStatusMonitorIdParameter.kt deleted file mode 100644 index e530e26..0000000 --- a/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/ServerStatusMonitorIdParameter.kt +++ /dev/null @@ -1,22 +0,0 @@ -package de.darkatra.vrising.discord.commands.parameters - -import dev.kord.core.entity.interaction.ChatInputCommandInteraction -import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder -import dev.kord.rest.builder.interaction.string - -object ServerStatusMonitorIdParameter { - const val NAME = "server-status-monitor-id" -} - -fun GlobalChatInputCreateBuilder.addServerStatusMonitorIdParameter() { - string( - name = ServerStatusMonitorIdParameter.NAME, - description = "The id of the server status monitor." - ) { - required = true - } -} - -fun ChatInputCommandInteraction.getServerStatusMonitorIdParameter(): String { - return command.strings[ServerStatusMonitorIdParameter.NAME]!! -} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/ServerStatusMonitorStatusParameter.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/ServerStatusMonitorStatusParameter.kt deleted file mode 100644 index 655facb..0000000 --- a/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/ServerStatusMonitorStatusParameter.kt +++ /dev/null @@ -1,26 +0,0 @@ -package de.darkatra.vrising.discord.commands.parameters - -import de.darkatra.vrising.discord.persistence.model.ServerStatusMonitorStatus -import dev.kord.core.entity.interaction.ChatInputCommandInteraction -import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder -import dev.kord.rest.builder.interaction.string - -object ServerStatusMonitorStatusParameter { - const val NAME = "status" -} - -fun GlobalChatInputCreateBuilder.addServerStatusMonitorStatusParameter(required: Boolean = true) { - string( - name = ServerStatusMonitorStatusParameter.NAME, - description = "Determines if a server status monitor should be updated or not." - ) { - this.required = required - - choice("ACTIVE", "ACTIVE") - choice("INACTIVE", "INACTIVE") - } -} - -fun ChatInputCommandInteraction.getServerStatusMonitorStatusParameter(): ServerStatusMonitorStatus? { - return command.strings[ServerStatusMonitorStatusParameter.NAME]?.let { ServerStatusMonitorStatus.valueOf(it) } -} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/StatusParameter.kt b/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/StatusParameter.kt new file mode 100644 index 0000000..1f7d59e --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/commands/parameters/StatusParameter.kt @@ -0,0 +1,26 @@ +package de.darkatra.vrising.discord.commands.parameters + +import de.darkatra.vrising.discord.persistence.model.Status +import dev.kord.core.entity.interaction.ChatInputCommandInteraction +import dev.kord.rest.builder.interaction.GlobalChatInputCreateBuilder +import dev.kord.rest.builder.interaction.string + +object StatusParameter { + const val NAME = "status" +} + +fun GlobalChatInputCreateBuilder.addStatusParameter(required: Boolean = true) { + string( + name = StatusParameter.NAME, + description = "Determines if a feature is active or not." + ) { + this.required = required + + choice("ACTIVE", "ACTIVE") + choice("INACTIVE", "INACTIVE") + } +} + +fun ChatInputCommandInteraction.getStatusParameter(): Status? { + return command.strings[StatusParameter.NAME]?.let { Status.valueOf(it) } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseHelper.kt b/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseHelper.kt new file mode 100644 index 0000000..eb7234f --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseHelper.kt @@ -0,0 +1,10 @@ +package de.darkatra.vrising.discord.migration + +import org.dizitart.no2.Nitrite +import org.dizitart.no2.collection.Document +import org.dizitart.no2.collection.NitriteId +import org.dizitart.no2.store.NitriteMap + +fun Nitrite.getNitriteMap(name: String): NitriteMap { + return store.openMap(name, NitriteId::class.java, Document::class.java) +} 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 68abb23..424f9e1 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigration.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigration.kt @@ -1,11 +1,12 @@ package de.darkatra.vrising.discord.migration -import org.dizitart.no2.Document import org.dizitart.no2.Nitrite +import org.dizitart.no2.collection.Document class DatabaseMigration( val description: String, val isApplicable: (currentSchemaVersion: SemanticVersion) -> Boolean, + val documentCollectionName: String, 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 94ebe9b..98ddfa9 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigrationService.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigrationService.kt @@ -1,11 +1,8 @@ package de.darkatra.vrising.discord.migration -import de.darkatra.vrising.discord.persistence.model.ServerStatusMonitor -import de.darkatra.vrising.discord.persistence.model.ServerStatusMonitorStatus -import org.dizitart.no2.Document +import de.darkatra.vrising.discord.persistence.model.Status import org.dizitart.no2.Nitrite -import org.dizitart.no2.objects.filters.ObjectFilters -import org.dizitart.no2.util.ObjectUtils +import org.dizitart.no2.collection.Document import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service @@ -18,130 +15,209 @@ class DatabaseMigrationService( appVersionFromPom: String, ) { - private val logger = LoggerFactory.getLogger(javaClass) - private val repository = database.getRepository(Schema::class.java) + private val logger by lazy { LoggerFactory.getLogger(javaClass) } 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 }, - documentAction = { document -> document["displayPlayerGearLevel"] = true } - ), - DatabaseMigration( - description = "Set default value for status and displayServerDescription property.", - isApplicable = { currentSchemaVersion -> currentSchemaVersion.major == 1 && currentSchemaVersion.minor <= 4 }, - documentAction = { document -> - document["status"] = ServerStatusMonitorStatus.ACTIVE.name - document["displayServerDescription"] = true - } - ), - DatabaseMigration( - description = "Remove the displayPlayerGearLevel property due to patch 0.5.42405.", - isApplicable = { currentSchemaVersion -> currentSchemaVersion.major == 1 && currentSchemaVersion.minor <= 5 }, - 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 }, - 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) + private val migrations: List + get() = listOf( + DatabaseMigration( + description = "Set default value for displayPlayerGearLevel property.", + isApplicable = { currentSchemaVersion -> currentSchemaVersion.major == 1 && currentSchemaVersion.minor <= 3 }, + documentCollectionName = "de.darkatra.vrising.discord.ServerStatusMonitor", + documentAction = { document -> document.put("displayPlayerGearLevel", true) } + ), + DatabaseMigration( + description = "Set default value for status and displayServerDescription property.", + isApplicable = { currentSchemaVersion -> currentSchemaVersion.major == 1 && currentSchemaVersion.minor <= 4 }, + documentCollectionName = "de.darkatra.vrising.discord.ServerStatusMonitor", + documentAction = { document -> + document.put("status", Status.ACTIVE.name) + document.put("displayServerDescription", true) } - oldCollection.remove(ObjectFilters.ALL) - }, - documentAction = { document -> - document["hostname"] = document["hostName"] - document["displayPlayerGearLevel"] = true - } - ), - DatabaseMigration( - description = "Set default value for version property.", - isApplicable = { currentSchemaVersion -> currentSchemaVersion.major < 2 || (currentSchemaVersion.major == 2 && currentSchemaVersion.minor <= 2) }, - documentAction = { document -> - document["version"] = Instant.now().toEpochMilli() - } - ), - DatabaseMigration( - description = "Make it possible to disable the discord embed and only use the activity or kill feed.", - isApplicable = { currentSchemaVersion -> currentSchemaVersion.major < 2 || (currentSchemaVersion.major == 2 && currentSchemaVersion.minor <= 8) }, - documentAction = { document -> - document["embedEnabled"] = true - } - ), - DatabaseMigration( - description = "Serialize error timestamp as long (epochSecond).", - isApplicable = { currentSchemaVersion -> currentSchemaVersion.major < 2 || (currentSchemaVersion.major == 2 && currentSchemaVersion.minor <= 9) }, - documentAction = { document -> - val recentErrors = document["recentErrors"] - if (recentErrors is List<*>) { - recentErrors.filterIsInstance().forEach { error -> - if (error["timestamp"] is String) { - error["timestamp"] = Instant.parse(error["timestamp"] as String).epochSecond + ), + DatabaseMigration( + description = "Remove the displayPlayerGearLevel property due to patch 0.5.42405.", + isApplicable = { currentSchemaVersion -> currentSchemaVersion.major == 1 && currentSchemaVersion.minor <= 5 }, + documentCollectionName = "de.darkatra.vrising.discord.ServerStatusMonitor", + 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.put("displayPlayerGearLevel", false) + } + ), + DatabaseMigration( + description = "Set default value for currentFailedAttempts property.", + isApplicable = { currentSchemaVersion -> currentSchemaVersion.major == 1 && currentSchemaVersion.minor <= 7 }, + documentCollectionName = "de.darkatra.vrising.discord.ServerStatusMonitor", + documentAction = { document -> document.put("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) }, + documentCollectionName = "de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitor", + databaseAction = { database -> + database.getNitriteMap("de.darkatra.vrising.discord.ServerStatusMonitor").use { oldCollection -> + database.getNitriteMap("de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitor").use { newCollection -> + oldCollection.values().forEach { document -> + newCollection.putIfAbsent(document.id, document) + } } + oldCollection.drop() } - } else { - document["recentErrors"] = emptyList() + }, + documentAction = { document -> + document.put("hostname", document["hostName"]) + document.put("displayPlayerGearLevel", true) } - } - ), - DatabaseMigration( - description = "Migrate the existing ServerStatusMonitor collection to the new collection name introduced by a package change.", - isApplicable = { currentSchemaVersion -> currentSchemaVersion.major < 2 || (currentSchemaVersion.major == 2 && currentSchemaVersion.minor <= 10 && currentSchemaVersion.patch <= 1) }, - databaseAction = { database -> - val oldCollection = database.getCollection("de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitor") - val newCollection = database.getCollection(ObjectUtils.findObjectStoreName(ServerStatusMonitor::class.java)) - oldCollection.find().forEach { document -> - newCollection.insert(document) + ), + DatabaseMigration( + description = "Set default value for version property.", + isApplicable = { currentSchemaVersion -> currentSchemaVersion.major < 2 || (currentSchemaVersion.major == 2 && currentSchemaVersion.minor <= 2) }, + documentCollectionName = "de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitor", + documentAction = { document -> + document.put("version", Instant.now().toEpochMilli()) } - oldCollection.remove(ObjectFilters.ALL) - } + ), + DatabaseMigration( + description = "Make it possible to disable the discord embed and only use the activity or kill feed.", + isApplicable = { currentSchemaVersion -> currentSchemaVersion.major < 2 || (currentSchemaVersion.major == 2 && currentSchemaVersion.minor <= 8) }, + documentCollectionName = "de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitor", + documentAction = { document -> + document.put("embedEnabled", true) + } + ), + DatabaseMigration( + description = "Serialize error timestamp as long (epochSecond).", + isApplicable = { currentSchemaVersion -> currentSchemaVersion.major < 2 || (currentSchemaVersion.major == 2 && currentSchemaVersion.minor <= 9) }, + documentCollectionName = "de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitor", + documentAction = { document -> + val recentErrors = document["recentErrors"] + if (recentErrors is List<*>) { + recentErrors.filterIsInstance().forEach { error -> + if (error["timestamp"] is String) { + error.put("timestamp", Instant.parse(error["timestamp"] as String).epochSecond) + } + } + } else { + document.put("recentErrors", emptyList()) + } + } + ), + DatabaseMigration( + description = "Migrate the existing ServerStatusMonitor collection to the new collection name introduced by a package change.", + isApplicable = { currentSchemaVersion -> currentSchemaVersion.major < 2 || (currentSchemaVersion.major == 2 && currentSchemaVersion.minor <= 10 && currentSchemaVersion.patch <= 1) }, + documentCollectionName = "de.darkatra.vrising.discord.persistence.model.ServerStatusMonitor", + databaseAction = { database -> + database.getNitriteMap("de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitor").use { oldCollection -> + database.getNitriteMap("de.darkatra.vrising.discord.persistence.model.ServerStatusMonitor").use { newCollection -> + oldCollection.values().forEach { document -> + newCollection.putIfAbsent(document.id, document) + } + } + oldCollection.drop() + } + } + ), + DatabaseMigration( + description = "Each feature now has its own nested database object. Will not migrate previous errors to the new format.", + isApplicable = { currentSchemaVersion -> currentSchemaVersion.major < 2 || (currentSchemaVersion.major == 2 && currentSchemaVersion.minor <= 10) }, + documentCollectionName = "de.darkatra.vrising.discord.persistence.model.Server", + databaseAction = { database -> + database.getNitriteMap("de.darkatra.vrising.discord.persistence.model.ServerStatusMonitor").use { oldCollection -> + database.getNitriteMap("de.darkatra.vrising.discord.persistence.model.Server").use { newCollection -> + oldCollection.values().forEach { document -> + + val server = Document.createDocument().apply { + put("id", document["id"]) + put("version_revision", 1L) + put("version_updated", Instant.ofEpochMilli(document["version"] as Long).toString()) + put("discordServerId", document["discordServerId"]) + put("hostname", document["hostname"]) + put("queryPort", document["queryPort"]) + put("apiHostname", document["apiHostname"]) + put("apiPort", document["apiPort"]) + put("apiUsername", document["apiUsername"]) + put("apiPassword", document["apiPassword"]) + + if (document["playerActivityDiscordChannelId"] != null) { + put("playerActivityFeed", Document.createDocument().apply { + put("status", Status.ACTIVE.name) + put("discordChannelId", document["playerActivityDiscordChannelId"]) + put("lastUpdated", Instant.now().toString()) + put("currentFailedAttempts", 0) + put("recentErrors", emptyList()) + }) + } + + if (document["pvpKillFeedDiscordChannelId"] != null) { + put("pvpKillFeed", Document.createDocument().apply { + put("status", Status.ACTIVE.name) + put("discordChannelId", document["pvpKillFeedDiscordChannelId"]) + put("lastUpdated", Instant.now().toString()) + put("currentFailedAttempts", 0) + put("recentErrors", emptyList()) + }) + } + + if (document["embedEnabled"] == true) { + put("statusMonitor", Document.createDocument().apply { + put("status", document["status"]) + put("discordChannelId", document["discordChannelId"]) + put("displayServerDescription", document["displayServerDescription"]) + put("displayPlayerGearLevel", document["displayPlayerGearLevel"]) + put("currentEmbedMessageId", document["currentEmbedMessageId"]) + put("currentFailedAttempts", document["currentFailedAttempts"]) + put("currentFailedApiAttempts", document["currentFailedApiAttempts"] ?: 0) + put("recentErrors", emptyList()) + }) + } + } + newCollection.putIfAbsent(server.id, server) + } + } + oldCollection.drop() + } + } + ) ) - ) fun migrateToLatestVersion(): Boolean { - // find the current version or default to V1.3.0 (the version before this feature was introduced) - val currentSchemaVersion = repository.find().toList() - .map(Schema::asSemanticVersion) - .maxWithOrNull(SemanticVersion.getComparator()) - ?: SemanticVersion(major = 1, minor = 3, patch = 0) + database.getRepository(Schema::class.java).use { schemaRepository -> - val migrationsToPerform = migrations.filter { migration -> migration.isApplicable(currentSchemaVersion) } - if (migrationsToPerform.isEmpty()) { - logger.info("No migrations need to be performed (V$currentSchemaVersion to V$currentAppVersion).") - return false - } + // find the current version or default to V1.3.0 (the version before this feature was introduced) + val currentSchemaVersion = schemaRepository.find().toList() + .map(Schema::asSemanticVersion) + .maxWithOrNull(SemanticVersion.getComparator()) + ?: SemanticVersion(major = 1, minor = 3, patch = 0) - 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 migrationsToPerform = migrations.filter { migration -> migration.isApplicable(currentSchemaVersion) } + if (migrationsToPerform.isEmpty()) { + logger.info("No migrations need to be performed (V$currentSchemaVersion to V$currentAppVersion).") + return false + } - // perform migration that affect the whole database - migrationsToPerform.forEach { migration -> migration.databaseAction(database) } + logger.info("Will migrate from V$currentSchemaVersion to V$currentAppVersion by performing ${migrationsToPerform.size} migrations.") + migrationsToPerform.forEachIndexed { index, migration -> + logger.info("* $index: ${migration.description}") + } - // 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.documentAction(document) } - collection.update(document) - } + // perform migration that affect the whole database + migrationsToPerform.forEach { migration -> + migration.databaseAction(database) - repository.insert(Schema("V$currentAppVersion")) - logger.info("Database migration from V$currentSchemaVersion to V$currentAppVersion was successful.") + database.store.openMap(migration.documentCollectionName, String::class.java, Document::class.java) + database.getNitriteMap(migration.documentCollectionName).use { collection -> + collection.values().forEach { document -> + migration.documentAction(document) + collection.put(document.id, document) + } + } + } + schemaRepository.insert(Schema("V$currentAppVersion")) + logger.info("Database migration from V$currentSchemaVersion to V$currentAppVersion was successful.") + } return true } } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/migration/Schema.kt b/src/main/kotlin/de/darkatra/vrising/discord/migration/Schema.kt index a36a7af..d65e754 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/migration/Schema.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/migration/Schema.kt @@ -1,16 +1,17 @@ package de.darkatra.vrising.discord.migration -import org.dizitart.no2.objects.Id +import org.dizitart.no2.repository.annotations.Id + +private val VERSION_PATTERN = Regex("^V(\\d+)\\.(\\d+)\\.(\\d+)").toPattern() data class Schema( @Id val appVersion: String, ) { - private val versionPattern = Regex("^V(\\d+)\\.(\\d+)\\.(\\d+)").toPattern() fun asSemanticVersion(): SemanticVersion { - val matcher = versionPattern.matcher(appVersion) + val matcher = VERSION_PATTERN.matcher(appVersion) if (!matcher.find() || matcher.groupCount() != 3) { error("Could not parse version from appVersion '$appVersion'.") } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/migration/SchemaEntityConverter.kt b/src/main/kotlin/de/darkatra/vrising/discord/migration/SchemaEntityConverter.kt new file mode 100644 index 0000000..88a5457 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/migration/SchemaEntityConverter.kt @@ -0,0 +1,22 @@ +package de.darkatra.vrising.discord.migration + +import org.dizitart.no2.collection.Document +import org.dizitart.no2.common.mapper.EntityConverter +import org.dizitart.no2.common.mapper.NitriteMapper + +class SchemaEntityConverter : EntityConverter { + + override fun getEntityType(): Class = Schema::class.java + + override fun fromDocument(document: Document, nitriteMapper: NitriteMapper): Schema { + return Schema( + appVersion = document.get("appVersion", String::class.java) + ) + } + + override fun toDocument(schema: Schema, nitriteMapper: NitriteMapper): Document { + return Document.createDocument().apply { + put("appVersion", schema.appVersion) + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/DatabaseBackupService.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/DatabaseBackupService.kt new file mode 100644 index 0000000..b3cebdb --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/DatabaseBackupService.kt @@ -0,0 +1,81 @@ +package de.darkatra.vrising.discord.persistence + +import de.darkatra.vrising.discord.BotProperties +import org.slf4j.LoggerFactory +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.stereotype.Service +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import kotlin.io.path.absolutePathString +import kotlin.io.path.copyTo +import kotlin.io.path.deleteExisting +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.isRegularFile +import kotlin.io.path.name +import kotlin.io.path.notExists +import kotlin.streams.asSequence + +@Service +@EnableConfigurationProperties(BotProperties::class) +class DatabaseBackupService( + private val botProperties: BotProperties +) { + + private val logger = LoggerFactory.getLogger(javaClass) + private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ssX").withZone(ZoneOffset.UTC) + + fun performDatabaseBackup() { + + val databaseFile = botProperties.databasePath + if (databaseFile.notExists()) { + logger.warn("Aborting backup process because the database does not exist: ${databaseFile.absolutePathString()}") + return + } + + val databaseBackupDirectory = botProperties.databaseBackupDirectory + if (databaseBackupDirectory.notExists()) { + databaseBackupDirectory.toFile().mkdirs() + } + + if (!databaseBackupDirectory.isDirectory()) { + logger.warn("Aborting backup process because the backup directory is not a directory: ${databaseBackupDirectory.absolutePathString()}") + return + } + + deleteOldBackups() + + val backupFile = databaseBackupDirectory.resolve(dateTimeFormatter.format(Instant.now())) + if (backupFile.exists()) { + logger.warn("A backup file with the same name already exists... Overwriting the existing file: ${backupFile.absolutePathString()}") + } + + botProperties.databasePath.copyTo(backupFile, overwrite = true) + + logger.info("Successfully created the database backup: ${backupFile.absolutePathString()}") + } + + fun deleteOldBackups() { + + Files.list(botProperties.databaseBackupDirectory).use { fileStream -> + fileStream.asSequence() + .filter { file -> file.isRegularFile() } + .mapNotNull { file -> + try { + Pair(file, Instant.from(dateTimeFormatter.parse(file.name))) + } catch (e: Exception) { + null + } + } + .sortedWith(Comparator.comparing, Instant> { it.second }.reversed()) + .drop(botProperties.databaseBackupMaxFiles - 1) + .forEach { (fileToDelete, _) -> + logger.info("Deleting '${fileToDelete.absolutePathString()}'...") + fileToDelete.deleteExisting() + } + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/DatabaseConfiguration.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/DatabaseConfiguration.kt index 5aaaab2..30e4605 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/persistence/DatabaseConfiguration.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/DatabaseConfiguration.kt @@ -1,11 +1,18 @@ package de.darkatra.vrising.discord.persistence import de.darkatra.vrising.discord.BotProperties +import de.darkatra.vrising.discord.migration.SchemaEntityConverter +import de.darkatra.vrising.discord.persistence.model.converter.ErrorEntityConverter +import de.darkatra.vrising.discord.persistence.model.converter.PlayerActivityFeedEntityConverter +import de.darkatra.vrising.discord.persistence.model.converter.PvpKillFeedEntityConverter +import de.darkatra.vrising.discord.persistence.model.converter.ServerEntityConverter +import de.darkatra.vrising.discord.persistence.model.converter.StatusMonitorEntityConverter import org.dizitart.no2.Nitrite +import org.dizitart.no2.mvstore.MVStoreModule import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import kotlin.io.path.absolutePathString +import java.nio.file.Path @Configuration @EnableConfigurationProperties(BotProperties::class) @@ -13,11 +20,35 @@ class DatabaseConfiguration( private val botProperties: BotProperties ) { + companion object { + + fun buildNitriteDatabase(databaseFile: Path, username: String? = null, password: String? = null): Nitrite { + + val storeModule = MVStoreModule.withConfig() + .filePath(databaseFile.toAbsolutePath().toFile()) + .compress(true) + .build() + + return Nitrite.builder() + .loadModule(storeModule) + .disableRepositoryTypeValidation() + .registerEntityConverter(SchemaEntityConverter()) + .registerEntityConverter(ErrorEntityConverter()) + .registerEntityConverter(PlayerActivityFeedEntityConverter()) + .registerEntityConverter(PvpKillFeedEntityConverter()) + .registerEntityConverter(ServerEntityConverter()) + .registerEntityConverter(StatusMonitorEntityConverter()) + .openOrCreate(username, password) + } + } + @Bean fun database(): Nitrite { - return Nitrite.builder() - .compressed() - .filePath(botProperties.databasePath.absolutePathString()) - .openOrCreate(botProperties.databaseUsername, botProperties.databasePassword) + + return buildNitriteDatabase( + botProperties.databasePath, + botProperties.databaseUsername, + botProperties.databasePassword + ) } } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/OutdatedServerException.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/OutdatedServerException.kt new file mode 100644 index 0000000..3f2b128 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/OutdatedServerException.kt @@ -0,0 +1,5 @@ +package de.darkatra.vrising.discord.persistence + +import de.darkatra.vrising.discord.BotException + +class OutdatedServerException(message: String) : BotException(message) diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/ServerRepository.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/ServerRepository.kt new file mode 100644 index 0000000..d854de3 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/ServerRepository.kt @@ -0,0 +1,107 @@ +package de.darkatra.vrising.discord.persistence + +import de.darkatra.vrising.discord.persistence.model.Server +import de.darkatra.vrising.discord.persistence.model.Version +import org.dizitart.kno2.filters.and +import org.dizitart.kno2.filters.eq +import org.dizitart.no2.Nitrite +import org.dizitart.no2.collection.FindOptions +import org.dizitart.no2.filters.Filter +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +class ServerRepository( + database: Nitrite, +) { + private val repository by lazy { database.getRepository(Server::class.java) } + + fun addServer(server: Server) { + + if (repository.find(Server::id eq server.id).any()) { + throw IllegalStateException("Server with id '${server.id}' already exists.") + } + + repository.insert(updateVersion(server)) + } + + fun updateServer(server: Server) { + + @Suppress("DEPRECATION") // this is the internal usage the warning is referring to + val serverVersion = server.version + + @Suppress("DEPRECATION") // this is the internal usage the warning is referring to + val databaseVersion = (repository.find(Server::id eq server.id).firstOrNull() + ?: throw OutdatedServerException("Server with id '${server.id}' not found.")) + .version!! + + if (serverVersion == null || databaseVersion.revision > serverVersion.revision || databaseVersion.updated > serverVersion.updated) { + throw OutdatedServerException("Server with id '${server.id}' was already updated by another thread.") + } + + repository.update(updateVersion(server)) + } + + fun removeServer(id: String, discordServerId: String? = null): Boolean { + + val filter = Server::id eq id + + if (discordServerId != null) { + filter.and(Server::discordServerId eq discordServerId) + } + + return repository.remove(filter).affectedCount > 0 + } + + fun getServer(id: String, discordServerId: String? = null): Server? { + + val filter = Server::id eq id + + if (discordServerId != null) { + filter.and(Server::discordServerId eq discordServerId) + } + + val server = repository.getById(id) ?: return null + if (discordServerId != null && server.discordServerId != discordServerId) { + return null + } + + return server + } + + fun getServers(discordServerId: String? = null, offset: Long? = null, limit: Long? = null): List { + + val filter = when { + discordServerId != null -> Server::discordServerId eq discordServerId + else -> Filter.ALL + } + + val findOptions = when { + offset != null && limit != null -> FindOptions.skipBy(offset).limit(limit) + else -> null + } + + return repository.find(filter, findOptions).toList() + } + + fun count(discordServerId: String? = null): Long { + + val filter = when { + discordServerId != null -> Server::discordServerId eq discordServerId + else -> Filter.ALL + } + + return repository.find(filter).size() + } + + private fun updateVersion(server: Server): Server { + + return server.apply { + @Suppress("DEPRECATION") // this is the internal usage the warning is referring to + version = Version( + revision = (version?.revision ?: 0) + 1, + updated = Instant.now() + ) + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/ServerStatusMonitorRepository.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/ServerStatusMonitorRepository.kt deleted file mode 100644 index 62be134..0000000 --- a/src/main/kotlin/de/darkatra/vrising/discord/persistence/ServerStatusMonitorRepository.kt +++ /dev/null @@ -1,119 +0,0 @@ -package de.darkatra.vrising.discord.persistence - -import de.darkatra.vrising.discord.persistence.model.ServerStatusMonitor -import de.darkatra.vrising.discord.persistence.model.ServerStatusMonitorStatus -import de.darkatra.vrising.discord.plus -import de.darkatra.vrising.discord.serverstatus.exceptions.OutdatedServerStatusMonitorException -import org.dizitart.kno2.filters.and -import org.dizitart.no2.FindOptions -import org.dizitart.no2.Nitrite -import org.dizitart.no2.objects.ObjectFilter -import org.dizitart.no2.objects.filters.ObjectFilters -import org.springframework.stereotype.Service -import java.time.Instant - -@Service -class ServerStatusMonitorRepository( - database: Nitrite, -) { - private var repository = database.getRepository(ServerStatusMonitor::class.java) - - fun addServerStatusMonitor(serverStatusMonitor: ServerStatusMonitor) { - - if (repository.find(ObjectFilters.eq("id", serverStatusMonitor.id)).any()) { - throw IllegalStateException("Monitor with id '${serverStatusMonitor.id}' already exists.") - } - - repository.insert(updateVersion(serverStatusMonitor)) - } - - fun updateServerStatusMonitor(serverStatusMonitor: ServerStatusMonitor) { - - @Suppress("DEPRECATION") // this is the internal usage the warning is referring to - val newVersion = serverStatusMonitor.version - - @Suppress("DEPRECATION") // this is the internal usage the warning is referring to - val databaseVersion = (repository.find(ObjectFilters.eq("id", serverStatusMonitor.id)).firstOrNull() - ?: throw OutdatedServerStatusMonitorException("Monitor with id '${serverStatusMonitor.id}' not found.")) - .version!! - - if (newVersion == null || databaseVersion > newVersion) { - throw OutdatedServerStatusMonitorException("Monitor with id '${serverStatusMonitor.id}' was already updated by another thread.") - } - - repository.update(updateVersion(serverStatusMonitor)) - } - - fun removeServerStatusMonitor(id: String, discordServerId: String? = null): Boolean { - var objectFilter: ObjectFilter = ObjectFilters.eq("id", id) - - if (discordServerId != null) { - objectFilter += ObjectFilters.eq("discordServerId", discordServerId) - } - - return repository.remove(objectFilter).affectedCount > 0 - } - - fun getServerStatusMonitor(id: String, discordServerId: String? = null): ServerStatusMonitor? { - - var objectFilter: ObjectFilter = ObjectFilters.eq("id", id) - - if (discordServerId != null) { - objectFilter += ObjectFilters.eq("discordServerId", discordServerId) - } - - return repository.find(objectFilter).firstOrNull() - } - - fun getServerStatusMonitors( - discordServerId: String? = null, - status: ServerStatusMonitorStatus? = null, - offset: Int? = null, - limit: Int? = null - ): List { - - val objectFilter = buildList { - if (discordServerId != null) { - add(ObjectFilters.eq("discordServerId", discordServerId)) - } - if (status != null) { - add(ObjectFilters.eq("status", status)) - } - }.reduceOrNull { acc: ObjectFilter, objectFilter: ObjectFilter -> acc.and(objectFilter) } - - if (offset != null && limit != null) { - - if (offset >= repository.size()) { - return emptyList() - } - - val findOptions = FindOptions.limit(offset, limit) - return when { - objectFilter != null -> repository.find(objectFilter, findOptions).toList() - else -> repository.find(findOptions).toList() - } - } - - return when { - objectFilter != null -> repository.find(objectFilter).toList() - else -> repository.find().toList() - } - } - - fun count( - discordServerId: String? = null, - ): Int { - - return when { - discordServerId != null -> repository.find(ObjectFilters.eq("discordServerId", discordServerId)).size() - else -> repository.find().size() - } - } - - private fun updateVersion(serverStatusMonitor: ServerStatusMonitor): ServerStatusMonitor { - return serverStatusMonitor.apply { - @Suppress("DEPRECATION") // this is the internal usage the warning is referring to - version = Instant.now().toEpochMilli() - } - } -} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/Error.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/Error.kt index 442f099..34fc921 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/Error.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/Error.kt @@ -1,6 +1,8 @@ package de.darkatra.vrising.discord.persistence.model +import java.time.Instant + data class Error( val message: String, - val timestamp: Long + val timestamp: Instant ) diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/ErrorAware.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/ErrorAware.kt new file mode 100644 index 0000000..9e374d0 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/ErrorAware.kt @@ -0,0 +1,25 @@ +package de.darkatra.vrising.discord.persistence.model + +import java.time.Instant + +interface ErrorAware { + + var recentErrors: List + + fun addError(throwable: Throwable, maxErrorsToKeep: Int) { + if (maxErrorsToKeep <= 0) { + return + } + recentErrors = recentErrors + .takeLast((maxErrorsToKeep - 1).coerceAtLeast(0)) + .toMutableList() + .apply { + add( + Error( + message = "${throwable::class.simpleName}: ${throwable.message}", + timestamp = Instant.now() + ) + ) + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/Leaderboard.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/Leaderboard.kt new file mode 100644 index 0000000..8855ddb --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/Leaderboard.kt @@ -0,0 +1,20 @@ +package de.darkatra.vrising.discord.persistence.model + +data class Leaderboard( + override var status: Status, + + // TODO: define the type of the leaderboard and think about other properties that should be configurable + + override var recentErrors: List = emptyList() +) : ErrorAware, ServerAware, StatusAware { + + private var server: Server? = null + + override fun getServer(): Server { + return server!! + } + + override fun setServer(server: Server) { + this.server = server + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/PlayerActivityFeed.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/PlayerActivityFeed.kt new file mode 100644 index 0000000..490c88e --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/PlayerActivityFeed.kt @@ -0,0 +1,24 @@ +package de.darkatra.vrising.discord.persistence.model + +import java.time.Instant + +data class PlayerActivityFeed( + override var status: Status, + var discordChannelId: String, + var lastUpdated: Instant, + + var currentFailedAttempts: Int = 0, + + override var recentErrors: List = emptyList() +) : ErrorAware, ServerAware, StatusAware { + + private var server: Server? = null + + override fun getServer(): Server { + return server!! + } + + override fun setServer(server: Server) { + this.server = server + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/PvpKillFeed.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/PvpKillFeed.kt new file mode 100644 index 0000000..2eef8d7 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/PvpKillFeed.kt @@ -0,0 +1,24 @@ +package de.darkatra.vrising.discord.persistence.model + +import java.time.Instant + +data class PvpKillFeed( + override var status: Status, + var discordChannelId: String, + var lastUpdated: Instant, + + var currentFailedAttempts: Int = 0, + + override var recentErrors: List = emptyList() +) : ErrorAware, ServerAware, StatusAware { + + private var server: Server? = null + + override fun getServer(): Server { + return server!! + } + + override fun setServer(server: Server) { + this.server = server + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/Server.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/Server.kt new file mode 100644 index 0000000..2fcd5d3 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/Server.kt @@ -0,0 +1,59 @@ +package de.darkatra.vrising.discord.persistence.model + +import org.dizitart.no2.index.IndexType +import org.dizitart.no2.repository.annotations.Id +import org.dizitart.no2.repository.annotations.Index +import org.dizitart.no2.repository.annotations.Indices +import java.time.Instant + +@Indices( + value = [ + Index(fields = ["discordServerId"], type = IndexType.NON_UNIQUE), + ] +) +data class Server( + @Id + val id: String, + @Deprecated("This field is updated automatically by the ServerRepository, manually update with caution") + internal var version: Version? = null, + var discordServerId: String, + + var hostname: String, + var queryPort: Int, + + var apiHostname: String? = null, + var apiPort: Int? = null, + var apiUsername: String? = null, + var apiPassword: String? = null, + + var playerActivityFeed: PlayerActivityFeed? = null, + var pvpKillFeed: PvpKillFeed? = null, + var statusMonitor: StatusMonitor? = null, + + // TODO: decide on the way to store leaderboards + // option 1: store all leaderboards as a List and create commands that allow CRUD operations on that list + // this allows users to create more than one leaderboard per "type" (which might not be good - idk) + // option 2: store leaderboards in specific fields, such as `pvpLeaderboard` or `soulShardLeaderboard` + // this prevents users from having more than one leaderboard per type and probably also simplifies the commands + // a little bit by not introducing another id + var pvpLeaderboard: Leaderboard? = null +) : StatusAware { + + val apiEnabled: Boolean + get() = apiHostname != null && apiPort != null + + @Suppress("DEPRECATION") // this is the internal usage the warning is referring to + val lastUpdated: Instant + get() = version!!.updated + + override val status: Status + get() { + return when (playerActivityFeed?.status == Status.ACTIVE + || statusMonitor?.status == Status.ACTIVE + || pvpKillFeed?.status == Status.ACTIVE + || pvpLeaderboard?.status == Status.ACTIVE) { + true -> Status.ACTIVE + false -> Status.INACTIVE + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/ServerAware.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/ServerAware.kt new file mode 100644 index 0000000..84b5aa0 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/ServerAware.kt @@ -0,0 +1,6 @@ +package de.darkatra.vrising.discord.persistence.model + +interface ServerAware { + fun getServer(): Server + fun setServer(server: Server) +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/ServerStatusMonitor.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/ServerStatusMonitor.kt deleted file mode 100644 index 425ed59..0000000 --- a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/ServerStatusMonitor.kt +++ /dev/null @@ -1,67 +0,0 @@ -package de.darkatra.vrising.discord.persistence.model - -import org.dizitart.no2.IndexType -import org.dizitart.no2.objects.Id -import org.dizitart.no2.objects.Index -import org.dizitart.no2.objects.Indices -import java.time.Instant - -@Indices( - value = [ - Index(value = "discordServerId", type = IndexType.NonUnique), - Index(value = "status", type = IndexType.NonUnique) - ] -) -data class ServerStatusMonitor( - @Id - val id: String, - @Deprecated("This field is updated automatically by the ServerStatusMonitorRepository, manually update with caution") - var version: Long? = null, - - var discordServerId: String, - var discordChannelId: String, - var playerActivityDiscordChannelId: String? = null, - var pvpKillFeedDiscordChannelId: String? = null, - - var hostname: String, - var queryPort: Int, - - var apiHostname: String? = null, - var apiPort: Int? = null, - var apiUsername: String? = null, - var apiPassword: String? = null, - - var status: ServerStatusMonitorStatus, - - var embedEnabled: Boolean = true, - var displayServerDescription: Boolean, - var displayPlayerGearLevel: Boolean, - - var currentEmbedMessageId: String? = null, - var currentFailedAttempts: Int = 0, - var currentFailedApiAttempts: Int = 0, - - var recentErrors: List = emptyList() -) { - - val apiEnabled: Boolean - get() = apiHostname != null && apiPort != null - - @Suppress("DEPRECATION") // this is the internal usage the warning is referring to - val lastUpdated: Instant - get() = Instant.ofEpochMilli(version!!) - - fun addError(throwable: Throwable, maxErrorsToKeep: Int) { - recentErrors = recentErrors - .takeLast((maxErrorsToKeep - 1).coerceAtLeast(0)) - .toMutableList() - .apply { - add( - Error( - message = "${throwable::class.simpleName}: ${throwable.message}", - timestamp = Instant.now().epochSecond - ) - ) - } - } -} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/ServerStatusMonitorStatus.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/Status.kt similarity index 67% rename from src/main/kotlin/de/darkatra/vrising/discord/persistence/model/ServerStatusMonitorStatus.kt rename to src/main/kotlin/de/darkatra/vrising/discord/persistence/model/Status.kt index 56e1a06..10e16c5 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/ServerStatusMonitorStatus.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/Status.kt @@ -1,6 +1,6 @@ package de.darkatra.vrising.discord.persistence.model -enum class ServerStatusMonitorStatus { +enum class Status { INACTIVE, ACTIVE } diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/StatusAware.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/StatusAware.kt new file mode 100644 index 0000000..05a45e7 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/StatusAware.kt @@ -0,0 +1,17 @@ +package de.darkatra.vrising.discord.persistence.model + +interface StatusAware { + val status: Status +} + +fun Iterable.filterActive(): List { + return filter { + it.status == Status.ACTIVE + } +} + +fun Iterable.filterInactive(): List { + return filter { + it.status == Status.INACTIVE + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/StatusMonitor.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/StatusMonitor.kt new file mode 100644 index 0000000..63e8f38 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/StatusMonitor.kt @@ -0,0 +1,26 @@ +package de.darkatra.vrising.discord.persistence.model + +data class StatusMonitor( + override var status: Status, + var discordChannelId: String, + + var displayServerDescription: Boolean, + var displayPlayerGearLevel: Boolean, + + var currentEmbedMessageId: String? = null, + var currentFailedAttempts: Int = 0, + var currentFailedApiAttempts: Int = 0, + + override var recentErrors: List = emptyList() +) : ErrorAware, ServerAware, StatusAware { + + private var server: Server? = null + + override fun getServer(): Server { + return server!! + } + + override fun setServer(server: Server) { + this.server = server + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/Version.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/Version.kt new file mode 100644 index 0000000..8e66954 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/Version.kt @@ -0,0 +1,8 @@ +package de.darkatra.vrising.discord.persistence.model + +import java.time.Instant + +data class Version( + val revision: Long, + val updated: Instant +) diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/converter/ErrorEntityConverter.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/converter/ErrorEntityConverter.kt new file mode 100644 index 0000000..61d82f0 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/converter/ErrorEntityConverter.kt @@ -0,0 +1,26 @@ +package de.darkatra.vrising.discord.persistence.model.converter + +import de.darkatra.vrising.discord.persistence.model.Error +import org.dizitart.no2.collection.Document +import org.dizitart.no2.common.mapper.EntityConverter +import org.dizitart.no2.common.mapper.NitriteMapper +import java.time.Instant + +class ErrorEntityConverter : EntityConverter { + + override fun getEntityType(): Class = Error::class.java + + override fun fromDocument(document: Document, nitriteMapper: NitriteMapper): Error { + return Error( + message = document.get(Error::message.name, String::class.java), + timestamp = Instant.parse(document.get(Error::timestamp.name, String::class.java)) + ) + } + + override fun toDocument(error: Error, nitriteMapper: NitriteMapper): Document { + return Document.createDocument().apply { + put(Error::message.name, error.message) + put(Error::timestamp.name, error.timestamp.toString()) + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/converter/PlayerActivityFeedEntityConverter.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/converter/PlayerActivityFeedEntityConverter.kt new file mode 100644 index 0000000..5679c94 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/converter/PlayerActivityFeedEntityConverter.kt @@ -0,0 +1,38 @@ +package de.darkatra.vrising.discord.persistence.model.converter + +import de.darkatra.vrising.discord.persistence.model.Error +import de.darkatra.vrising.discord.persistence.model.PlayerActivityFeed +import de.darkatra.vrising.discord.persistence.model.Status +import org.dizitart.no2.collection.Document +import org.dizitart.no2.common.mapper.EntityConverter +import org.dizitart.no2.common.mapper.NitriteMapper +import java.time.Instant + +class PlayerActivityFeedEntityConverter : EntityConverter { + + override fun getEntityType(): Class = PlayerActivityFeed::class.java + + override fun fromDocument(document: Document, nitriteMapper: NitriteMapper): PlayerActivityFeed { + return PlayerActivityFeed( + status = Status.valueOf(document.get(PlayerActivityFeed::status.name, String::class.java)), + discordChannelId = document.get(PlayerActivityFeed::discordChannelId.name, String::class.java), + lastUpdated = document.get(PlayerActivityFeed::lastUpdated.name, String::class.java).let(Instant::parse), + currentFailedAttempts = document.get(PlayerActivityFeed::currentFailedAttempts.name) as Int, + recentErrors = (document.get(PlayerActivityFeed::recentErrors.name) as List<*>).map { error -> + nitriteMapper.tryConvert(error, Error::class.java) as Error + } + ) + } + + override fun toDocument(playerActivityFeed: PlayerActivityFeed, nitriteMapper: NitriteMapper): Document { + return Document.createDocument().apply { + put(PlayerActivityFeed::status.name, playerActivityFeed.status.name) + put(PlayerActivityFeed::discordChannelId.name, playerActivityFeed.discordChannelId) + put(PlayerActivityFeed::lastUpdated.name, playerActivityFeed.lastUpdated.toString()) + put(PlayerActivityFeed::currentFailedAttempts.name, playerActivityFeed.currentFailedAttempts) + put(PlayerActivityFeed::recentErrors.name, playerActivityFeed.recentErrors.map { error -> + nitriteMapper.tryConvert(error, Document::class.java) + }) + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/converter/PvpKillFeedEntityConverter.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/converter/PvpKillFeedEntityConverter.kt new file mode 100644 index 0000000..ec525b1 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/converter/PvpKillFeedEntityConverter.kt @@ -0,0 +1,38 @@ +package de.darkatra.vrising.discord.persistence.model.converter + +import de.darkatra.vrising.discord.persistence.model.Error +import de.darkatra.vrising.discord.persistence.model.PvpKillFeed +import de.darkatra.vrising.discord.persistence.model.Status +import org.dizitart.no2.collection.Document +import org.dizitart.no2.common.mapper.EntityConverter +import org.dizitart.no2.common.mapper.NitriteMapper +import java.time.Instant + +class PvpKillFeedEntityConverter : EntityConverter { + + override fun getEntityType(): Class = PvpKillFeed::class.java + + override fun fromDocument(document: Document, nitriteMapper: NitriteMapper): PvpKillFeed { + return PvpKillFeed( + status = Status.valueOf(document.get(PvpKillFeed::status.name, String::class.java)), + discordChannelId = document.get(PvpKillFeed::discordChannelId.name, String::class.java), + lastUpdated = document.get(PvpKillFeed::lastUpdated.name, String::class.java).let(Instant::parse), + currentFailedAttempts = document.get(PvpKillFeed::currentFailedAttempts.name) as Int, + recentErrors = (document.get(PvpKillFeed::recentErrors.name) as List<*>).map { error -> + nitriteMapper.tryConvert(error, Error::class.java) as Error + } + ) + } + + override fun toDocument(pvpKillFeed: PvpKillFeed, nitriteMapper: NitriteMapper): Document { + return Document.createDocument().apply { + put(PvpKillFeed::status.name, pvpKillFeed.status.name) + put(PvpKillFeed::discordChannelId.name, pvpKillFeed.discordChannelId) + put(PvpKillFeed::lastUpdated.name, pvpKillFeed.lastUpdated.toString()) + put(PvpKillFeed::currentFailedAttempts.name, pvpKillFeed.currentFailedAttempts) + put(PvpKillFeed::recentErrors.name, pvpKillFeed.recentErrors.map { error -> + nitriteMapper.tryConvert(error, Document::class.java) + }) + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/converter/ServerEntityConverter.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/converter/ServerEntityConverter.kt new file mode 100644 index 0000000..c14a58f --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/converter/ServerEntityConverter.kt @@ -0,0 +1,88 @@ +package de.darkatra.vrising.discord.persistence.model.converter + +import de.darkatra.vrising.discord.persistence.model.Leaderboard +import de.darkatra.vrising.discord.persistence.model.PlayerActivityFeed +import de.darkatra.vrising.discord.persistence.model.PvpKillFeed +import de.darkatra.vrising.discord.persistence.model.Server +import de.darkatra.vrising.discord.persistence.model.StatusMonitor +import de.darkatra.vrising.discord.persistence.model.Version +import org.dizitart.no2.collection.Document +import org.dizitart.no2.common.mapper.EntityConverter +import org.dizitart.no2.common.mapper.NitriteMapper +import java.time.Instant + +class ServerEntityConverter : EntityConverter { + + override fun getEntityType(): Class = Server::class.java + + override fun fromDocument(document: Document, nitriteMapper: NitriteMapper): Server { + @Suppress("DEPRECATION") + return Server( + id = document.get(Server::id.name, String::class.java), + version = Version( + revision = document.get(Server::version.name + "_" + Version::revision.name) as Long, + updated = Instant.parse(document.get(Server::version.name + "_" + Version::updated.name, String::class.java)), + ), + discordServerId = document.get(Server::discordServerId.name, String::class.java), + hostname = document.get(Server::hostname.name, String::class.java), + queryPort = document.get(Server::queryPort.name) as Int, + apiHostname = document.get(Server::apiHostname.name, String::class.java), + apiPort = document.get(Server::apiPort.name) as Int?, + apiUsername = document.get(Server::apiUsername.name, String::class.java), + apiPassword = document.get(Server::apiPassword.name, String::class.java), + playerActivityFeed = document.get(Server::playerActivityFeed.name)?.let { playerActivityFeed -> + nitriteMapper.tryConvert(playerActivityFeed, PlayerActivityFeed::class.java) as PlayerActivityFeed + }, + pvpKillFeed = document.get(Server::pvpKillFeed.name)?.let { pvpKillFeed -> + nitriteMapper.tryConvert(pvpKillFeed, PvpKillFeed::class.java) as PvpKillFeed + }, + statusMonitor = document.get(Server::statusMonitor.name)?.let { statusMonitor -> + nitriteMapper.tryConvert(statusMonitor, StatusMonitor::class.java) as StatusMonitor + }, + pvpLeaderboard = document.get(Server::pvpLeaderboard.name)?.let { pvpLeaderboard -> + nitriteMapper.tryConvert(pvpLeaderboard, Leaderboard::class.java) as Leaderboard + } + ).also { server -> + server.playerActivityFeed?.setServer(server) + server.pvpKillFeed?.setServer(server) + server.statusMonitor?.setServer(server) + server.pvpLeaderboard?.setServer(server) + } + } + + override fun toDocument(server: Server, nitriteMapper: NitriteMapper): Document { + @Suppress("DEPRECATION") + return Document.createDocument().apply { + put(Server::id.name, server.id) + put(Server::version.name + "_" + Version::revision.name, server.version!!.revision) + put(Server::version.name + "_" + Version::updated.name, server.version!!.updated.toString()) + put(Server::discordServerId.name, server.discordServerId) + put(Server::hostname.name, server.hostname) + put(Server::queryPort.name, server.queryPort) + server.apiHostname?.let { apiHostname -> + put(Server::apiHostname.name, apiHostname) + } + server.apiPort?.let { apiPort -> + put(Server::apiPort.name, apiPort) + } + server.apiUsername?.let { apiUsername -> + put(Server::apiUsername.name, apiUsername) + } + server.apiPassword?.let { apiPassword -> + put(Server::apiPassword.name, apiPassword) + } + server.playerActivityFeed?.let { playerActivityFeed -> + put(Server::playerActivityFeed.name, nitriteMapper.tryConvert(playerActivityFeed, Document::class.java)) + } + server.pvpKillFeed?.let { pvpKillFeed -> + put(Server::pvpKillFeed.name, nitriteMapper.tryConvert(pvpKillFeed, Document::class.java)) + } + server.statusMonitor?.let { statusMonitor -> + put(Server::statusMonitor.name, nitriteMapper.tryConvert(statusMonitor, Document::class.java)) + } + server.pvpLeaderboard?.let { pvpLeaderboard -> + put(Server::pvpLeaderboard.name, nitriteMapper.tryConvert(pvpLeaderboard, Document::class.java)) + } + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/converter/StatusMonitorEntityConverter.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/converter/StatusMonitorEntityConverter.kt new file mode 100644 index 0000000..e3bfdd0 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/model/converter/StatusMonitorEntityConverter.kt @@ -0,0 +1,45 @@ +package de.darkatra.vrising.discord.persistence.model.converter + +import de.darkatra.vrising.discord.persistence.model.Error +import de.darkatra.vrising.discord.persistence.model.Status +import de.darkatra.vrising.discord.persistence.model.StatusMonitor +import org.dizitart.no2.collection.Document +import org.dizitart.no2.common.mapper.EntityConverter +import org.dizitart.no2.common.mapper.NitriteMapper + +class StatusMonitorEntityConverter : EntityConverter { + + override fun getEntityType(): Class = StatusMonitor::class.java + + override fun fromDocument(document: Document, nitriteMapper: NitriteMapper): StatusMonitor { + return StatusMonitor( + status = Status.valueOf(document.get(StatusMonitor::status.name, String::class.java)), + discordChannelId = document.get(StatusMonitor::discordChannelId.name, String::class.java), + displayServerDescription = document.get(StatusMonitor::displayServerDescription.name) as Boolean, + displayPlayerGearLevel = document.get(StatusMonitor::displayPlayerGearLevel.name) as Boolean, + currentEmbedMessageId = document.get(StatusMonitor::currentEmbedMessageId.name, String::class.java), + currentFailedAttempts = document.get(StatusMonitor::currentFailedAttempts.name) as Int, + currentFailedApiAttempts = document.get(StatusMonitor::currentFailedApiAttempts.name) as Int, + recentErrors = (document.get(StatusMonitor::recentErrors.name) as List<*>).map { error -> + nitriteMapper.tryConvert(error, Error::class.java) as Error + } + ) + } + + override fun toDocument(statusMonitor: StatusMonitor, nitriteMapper: NitriteMapper): Document { + return Document.createDocument().apply { + put(StatusMonitor::status.name, statusMonitor.status.name) + put(StatusMonitor::discordChannelId.name, statusMonitor.discordChannelId) + put(StatusMonitor::displayServerDescription.name, statusMonitor.displayServerDescription) + put(StatusMonitor::displayPlayerGearLevel.name, statusMonitor.displayPlayerGearLevel) + statusMonitor.currentEmbedMessageId?.let { currentEmbedMessageId -> + put(StatusMonitor::currentEmbedMessageId.name, currentEmbedMessageId) + } + put(StatusMonitor::currentFailedAttempts.name, statusMonitor.currentFailedAttempts) + put(StatusMonitor::currentFailedApiAttempts.name, statusMonitor.currentFailedApiAttempts) + put(StatusMonitor::recentErrors.name, statusMonitor.recentErrors.map { error -> + nitriteMapper.tryConvert(error, Document::class.java) + }) + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/PlayerActivityFeedService.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/PlayerActivityFeedService.kt new file mode 100644 index 0000000..e20f93f --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/PlayerActivityFeedService.kt @@ -0,0 +1,106 @@ +package de.darkatra.vrising.discord.serverstatus + +import de.darkatra.vrising.discord.BotProperties +import de.darkatra.vrising.discord.InvalidDiscordChannelException +import de.darkatra.vrising.discord.clients.botcompanion.BotCompanionClient +import de.darkatra.vrising.discord.clients.botcompanion.model.PlayerActivity +import de.darkatra.vrising.discord.commands.ConfigurePlayerActivityFeedCommand +import de.darkatra.vrising.discord.getDiscordChannel +import de.darkatra.vrising.discord.persistence.model.PlayerActivityFeed +import de.darkatra.vrising.discord.persistence.model.Status +import de.darkatra.vrising.discord.tryCreateMessage +import dev.kord.core.Kord +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +class PlayerActivityFeedService( + private val botProperties: BotProperties, + private val botCompanionClient: BotCompanionClient, + private val configurePlayerActivityFeedCommand: ConfigurePlayerActivityFeedCommand +) { + + private val logger by lazy { LoggerFactory.getLogger(javaClass) } + + suspend fun updatePlayerActivityFeed(kord: Kord, playerActivityFeed: PlayerActivityFeed) { + + if (!playerActivityFeed.getServer().apiEnabled) { + logger.debug("Skipping player activity feed update for server '${playerActivityFeed.getServer().id}' because apiEnabled is false.") + return + } + + logger.debug("Attempting to update the player activity feed for server '${playerActivityFeed.getServer().id}'...") + + val playerActivityChannel = kord.getDiscordChannel(playerActivityFeed.discordChannelId).getOrElse { e -> + when (e) { + is InvalidDiscordChannelException -> { + logger.debug("Disabling player activity feed for server '${playerActivityFeed.getServer().id}' because the channel '${playerActivityFeed.discordChannelId}' does not seem to exist.") + playerActivityFeed.status = Status.INACTIVE + } + + else -> { + playerActivityFeed.currentFailedAttempts += 1 + playerActivityFeed.addError(e, botProperties.maxRecentErrors) + disablePlayerActivityFeedIfNecessary(playerActivityFeed) + } + } + return + } + + val playerActivities = botCompanionClient.getPlayerActivities( + playerActivityFeed.getServer().apiHostname!!, + playerActivityFeed.getServer().apiPort!!, + playerActivityFeed.getServer().apiUsername, + playerActivityFeed.getServer().apiPassword + ).getOrElse { e -> + + logger.error("Exception updating the player activity feed for server '${playerActivityFeed.getServer().id}'", e) + playerActivityFeed.currentFailedAttempts += 1 + playerActivityFeed.addError(e, botProperties.maxRecentErrors) + + disablePlayerActivityFeedIfNecessary(playerActivityFeed) { + playerActivityChannel.tryCreateMessage( + """Disabled the player activity feed for server '${playerActivityFeed.getServer().id}' because + |the bot companion did not respond successfully after ${botProperties.maxFailedApiAttempts} attempts. + |Please make sure the server-api-hostname and server-api-port are correct. + |You can re-enable this functionality using the ${configurePlayerActivityFeedCommand.getCommandName()} command.""".trimMargin() + ) + } + + return + } + + playerActivityFeed.currentFailedAttempts = 0 + + playerActivities + .filter { playerActivity -> playerActivity.occurred.isAfter(playerActivityFeed.lastUpdated) } + .sortedWith(Comparator.comparing(PlayerActivity::occurred)) + .forEach { playerActivity -> + val action = when (playerActivity.type) { + PlayerActivity.Type.CONNECTED -> "joined" + PlayerActivity.Type.DISCONNECTED -> "left" + } + try { + playerActivityChannel.createMessage( + ": ${playerActivity.playerName} $action the server." + ) + } catch (e: Exception) { + logger.warn("Could not post player activity feed message for server '${playerActivityFeed.getServer().id}'.", e) + } + } + + playerActivityFeed.lastUpdated = Instant.now() + + logger.debug("Successfully updated the player activity feed for server '${playerActivityFeed.getServer().id}'.") + } + + private suspend fun disablePlayerActivityFeedIfNecessary(playerActivityFeed: PlayerActivityFeed, block: suspend () -> Unit = {}) { + + if (botProperties.maxFailedApiAttempts != 0 && playerActivityFeed.currentFailedAttempts >= botProperties.maxFailedApiAttempts) { + logger.warn("Disabling the player activity feed for server '${playerActivityFeed.getServer().id}' because it exceeded the max failed api attempts.") + playerActivityFeed.status = Status.INACTIVE + block() + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/PvpKillFeedService.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/PvpKillFeedService.kt new file mode 100644 index 0000000..61e95a9 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/PvpKillFeedService.kt @@ -0,0 +1,101 @@ +package de.darkatra.vrising.discord.serverstatus + +import de.darkatra.vrising.discord.BotProperties +import de.darkatra.vrising.discord.InvalidDiscordChannelException +import de.darkatra.vrising.discord.clients.botcompanion.BotCompanionClient +import de.darkatra.vrising.discord.clients.botcompanion.model.PvpKill +import de.darkatra.vrising.discord.commands.ConfigurePvpKillFeedCommand +import de.darkatra.vrising.discord.getDiscordChannel +import de.darkatra.vrising.discord.persistence.model.PvpKillFeed +import de.darkatra.vrising.discord.persistence.model.Status +import de.darkatra.vrising.discord.tryCreateMessage +import dev.kord.core.Kord +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +class PvpKillFeedService( + private val botProperties: BotProperties, + private val botCompanionClient: BotCompanionClient, + private val configurePvpKillFeedCommand: ConfigurePvpKillFeedCommand +) { + + private val logger by lazy { LoggerFactory.getLogger(javaClass) } + + suspend fun updatePvpKillFeed(kord: Kord, pvpKillFeed: PvpKillFeed) { + + if (!pvpKillFeed.getServer().apiEnabled) { + logger.debug("Skipping pvp kill feed update for server '${pvpKillFeed.getServer().id}' because apiEnabled is false.") + return + } + + logger.debug("Attempting to update the pvp kill feed for server '${pvpKillFeed.getServer().id}'...") + + val pvpKillFeedChannel = kord.getDiscordChannel(pvpKillFeed.discordChannelId).getOrElse { e -> + when (e) { + is InvalidDiscordChannelException -> { + logger.debug("Disabling pvp kill feed for server '${pvpKillFeed.getServer().id}' because the channel '${pvpKillFeed.discordChannelId}' does not seem to exist.") + pvpKillFeed.status = Status.INACTIVE + } + + else -> { + pvpKillFeed.currentFailedAttempts += 1 + pvpKillFeed.addError(e, botProperties.maxRecentErrors) + disablePvpKillFeedIfNecessary(pvpKillFeed) + } + } + return + } + + val pvpKills = botCompanionClient.getPvpKills( + pvpKillFeed.getServer().apiHostname!!, + pvpKillFeed.getServer().apiPort!!, + pvpKillFeed.getServer().apiUsername, + pvpKillFeed.getServer().apiPassword + ).getOrElse { e -> + + logger.error("Exception updating the pvp kill feed for server ${pvpKillFeed.getServer().id}", e) + pvpKillFeed.currentFailedAttempts += 1 + pvpKillFeed.addError(e, botProperties.maxRecentErrors) + + disablePvpKillFeedIfNecessary(pvpKillFeed) { + pvpKillFeedChannel.tryCreateMessage( + """Disabled the pvp kill feed for server '${pvpKillFeed.getServer().id}' because + |the bot companion did not respond successfully after ${botProperties.maxFailedApiAttempts} attempts. + |Please make sure the server-api-hostname and server-api-port are correct. + |You can re-enable this functionality using the ${configurePvpKillFeedCommand.getCommandName()} command.""".trimMargin() + ) + } + return + } + + pvpKillFeed.currentFailedAttempts = 0 + + pvpKills + .filter { pvpKill -> pvpKill.occurred.isAfter(pvpKillFeed.lastUpdated) } + .sortedWith(Comparator.comparing(PvpKill::occurred)) + .forEach { pvpKill -> + try { + pvpKillFeedChannel.createMessage( + ": ${pvpKill.killer.name} (${pvpKill.killer.gearLevel}) killed ${pvpKill.victim.name} (${pvpKill.victim.gearLevel})." + ) + } catch (e: Exception) { + logger.warn("Could not post pvp kill feed message for server '${pvpKillFeed.getServer().id}'.", e) + } + } + + pvpKillFeed.lastUpdated = Instant.now() + + logger.debug("Successfully updated the pvp kill feed for server '${pvpKillFeed.getServer().id}'.") + } + + private suspend fun disablePvpKillFeedIfNecessary(pvpKillFeed: PvpKillFeed, block: suspend () -> Unit = {}) { + + if (botProperties.maxFailedApiAttempts != 0 && pvpKillFeed.currentFailedAttempts >= botProperties.maxFailedApiAttempts) { + logger.warn("Disabling the pvp kill feed for server '${pvpKillFeed.getServer().id}' because it exceeded the max failed api attempts.") + pvpKillFeed.status = Status.INACTIVE + block() + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerService.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerService.kt new file mode 100644 index 0000000..e9acff2 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerService.kt @@ -0,0 +1,97 @@ +package de.darkatra.vrising.discord.serverstatus + +import de.darkatra.vrising.discord.persistence.OutdatedServerException +import de.darkatra.vrising.discord.persistence.ServerRepository +import de.darkatra.vrising.discord.persistence.model.Server +import de.darkatra.vrising.discord.persistence.model.Status +import de.darkatra.vrising.discord.persistence.model.filterActive +import de.darkatra.vrising.discord.persistence.model.filterInactive +import dev.kord.core.Kord +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import org.springframework.stereotype.Service +import java.time.Instant +import java.time.temporal.ChronoUnit + +@Service +class ServerService( + private val serverRepository: ServerRepository, + private val statusMonitorService: StatusMonitorService, + private val playerActivityFeedService: PlayerActivityFeedService, + private val pvpKillFeedService: PvpKillFeedService +) { + + private val logger by lazy { LoggerFactory.getLogger(javaClass) } + + suspend fun updateServers(kord: Kord) { + + val activeServers = serverRepository.getServers().filterActive() + activeServers.forEach { server -> + + MDC.put("server-id", server.id) + + updateStatusMonitor(kord, server) + updatePlayerActivityFeed(kord, server) + updatePvpKillFeed(kord, server) + + try { + serverRepository.updateServer(server) + } catch (e: OutdatedServerException) { + logger.debug("Server was updated or deleted by another thread. Will ignore this exception and proceed with the next server.", e) + } + + MDC.clear() + } + } + + suspend fun cleanupInactiveServers(kord: Kord) { + + val inactiveServers = serverRepository.getServers().filterInactive() + if (inactiveServers.isEmpty()) { + logger.info("No inactive servers to clean up.") + return + } + + val sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS) + inactiveServers.forEach { server -> + if (server.lastUpdated.isBefore(sevenDaysAgo)) { + serverRepository.removeServer(server.id) + } + } + + logger.info("Successfully removed ${inactiveServers.count()} servers with no active feature.") + } + + private suspend fun updateStatusMonitor(kord: Kord, server: Server) { + + val statusMonitor = server.statusMonitor + if (statusMonitor == null || statusMonitor.status == Status.INACTIVE) { + logger.debug("No active status monitor to update for server '${server.id}'.") + return + } + + statusMonitorService.updateStatusMonitor(kord, statusMonitor) + } + + private suspend fun updatePlayerActivityFeed(kord: Kord, server: Server) { + + val playerActivityFeed = server.playerActivityFeed + if (playerActivityFeed == null || playerActivityFeed.status == Status.INACTIVE) { + logger.debug("No active player activity feed to update for server '${server.id}'.") + return + } + + playerActivityFeedService.updatePlayerActivityFeed(kord, playerActivityFeed) + } + + private suspend fun updatePvpKillFeed(kord: Kord, server: Server) { + + val pvpKillFeed = server.pvpKillFeed + if (pvpKillFeed == null || pvpKillFeed.status == Status.INACTIVE) { + logger.debug("No active pvp kill feed to update for server '${server.id}'.") + return + } + + pvpKillFeedService.updatePvpKillFeed(kord, pvpKillFeed) + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusEmbed.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusEmbed.kt index d6d3cb1..0ca03c3 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusEmbed.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusEmbed.kt @@ -54,9 +54,9 @@ object ServerStatusEmbed { // days-running -> for how many days the server has been running in in-game days (pre 0.5.42553) val currentDay = serverInfo.rules["days-runningv2"] field { - name = when (currentDay != null) { - true -> "Days running" - false -> "Ingame days" + name = when { + currentDay != null -> "Days running" + else -> "Ingame days" } // fallback to the old field for older servers and "-" if both fields are absent value = "${currentDay ?: serverInfo.rules["days-running"] ?: "-"}" diff --git a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusMonitorService.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusMonitorService.kt deleted file mode 100644 index a8e8b6c..0000000 --- a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusMonitorService.kt +++ /dev/null @@ -1,334 +0,0 @@ -package de.darkatra.vrising.discord.serverstatus - -import de.darkatra.vrising.discord.BotProperties -import de.darkatra.vrising.discord.clients.botcompanion.BotCompanionClient -import de.darkatra.vrising.discord.clients.botcompanion.model.PlayerActivity -import de.darkatra.vrising.discord.clients.botcompanion.model.PvpKill -import de.darkatra.vrising.discord.clients.serverquery.ServerQueryClient -import de.darkatra.vrising.discord.getDiscordChannel -import de.darkatra.vrising.discord.persistence.ServerStatusMonitorRepository -import de.darkatra.vrising.discord.persistence.model.ServerStatusMonitor -import de.darkatra.vrising.discord.persistence.model.ServerStatusMonitorStatus -import de.darkatra.vrising.discord.serverstatus.exceptions.OutdatedServerStatusMonitorException -import de.darkatra.vrising.discord.serverstatus.model.ServerInfo -import dev.kord.common.entity.Snowflake -import dev.kord.core.Kord -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.embed -import org.slf4j.LoggerFactory -import org.slf4j.MDC -import org.springframework.http.client.ClientHttpRequestInterceptor -import org.springframework.http.client.support.BasicAuthenticationInterceptor -import org.springframework.stereotype.Service -import java.time.Instant -import java.time.temporal.ChronoUnit - -@Service -class ServerStatusMonitorService( - private val serverStatusMonitorRepository: ServerStatusMonitorRepository, - private val serverQueryClient: ServerQueryClient, - private val botCompanionClient: BotCompanionClient, - private val botProperties: BotProperties -) { - - private val logger = LoggerFactory.getLogger(javaClass) - - suspend fun updateServerStatusMonitors(kord: Kord) { - - serverStatusMonitorRepository.getServerStatusMonitors(status = ServerStatusMonitorStatus.ACTIVE).forEach { serverStatusMonitor -> - - MDC.put("server-status-monitor-id", serverStatusMonitor.id) - - try { - updateServerStatusMonitor(kord, serverStatusMonitor) - updatePlayerActivityFeed(kord, serverStatusMonitor) - updatePvpKillFeed(kord, serverStatusMonitor) - } catch (e: Exception) { - logger.error("Unhandled error updating status monitor '${serverStatusMonitor.id}'. Please report this issue: https://github.com/DarkAtra/v-rising-discord-bot/issues/new/choose") - } - try { - serverStatusMonitorRepository.updateServerStatusMonitor(serverStatusMonitor) - } catch (e: OutdatedServerStatusMonitorException) { - logger.debug("Server status monitor was updated or deleted by another thread. Will ignore this exception and proceed as usual.", e) - } - - MDC.clear() - } - } - - suspend fun cleanupInactiveServerStatusMonitors(kord: Kord) { - - val sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS) - val inactiveServerStatusMonitors = serverStatusMonitorRepository.getServerStatusMonitors(status = ServerStatusMonitorStatus.INACTIVE) - inactiveServerStatusMonitors.forEach { serverStatusMonitor -> - if (serverStatusMonitor.lastUpdated.isBefore(sevenDaysAgo)) { - serverStatusMonitorRepository.removeServerStatusMonitor(serverStatusMonitor.id) - } - } - - logger.info("Successfully removed ${inactiveServerStatusMonitors.count()} inactive server status monitors.") - } - - private suspend fun updateServerStatusMonitor(kord: Kord, serverStatusMonitor: ServerStatusMonitor) { - - if (!serverStatusMonitor.embedEnabled) { - logger.debug("Skipping server monitor '${serverStatusMonitor.id}' because embedEnabled is false.") - return - } - - val channel = kord.getDiscordChannel(serverStatusMonitor.discordChannelId).getOrElse { - logger.debug("Disabling server monitor '${serverStatusMonitor.id}' because the channel '${serverStatusMonitor.discordChannelId}' does not seem to exist.") - serverStatusMonitor.status = ServerStatusMonitorStatus.INACTIVE - return - } - - val serverInfo = serverQueryClient.getServerStatus( - serverStatusMonitor.hostname, - serverStatusMonitor.queryPort - ).map { serverStatus -> - ServerInfo.of(serverStatus) - }.getOrElse { e -> - - logger.error("Exception fetching the status of '${serverStatusMonitor.id}'.", e) - serverStatusMonitor.currentFailedAttempts += 1 - - if (botProperties.maxRecentErrors > 0) { - serverStatusMonitor.addError(e, botProperties.maxRecentErrors) - } - - try { - - if (serverStatusMonitor.currentEmbedMessageId == null && serverStatusMonitor.currentFailedAttempts == 1) { - channel.createMessage( - """The status check for your status monitor '${serverStatusMonitor.id}' failed. - |Please check the detailed error message using the get-server-details command.""".trimMargin() - ) - } - - if (botProperties.maxFailedAttempts != 0 && serverStatusMonitor.currentFailedAttempts >= botProperties.maxFailedAttempts) { - logger.warn("Disabling server monitor '${serverStatusMonitor.id}' because it exceeded the max failed attempts.") - serverStatusMonitor.status = ServerStatusMonitorStatus.INACTIVE - - channel.createMessage( - """Disabled server status monitor '${serverStatusMonitor.id}' because the server did not - |respond successfully 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 using the update-server command.""".trimMargin() - ) - } - } catch (e: Exception) { - logger.warn("Could not post status message for monitor '${serverStatusMonitor.id}'.", e) - } - return - } - - if (serverStatusMonitor.apiEnabled && serverStatusMonitor.displayPlayerGearLevel) { - - val characters = botCompanionClient.getCharacters( - serverStatusMonitor.apiHostname!!, - serverStatusMonitor.apiPort!!, - getInterceptors(serverStatusMonitor) - ).getOrElse { e -> - - logger.warn("Could not resolve characters for server monitor '${serverStatusMonitor.id}'. Player Gear level will not be displayed.", e) - serverStatusMonitor.currentFailedApiAttempts += 1 - - if (botProperties.maxRecentErrors > 0) { - serverStatusMonitor.addError(e, botProperties.maxRecentErrors) - } - - try { - - if (botProperties.maxFailedApiAttempts != 0 && serverStatusMonitor.currentFailedApiAttempts >= botProperties.maxFailedApiAttempts) { - logger.warn("Disabling displayPlayerGearLevel for server monitor '${serverStatusMonitor.id}' because it exceeded the max failed api attempts.") - serverStatusMonitor.displayPlayerGearLevel = false - - channel.createMessage( - """Disabled displayPlayerGearLevel for server status monitor '${serverStatusMonitor.id}' because - |the bot companion did not respond successfully after ${botProperties.maxFailedApiAttempts} attempts. - |Please make sure the server-api-hostname and server-api-port are correct. - |You can re-enable the functionality using the update-server command.""".trimMargin() - ) - } - } catch (e: Exception) { - logger.warn("Could not post status message for monitor '${serverStatusMonitor.id}'", e) - } - return - } - - serverInfo.enrichCompanionData(characters) - serverStatusMonitor.currentFailedApiAttempts = 0 - } - - 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) } - - serverStatusMonitor.currentFailedAttempts = 0 - - logger.debug("Successfully updated the status of server monitor '${serverStatusMonitor.id}'.") - return - } catch (e: EntityNotFoundException) { - serverStatusMonitor.currentEmbedMessageId = null - } catch (e: Exception) { - logger.warn("Could not update status embed for monitor '${serverStatusMonitor.id}'", e) - } - } - - try { - serverStatusMonitor.currentEmbedMessageId = channel.createEmbed(embedCustomizer).id.toString() - serverStatusMonitor.currentFailedAttempts = 0 - - logger.debug("Successfully updated the status and persisted the embedId of server monitor '${serverStatusMonitor.id}'.") - } catch (e: Exception) { - logger.warn("Could not create status embed for monitor '${serverStatusMonitor.id}'", e) - } - } - - private suspend fun updatePlayerActivityFeed(kord: Kord, serverStatusMonitor: ServerStatusMonitor) { - - if (!serverStatusMonitor.apiEnabled) { - logger.debug("Skipping player activity feed update for server monitor '${serverStatusMonitor.id}' because apiEnabled is false.") - return - } - - val playerActivityDiscordChannelId = serverStatusMonitor.playerActivityDiscordChannelId ?: return - val playerActivityChannel = kord.getDiscordChannel(playerActivityDiscordChannelId).getOrElse { - logger.debug("Disabling player activity feed for server monitor '${serverStatusMonitor.id}' because the channel '${playerActivityDiscordChannelId}' does not seem to exist.") - serverStatusMonitor.playerActivityDiscordChannelId = null - return - } - - val playerActivities = botCompanionClient.getPlayerActivities( - serverStatusMonitor.apiHostname!!, - serverStatusMonitor.apiPort!!, - getInterceptors(serverStatusMonitor) - ).getOrElse { e -> - - logger.error("Exception updating the player activity feed of '${serverStatusMonitor.id}'", e) - serverStatusMonitor.currentFailedApiAttempts += 1 - - if (botProperties.maxRecentErrors > 0) { - serverStatusMonitor.addError(e, botProperties.maxRecentErrors) - } - - try { - if (botProperties.maxFailedApiAttempts != 0 && serverStatusMonitor.currentFailedApiAttempts >= botProperties.maxFailedApiAttempts) { - logger.warn("Disabling the player activity feed for server monitor '${serverStatusMonitor.id}' because it exceeded the max failed api attempts.") - serverStatusMonitor.playerActivityDiscordChannelId = null - - playerActivityChannel.createMessage( - """Disabled the player activity feed for server status monitor '${serverStatusMonitor.id}' because - |the bot companion did not respond successfully after ${botProperties.maxFailedApiAttempts} attempts. - |Please make sure the server-api-hostname and server-api-port are correct. - |You can re-enable the functionality using the update-server command.""".trimMargin() - ) - } - } catch (e: Exception) { - logger.warn("Could not post status message for monitor '${serverStatusMonitor.id}'", e) - } - return - } - - serverStatusMonitor.currentFailedApiAttempts = 0 - - playerActivities - .filter { playerActivity -> playerActivity.occurred.isAfter(serverStatusMonitor.lastUpdated) } - .sortedWith(Comparator.comparing(PlayerActivity::occurred)) - .forEach { playerActivity -> - val action = when (playerActivity.type) { - PlayerActivity.Type.CONNECTED -> "joined" - PlayerActivity.Type.DISCONNECTED -> "left" - } - playerActivityChannel.createMessage( - ": ${playerActivity.playerName} $action the server." - ) - } - - logger.debug("Successfully updated the player activity feed of server monitor '${serverStatusMonitor.id}'.") - } - - private suspend fun updatePvpKillFeed(kord: Kord, serverStatusMonitor: ServerStatusMonitor) { - - if (!serverStatusMonitor.apiEnabled) { - logger.debug("Skipping pvp kill feed update for server monitor '${serverStatusMonitor.id}' because apiEnabled is false.") - return - } - - val pvpKillFeedDiscordChannelId = serverStatusMonitor.pvpKillFeedDiscordChannelId ?: return - val pvpKillFeedChannel = kord.getDiscordChannel(pvpKillFeedDiscordChannelId).getOrElse { - logger.debug("Disabling pvp kill feed for server monitor '${serverStatusMonitor.id}' because the channel '${pvpKillFeedDiscordChannelId}' does not seem to exist.") - serverStatusMonitor.pvpKillFeedDiscordChannelId = null - return - } - - val pvpKills = botCompanionClient.getPvpKills( - serverStatusMonitor.apiHostname!!, - serverStatusMonitor.apiPort!!, - getInterceptors(serverStatusMonitor) - ).getOrElse { e -> - - logger.error("Exception updating the pvp kill feed of ${serverStatusMonitor.id}", e) - serverStatusMonitor.currentFailedApiAttempts += 1 - - if (botProperties.maxRecentErrors > 0) { - serverStatusMonitor.addError(e, botProperties.maxRecentErrors) - } - - try { - if (botProperties.maxFailedApiAttempts != 0 && serverStatusMonitor.currentFailedApiAttempts >= botProperties.maxFailedApiAttempts) { - logger.warn("Disabling the pvp kill feed for server monitor '${serverStatusMonitor.id}' because it exceeded the max failed api attempts.") - serverStatusMonitor.pvpKillFeedDiscordChannelId = null - - pvpKillFeedChannel.createMessage( - """Disabled the pvp kill feed for server status monitor '${serverStatusMonitor.id}' because - |the bot companion did not respond successfully after ${botProperties.maxFailedApiAttempts} attempts. - |Please make sure the server-api-hostname and server-api-port are correct. - |You can re-enable the functionality using the update-server command.""".trimMargin() - ) - } - } catch (e: Exception) { - logger.warn("Could not post status message for monitor '${serverStatusMonitor.id}'", e) - } - return - } - - serverStatusMonitor.currentFailedApiAttempts = 0 - - pvpKills - .filter { pvpKill -> pvpKill.occurred.isAfter(serverStatusMonitor.lastUpdated) } - .sortedWith(Comparator.comparing(PvpKill::occurred)) - .forEach { pvpKill -> - pvpKillFeedChannel.createMessage( - ": ${pvpKill.killer.name} (${pvpKill.killer.gearLevel}) killed ${pvpKill.victim.name} (${pvpKill.victim.gearLevel})." - ) - } - - logger.debug("Successfully updated the pvp kill feed of server monitor '${serverStatusMonitor.id}'.") - } - - private fun getInterceptors(serverStatusMonitor: ServerStatusMonitor): List { - - val (_, _, _, _, _, _, _, _, _, _, apiUsername, apiPassword, _, _, _, _, _, _) = serverStatusMonitor - - return when (apiUsername != null && apiPassword != null) { - true -> listOf(BasicAuthenticationInterceptor(apiUsername, apiPassword)) - false -> emptyList() - } - } -} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/StatusMonitorService.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/StatusMonitorService.kt new file mode 100644 index 0000000..a5db036 --- /dev/null +++ b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/StatusMonitorService.kt @@ -0,0 +1,179 @@ +package de.darkatra.vrising.discord.serverstatus + +import de.darkatra.vrising.discord.BotProperties +import de.darkatra.vrising.discord.InvalidDiscordChannelException +import de.darkatra.vrising.discord.clients.botcompanion.BotCompanionClient +import de.darkatra.vrising.discord.clients.serverquery.CancellationException +import de.darkatra.vrising.discord.clients.serverquery.ServerQueryClient +import de.darkatra.vrising.discord.commands.ConfigureStatusMonitorCommand +import de.darkatra.vrising.discord.commands.GetStatusMonitorDetailsCommand +import de.darkatra.vrising.discord.getDiscordChannel +import de.darkatra.vrising.discord.persistence.model.Status +import de.darkatra.vrising.discord.persistence.model.StatusMonitor +import de.darkatra.vrising.discord.serverstatus.model.ServerInfo +import de.darkatra.vrising.discord.tryCreateMessage +import dev.kord.common.entity.Snowflake +import dev.kord.core.Kord +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.embed +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +class StatusMonitorService( + private val botProperties: BotProperties, + private val serverQueryClient: ServerQueryClient, + private val botCompanionClient: BotCompanionClient, + private val getStatusMonitorDetailsCommand: GetStatusMonitorDetailsCommand, + private val configureStatusMonitorCommand: ConfigureStatusMonitorCommand +) { + + private val logger by lazy { LoggerFactory.getLogger(javaClass) } + + suspend fun updateStatusMonitor(kord: Kord, statusMonitor: StatusMonitor) { + + logger.debug("Attempting to update the server monitor for server '${statusMonitor.getServer().id}'...") + + val channel = kord.getDiscordChannel(statusMonitor.discordChannelId).getOrElse { e -> + when (e) { + is InvalidDiscordChannelException -> { + logger.debug("Disabling server monitor for server '${statusMonitor.getServer().id}' because the channel '${statusMonitor.discordChannelId}' does not seem to exist.") + statusMonitor.status = Status.INACTIVE + } + + else -> { + statusMonitor.currentFailedAttempts += 1 + statusMonitor.addError(e, botProperties.maxRecentErrors) + disableStatusMonitorIfNecessary(statusMonitor) + } + } + return + } + + val serverInfo = serverQueryClient.getServerStatus( + statusMonitor.getServer().hostname, + statusMonitor.getServer().queryPort + ).map { serverStatus -> + ServerInfo.of(serverStatus) + }.getOrElse { e -> + + if (e is CancellationException) { + logger.debug("Server query was canceled.", e) + return + } + + logger.error("Exception updating the status monitor for server '${statusMonitor.getServer().id}'.", e) + statusMonitor.currentFailedAttempts += 1 + statusMonitor.addError(e, botProperties.maxRecentErrors) + + if (statusMonitor.currentEmbedMessageId == null && statusMonitor.currentFailedAttempts == 1) { + channel.tryCreateMessage( + """Failed to update the status monitor for server '${statusMonitor.getServer().id}'. + |Please check the detailed error message using the ${getStatusMonitorDetailsCommand.getCommandName()} command.""".trimMargin() + ) + } + + disableStatusMonitorIfNecessary(statusMonitor) { + channel.tryCreateMessage( + """Disabled status monitor for server '${statusMonitor.getServer().id}' because the server did not + |respond successfully 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 this functionality using the ${configureStatusMonitorCommand.getCommandName()} command.""".trimMargin() + ) + } + return + } + + if (statusMonitor.getServer().apiEnabled && statusMonitor.displayPlayerGearLevel) { + + val characters = botCompanionClient.getCharacters( + statusMonitor.getServer().apiHostname!!, + statusMonitor.getServer().apiPort!!, + statusMonitor.getServer().apiUsername, + statusMonitor.getServer().apiPassword + ).getOrElse { e -> + + logger.warn("Could not resolve characters for status monitor for server '${statusMonitor.getServer().id}'.", e) + statusMonitor.currentFailedApiAttempts += 1 + statusMonitor.addError(e, botProperties.maxRecentErrors) + + disableBotCompanionFeaturesIfNecessary(statusMonitor) { + channel.tryCreateMessage( + """The status monitor for server '${statusMonitor.getServer().id}' will no longer display the players gear level because + |the bot companion did not respond successfully after ${botProperties.maxFailedApiAttempts} attempts. + |Please make sure the server-api-hostname and server-api-port are correct. + |You can re-enable this functionality using the ${configureStatusMonitorCommand.getCommandName()} command.""".trimMargin() + ) + } + return + } + + serverInfo.enrichCompanionData(characters) + statusMonitor.currentFailedApiAttempts = 0 + } + + val embedCustomizer: (embedBuilder: EmbedBuilder) -> Unit = { embedBuilder -> + ServerStatusEmbed.buildEmbed( + serverInfo, + statusMonitor.getServer().apiEnabled, + statusMonitor.displayServerDescription, + statusMonitor.displayPlayerGearLevel, + embedBuilder + ) + } + + val currentEmbedMessageId = statusMonitor.currentEmbedMessageId + when { + currentEmbedMessageId != null -> try { + + channel.getMessage(Snowflake(currentEmbedMessageId)) + .edit { embed(embedCustomizer) } + + statusMonitor.currentFailedAttempts = 0 + + logger.debug("Successfully updated the status monitor for server '${statusMonitor.getServer().id}'.") + } catch (e: EntityNotFoundException) { + statusMonitor.currentEmbedMessageId = null + } catch (e: Exception) { + logger.warn("Could not update status embed for server '${statusMonitor.getServer().id}'", e) + + statusMonitor.currentFailedApiAttempts += 1 + statusMonitor.addError(e, botProperties.maxRecentErrors) + } + + else -> try { + + statusMonitor.currentEmbedMessageId = channel.createEmbed(embedCustomizer).id.toString() + statusMonitor.currentFailedAttempts = 0 + + logger.debug("Successfully updated the status and persisted the embedId for server monitor of server '${statusMonitor.getServer().id}'.") + } catch (e: Exception) { + logger.warn("Could not create status embed for server '${statusMonitor.getServer().id}'", e) + + statusMonitor.currentFailedApiAttempts += 1 + statusMonitor.addError(e, botProperties.maxRecentErrors) + } + } + } + + private suspend fun disableStatusMonitorIfNecessary(statusMonitor: StatusMonitor, block: suspend () -> Unit = {}) { + + if (botProperties.maxFailedAttempts != 0 && statusMonitor.currentFailedAttempts >= botProperties.maxFailedAttempts) { + logger.warn("Disabling server monitor for server '${statusMonitor.getServer().id}' because it exceeded the max failed attempts.") + statusMonitor.status = Status.INACTIVE + block() + } + } + + private suspend fun disableBotCompanionFeaturesIfNecessary(statusMonitor: StatusMonitor, block: suspend () -> Unit = {}) { + + if (botProperties.maxFailedApiAttempts != 0 && statusMonitor.currentFailedApiAttempts >= botProperties.maxFailedApiAttempts) { + logger.warn("Disabling displayPlayerGearLevel for status monitor of server '${statusMonitor.getServer().id}' because it exceeded the max failed api attempts.") + statusMonitor.displayPlayerGearLevel = false + block() + } + } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/exceptions/InvalidDiscordChannelException.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/exceptions/InvalidDiscordChannelException.kt deleted file mode 100644 index 03cfd99..0000000 --- a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/exceptions/InvalidDiscordChannelException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package de.darkatra.vrising.discord.serverstatus.exceptions - -import de.darkatra.vrising.discord.BotException - -class InvalidDiscordChannelException(message: String, cause: Throwable? = null) : BotException(message, cause) diff --git a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/exceptions/OutdatedServerStatusMonitorException.kt b/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/exceptions/OutdatedServerStatusMonitorException.kt deleted file mode 100644 index ab53976..0000000 --- a/src/main/kotlin/de/darkatra/vrising/discord/serverstatus/exceptions/OutdatedServerStatusMonitorException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package de.darkatra.vrising.discord.serverstatus.exceptions - -import de.darkatra.vrising.discord.BotException - -class OutdatedServerStatusMonitorException(message: String) : BotException(message) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 403e5ae..388f6f8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,6 @@ spring: + application: + name: @project.artifactId@ main: web-application-type: none banner-mode: off diff --git a/src/test/kotlin/de/darkatra/vrising/discord/DatabaseConfigurationTestUtils.kt b/src/test/kotlin/de/darkatra/vrising/discord/DatabaseConfigurationTestUtils.kt index 9aa609e..32917d7 100644 --- a/src/test/kotlin/de/darkatra/vrising/discord/DatabaseConfigurationTestUtils.kt +++ b/src/test/kotlin/de/darkatra/vrising/discord/DatabaseConfigurationTestUtils.kt @@ -1,26 +1,45 @@ package de.darkatra.vrising.discord +import de.darkatra.vrising.discord.persistence.DatabaseConfiguration import org.dizitart.no2.Nitrite -import org.dizitart.no2.objects.filters.ObjectFilters +import org.dizitart.no2.filters.Filter import org.slf4j.LoggerFactory -import java.io.File +import java.net.URL +import java.nio.file.Files +import java.nio.file.StandardOpenOption.CREATE +import java.nio.file.StandardOpenOption.TRUNCATE_EXISTING +import kotlin.io.path.absolutePathString +import kotlin.io.path.outputStream object DatabaseConfigurationTestUtils { - private val logger = LoggerFactory.getLogger(javaClass) + val DATABASE_FILE_V1_2_x by lazy { DatabaseConfigurationTestUtils::class.java.getResource("/persistence/v1.2.db")!! } + val DATABASE_FILE_V2_10_5 by lazy { DatabaseConfigurationTestUtils::class.java.getResource("/persistence/v2.10.5.db")!! } + private val logger by lazy { LoggerFactory.getLogger(javaClass) } - fun getTestDatabase(): Nitrite { - return Nitrite.builder() - .compressed() - .filePath(File.createTempFile("v-rising-bot", ".db").also { - logger.info("Test Db location: " + it.absolutePath) - }) - .openOrCreate() + fun getTestDatabase(fromTemplate: URL? = null, username: String? = null, password: String? = null): Nitrite { + + val databaseFile = Files.createTempFile("v-rising-bot", ".db").also { + logger.info("Test Db location: " + it.absolutePathString()) + } + + if (fromTemplate != null) { + logger.info("Loading template from '$fromTemplate'.") + fromTemplate.openStream().buffered().use { inputStream -> + databaseFile.outputStream(CREATE, TRUNCATE_EXISTING).buffered().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + } + + return DatabaseConfiguration.buildNitriteDatabase(databaseFile, username, password) } fun clearDatabase(nitrite: Nitrite) { nitrite.listCollectionNames().forEach { collectionName -> - nitrite.getCollection(collectionName).remove(ObjectFilters.ALL) + nitrite.getCollection(collectionName).use { + it.remove(Filter.ALL) + } } } } diff --git a/src/test/kotlin/de/darkatra/vrising/discord/KordExtensionsKtTest.kt b/src/test/kotlin/de/darkatra/vrising/discord/KordExtensionsKtTest.kt new file mode 100644 index 0000000..79d4426 --- /dev/null +++ b/src/test/kotlin/de/darkatra/vrising/discord/KordExtensionsKtTest.kt @@ -0,0 +1,40 @@ +package de.darkatra.vrising.discord + +import dev.kord.core.entity.interaction.InteractionCommand +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.DisabledInNativeImage +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@DisabledInNativeImage +class KordExtensionsKtTest { + + @Test + fun `should extract channel id from string`() { + + val parameterName = "parameter-name" + + val interaction = mock { + whenever(it.strings).thenReturn(mapOf(parameterName to "123456789")) + } + + val channelId = interaction.getChannelIdFromStringParameter(parameterName) + + assertThat(channelId).isEqualTo("123456789") + } + + @Test + fun `should extract channel id from channel referencing string`() { + + val parameterName = "parameter-name" + + val interaction = mock { + whenever(it.strings).thenReturn(mapOf(parameterName to "<#123456789>")) + } + + val channelId = interaction.getChannelIdFromStringParameter(parameterName) + + assertThat(channelId).isEqualTo("123456789") + } +} diff --git a/src/test/kotlin/de/darkatra/vrising/discord/RuntimeHintsTest.kt b/src/test/kotlin/de/darkatra/vrising/discord/RuntimeHintsTest.kt index 9b607da..b187446 100644 --- a/src/test/kotlin/de/darkatra/vrising/discord/RuntimeHintsTest.kt +++ b/src/test/kotlin/de/darkatra/vrising/discord/RuntimeHintsTest.kt @@ -1,9 +1,6 @@ package de.darkatra.vrising.discord -import de.darkatra.vrising.discord.migration.Schema -import de.darkatra.vrising.discord.persistence.model.ServerStatusMonitor import org.junit.jupiter.api.Test -import org.springframework.aot.hint.annotation.RegisterReflectionForBinding import org.springframework.boot.test.context.SpringBootTest import org.springframework.context.annotation.ImportRuntimeHints @@ -18,7 +15,6 @@ class RuntimeHintsTest { // workaround to generate runtime hints for unit tests } - @ImportRuntimeHints(BotRuntimeHints::class) - @RegisterReflectionForBinding(BotProperties::class, Schema::class, ServerStatusMonitor::class) + @ImportRuntimeHints(BotRuntimeHints::class, TestRuntimeHints::class) class TestConfiguration } diff --git a/src/test/kotlin/de/darkatra/vrising/discord/TestRuntimeHints.kt b/src/test/kotlin/de/darkatra/vrising/discord/TestRuntimeHints.kt new file mode 100644 index 0000000..d79052e --- /dev/null +++ b/src/test/kotlin/de/darkatra/vrising/discord/TestRuntimeHints.kt @@ -0,0 +1,13 @@ +package de.darkatra.vrising.discord + +import org.springframework.aot.hint.RuntimeHints +import org.springframework.aot.hint.RuntimeHintsRegistrar + +class TestRuntimeHints : RuntimeHintsRegistrar { + + override fun registerHints(hints: RuntimeHints, classLoader: ClassLoader?) { + + hints.resources() + .registerPattern("persistence/*.db") + } +} diff --git a/src/test/kotlin/de/darkatra/vrising/discord/clients/botcompanion/BotCompanionClientTest.kt b/src/test/kotlin/de/darkatra/vrising/discord/clients/botcompanion/BotCompanionClientTest.kt index 3f1af1c..30d7be7 100644 --- a/src/test/kotlin/de/darkatra/vrising/discord/clients/botcompanion/BotCompanionClientTest.kt +++ b/src/test/kotlin/de/darkatra/vrising/discord/clients/botcompanion/BotCompanionClientTest.kt @@ -1,29 +1,37 @@ package de.darkatra.vrising.discord.clients.botcompanion import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.client.WireMock.equalTo import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo import com.github.tomakehurst.wiremock.junit5.WireMockTest import de.darkatra.vrising.discord.clients.botcompanion.model.PlayerActivity import de.darkatra.vrising.discord.clients.botcompanion.model.VBlood +import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.DisabledInNativeImage +import org.springframework.context.support.StaticApplicationContext import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.MediaType -import org.springframework.http.client.support.BasicAuthenticationInterceptor @WireMockTest @DisabledInNativeImage class BotCompanionClientTest { - private val botCompanionClient = BotCompanionClient() + private val botCompanionClient = BotCompanionClient( + StaticApplicationContext().apply { + id = "test" + } + ) @Test fun `should get characters`(wireMockRuntimeInfo: WireMockRuntimeInfo) { wireMockRuntimeInfo.wireMock.register( WireMock.get("/v-rising-discord-bot/characters") + .withHeader(HttpHeaders.ACCEPT, equalTo(MediaType.APPLICATION_JSON_VALUE)) + .withHeader(HttpHeaders.USER_AGENT, equalTo("test")) .willReturn( WireMock.aResponse() .withStatus(HttpStatus.OK.value()) @@ -47,7 +55,9 @@ class BotCompanionClientTest { ) ) - val charactersResult = botCompanionClient.getCharacters("localhost", wireMockRuntimeInfo.httpPort, emptyList()) + val charactersResult = runBlocking { + botCompanionClient.getCharacters("localhost", wireMockRuntimeInfo.httpPort) + } assertThat(charactersResult.isSuccess).isTrue() val characters = charactersResult.getOrThrow() @@ -68,6 +78,8 @@ class BotCompanionClientTest { wireMockRuntimeInfo.wireMock.register( WireMock.get("/v-rising-discord-bot/characters") + .withHeader(HttpHeaders.ACCEPT, equalTo(MediaType.APPLICATION_JSON_VALUE)) + .withHeader(HttpHeaders.USER_AGENT, equalTo("test")) .withBasicAuth(username, password) .willReturn( WireMock.aResponse() @@ -92,11 +104,9 @@ class BotCompanionClientTest { ) ) - val charactersResult = botCompanionClient.getCharacters( - "localhost", - wireMockRuntimeInfo.httpPort, - listOf(BasicAuthenticationInterceptor(username, password)) - ) + val charactersResult = runBlocking { + botCompanionClient.getCharacters("localhost", wireMockRuntimeInfo.httpPort, username, password) + } assertThat(charactersResult.isSuccess).isTrue() val characters = charactersResult.getOrThrow() @@ -114,6 +124,8 @@ class BotCompanionClientTest { wireMockRuntimeInfo.wireMock.register( WireMock.get("/v-rising-discord-bot/characters") + .withHeader(HttpHeaders.ACCEPT, equalTo(MediaType.APPLICATION_JSON_VALUE)) + .withHeader(HttpHeaders.USER_AGENT, equalTo("test")) .willReturn( WireMock.aResponse() .withStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()) @@ -129,17 +141,19 @@ class BotCompanionClientTest { ) ) - val charactersResult = botCompanionClient.getCharacters("localhost", wireMockRuntimeInfo.httpPort, emptyList()) + val charactersResult = runBlocking { + botCompanionClient.getCharacters("localhost", wireMockRuntimeInfo.httpPort) + } assertThat(charactersResult.isFailure).isTrue() } @Test fun `should get player activities`(wireMockRuntimeInfo: WireMockRuntimeInfo) { - val wireMock = wireMockRuntimeInfo.wireMock - - wireMock.register( + wireMockRuntimeInfo.wireMock.register( WireMock.get("/v-rising-discord-bot/player-activities") + .withHeader(HttpHeaders.ACCEPT, equalTo(MediaType.APPLICATION_JSON_VALUE)) + .withHeader(HttpHeaders.USER_AGENT, equalTo("test")) .willReturn( WireMock.aResponse() .withStatus(HttpStatus.OK.value()) @@ -163,7 +177,9 @@ class BotCompanionClientTest { ) ) - val playerActivitiesResult = botCompanionClient.getPlayerActivities("localhost", wireMockRuntimeInfo.httpPort, emptyList()) + val playerActivitiesResult = runBlocking { + botCompanionClient.getPlayerActivities("localhost", wireMockRuntimeInfo.httpPort) + } assertThat(playerActivitiesResult.isSuccess).isTrue() val playerActivities = playerActivitiesResult.getOrThrow() @@ -178,10 +194,10 @@ class BotCompanionClientTest { @Test fun `should get pvp kills`(wireMockRuntimeInfo: WireMockRuntimeInfo) { - val wireMock = wireMockRuntimeInfo.wireMock - - wireMock.register( + wireMockRuntimeInfo.wireMock.register( WireMock.get("/v-rising-discord-bot/pvp-kills") + .withHeader(HttpHeaders.ACCEPT, equalTo(MediaType.APPLICATION_JSON_VALUE)) + .withHeader(HttpHeaders.USER_AGENT, equalTo("test")) .willReturn( WireMock.aResponse() .withStatus(HttpStatus.OK.value()) @@ -206,7 +222,9 @@ class BotCompanionClientTest { ) ) - val pvpKillsResult = botCompanionClient.getPvpKills("localhost", wireMockRuntimeInfo.httpPort, emptyList()) + val pvpKillsResult = runBlocking { + botCompanionClient.getPvpKills("localhost", wireMockRuntimeInfo.httpPort) + } assertThat(pvpKillsResult.isSuccess).isTrue() val pvpKills = pvpKillsResult.getOrThrow() diff --git a/src/test/kotlin/de/darkatra/vrising/discord/clients/botcompanion/model/CharacterTest.kt b/src/test/kotlin/de/darkatra/vrising/discord/clients/botcompanion/model/CharacterTest.kt new file mode 100644 index 0000000..60a58b2 --- /dev/null +++ b/src/test/kotlin/de/darkatra/vrising/discord/clients/botcompanion/model/CharacterTest.kt @@ -0,0 +1,38 @@ +package de.darkatra.vrising.discord.clients.botcompanion.model + +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class CharacterTest { + + @Test + fun `should deserialize character`() { + + val objectMapper = jacksonObjectMapper().registerModule(JavaTimeModule()) + + val character = objectMapper.readValue( + // language=json + """ + { + "name": "Atra", + "gearLevel": 83, + "clan": "Test", + "killedVBloods": [ + "FOREST_WOLF", + "BANDIT_STONEBREAKER", + null + ] + } + """.trimIndent(), + Character::class.java + ) + + assertThat(character).isNotNull() + 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) + } +} diff --git a/src/test/kotlin/de/darkatra/vrising/discord/clients/botcompanion/model/PlayerActivityTest.kt b/src/test/kotlin/de/darkatra/vrising/discord/clients/botcompanion/model/PlayerActivityTest.kt new file mode 100644 index 0000000..d908e47 --- /dev/null +++ b/src/test/kotlin/de/darkatra/vrising/discord/clients/botcompanion/model/PlayerActivityTest.kt @@ -0,0 +1,32 @@ +package de.darkatra.vrising.discord.clients.botcompanion.model + +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class PlayerActivityTest { + + @Test + fun `should deserialize player activity`() { + + val objectMapper = jacksonObjectMapper().registerModule(JavaTimeModule()) + + val playerActivity = objectMapper.readValue( + // language=json + """ + { + "type": "CONNECTED", + "playerName": "Atra", + "occurred": "2023-01-01T00:00:00Z" + } + """.trimIndent(), + PlayerActivity::class.java + ) + + assertThat(playerActivity).isNotNull() + assertThat(playerActivity.type).isEqualTo(PlayerActivity.Type.CONNECTED) + assertThat(playerActivity.playerName).isEqualTo("Atra") + assertThat(playerActivity.occurred).isEqualTo("2023-01-01T00:00:00Z") + } +} diff --git a/src/test/kotlin/de/darkatra/vrising/discord/clients/botcompanion/model/PvpKillTest.kt b/src/test/kotlin/de/darkatra/vrising/discord/clients/botcompanion/model/PvpKillTest.kt new file mode 100644 index 0000000..2b2a61a --- /dev/null +++ b/src/test/kotlin/de/darkatra/vrising/discord/clients/botcompanion/model/PvpKillTest.kt @@ -0,0 +1,42 @@ +package de.darkatra.vrising.discord.clients.botcompanion.model + +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class PvpKillTest { + + @Test + fun `should deserialize pvp kill`() { + + val objectMapper = jacksonObjectMapper().registerModule(JavaTimeModule()) + + val pvpKill = objectMapper.readValue( + // language=json + """ + { + "killer": { + "name": "Atra", + "gearLevel": 71 + }, + "victim": { + "name": "Testi", + "gearLevel": 11 + }, + "occurred": "2023-01-01T00:00:00Z" + } + """.trimIndent(), + PvpKill::class.java + ) + + assertThat(pvpKill).isNotNull() + assertThat(pvpKill.killer).isNotNull() + assertThat(pvpKill.killer.name).isEqualTo("Atra") + assertThat(pvpKill.killer.gearLevel).isEqualTo(71) + assertThat(pvpKill.victim).isNotNull() + assertThat(pvpKill.victim.name).isEqualTo("Testi") + assertThat(pvpKill.victim.gearLevel).isEqualTo(11) + assertThat(pvpKill.occurred).isEqualTo("2023-01-01T00:00:00Z") + } +} 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 29c6f76..01c98f4 100644 --- a/src/test/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigrationServiceTest.kt +++ b/src/test/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigrationServiceTest.kt @@ -1,101 +1,229 @@ package de.darkatra.vrising.discord.migration import de.darkatra.vrising.discord.DatabaseConfigurationTestUtils -import de.darkatra.vrising.discord.persistence.model.ServerStatusMonitor +import de.darkatra.vrising.discord.persistence.model.Server +import de.darkatra.vrising.discord.persistence.model.Status +import de.darkatra.vrising.discord.persistence.model.Version 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.dizitart.no2.collection.Document import org.junit.jupiter.api.Test -import org.junit.jupiter.api.condition.DisabledInNativeImage +import java.time.Instant -@DisabledInNativeImage class DatabaseMigrationServiceTest { - private lateinit var database: Nitrite - - @BeforeEach - fun setUp() { - database = DatabaseConfigurationTestUtils.getTestDatabase() - } - @Test fun `should perform database migration when no schema was found`() { - val databaseMigrationService = DatabaseMigrationService( - database = database, - appVersionFromPom = "1.5.0" - ) + DatabaseConfigurationTestUtils.getTestDatabase().use { database -> - assertThat(databaseMigrationService.migrateToLatestVersion()).isTrue() + val databaseMigrationService = DatabaseMigrationService( + database = database, + appVersionFromPom = "1.5.0" + ) + + assertThat(databaseMigrationService.migrateToLatestVersion()).isTrue() - val repository = database.getRepository(Schema::class.java) + val repository = database.getRepository(Schema::class.java) - val schemas = repository.find().toList() - assertThat(schemas).hasSize(1) - assertThat(schemas).first().extracting(Schema::appVersion).isEqualTo("V1.5.0") + val schemas = repository.find().toList() + assertThat(schemas).hasSize(1) + assertThat(schemas).first().extracting(Schema::appVersion).isEqualTo("V1.5.0") + } } @Test fun `should not perform database migration when schema matches the current version`() { - val repository = database.getRepository(Schema::class.java) - repository.insert(Schema(appVersion = "V1.4.0")) - 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")) - repository.insert(Schema(appVersion = "V2.3.0")) - repository.insert(Schema(appVersion = "V2.9.0")) - repository.insert(Schema(appVersion = "V2.10.0")) - repository.insert(Schema(appVersion = "V2.10.2")) - - val databaseMigrationService = DatabaseMigrationService( - database = database, - appVersionFromPom = "2.10.2" - ) - - assertThat(databaseMigrationService.migrateToLatestVersion()).isFalse() - - val schemas = repository.find().toList() - assertThat(schemas).hasSize(9) + DatabaseConfigurationTestUtils.getTestDatabase().use { database -> + + database.getRepository(Schema::class.java).use { repository -> + repository.insert(Schema(appVersion = "V1.4.0")) + 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")) + repository.insert(Schema(appVersion = "V2.3.0")) + repository.insert(Schema(appVersion = "V2.9.0")) + repository.insert(Schema(appVersion = "V2.10.0")) + repository.insert(Schema(appVersion = "V2.10.2")) + repository.insert(Schema(appVersion = "V2.11.0")) + } + + val databaseMigrationService = DatabaseMigrationService( + database = database, + appVersionFromPom = "2.11.0" + ) + + assertThat(databaseMigrationService.migrateToLatestVersion()).isFalse() + + val schemas = database.getRepository(Schema::class.java).use { repository -> + repository.find().toList() + } + assertThat(schemas).hasSize(10) + } } @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")) + DatabaseConfigurationTestUtils.getTestDatabase().use { database -> + + database.getRepository(Schema::class.java).use { repository -> + repository.insert(Schema(appVersion = "V2.1.0")) + } + + val databaseMigrationService = DatabaseMigrationService( + database = database, + appVersionFromPom = "2.9.0" + ) + + database.getCollection("de.darkatra.vrising.discord.ServerStatusMonitor").use { oldCollection -> + oldCollection.insert(Document.createDocument("hostName", "test-hostname")) + assertThat(oldCollection.size()).isEqualTo(1) + } - val databaseMigrationService = DatabaseMigrationService( - database = database, - appVersionFromPom = "2.9.0" - ) + database.getCollection("de.darkatra.vrising.discord.persistence.model.Server").use { newCollection -> + assertThat(newCollection.size()).isEqualTo(0) + } - val oldCollection = database.getCollection("de.darkatra.vrising.discord.ServerStatusMonitor") - val newCollection = database.getCollection(ObjectUtils.findObjectStoreName(ServerStatusMonitor::class.java)) + assertThat(databaseMigrationService.migrateToLatestVersion()).isTrue() - oldCollection.insert( - arrayOf( - Document.createDocument("hostName", "test-hostname") + database.getCollection("de.darkatra.vrising.discord.ServerStatusMonitor").use { oldCollection -> + assertThat(oldCollection.size()).isEqualTo(0) + } + + val migratedDocument = database.getCollection("de.darkatra.vrising.discord.persistence.model.Server").use { newCollection -> + assertThat(newCollection.size()).isEqualTo(1) + newCollection.find().first() + } + assertThat(migratedDocument["hostname"]).isEqualTo("test-hostname") + + val schemas = database.getRepository(Schema::class.java).use { repository -> + repository.find().toList() + } + assertThat(schemas).hasSize(2) + } + } + + @Test + fun `should migrate schema from 1_2_x to 2_11_0`() { + + DatabaseConfigurationTestUtils.getTestDatabase(DatabaseConfigurationTestUtils.DATABASE_FILE_V1_2_x).use { database -> + + val databaseMigrationService = DatabaseMigrationService( + database = database, + appVersionFromPom = "2.11.0" ) - ) - assertThat(oldCollection.size()).isEqualTo(1) - assertThat(newCollection.size()).isEqualTo(0) + val oldDocument = database.getCollection("de.darkatra.vrising.discord.ServerStatusMonitor").use { oldCollection -> + assertThat(oldCollection.size()).isEqualTo(1) + oldCollection.find().first() + } + database.getRepository(Server::class.java).use { serverRepository -> + assertThat(serverRepository.size()).isEqualTo(0) + } + + assertThat(databaseMigrationService.migrateToLatestVersion()).isTrue() + + database.getCollection("de.darkatra.vrising.discord.ServerStatusMonitor").use { oldCollection -> + assertThat(oldCollection.size()).isEqualTo(0) + } + + val server = database.getRepository(Server::class.java).use { serverRepository -> + assertThat(serverRepository.size()).isEqualTo(1) + serverRepository.find().first() + } + assertThat(server.id).isEqualTo(oldDocument["id"]) + @Suppress("DEPRECATION") + assertThat(server.version).isNotNull() + assertThat(server.discordServerId).isEqualTo(oldDocument["discordServerId"]) + assertThat(server.hostname).isEqualTo(oldDocument["hostName"]) + assertThat(server.queryPort).isEqualTo(oldDocument["queryPort"]) + assertThat(server.apiHostname).isEqualTo(oldDocument["apiHostname"]) + assertThat(server.apiPort).isEqualTo(oldDocument["apiPort"]) + assertThat(server.apiUsername).isEqualTo(oldDocument["apiUsername"]) + assertThat(server.apiPassword).isEqualTo(oldDocument["apiPassword"]) + assertThat(server.pvpLeaderboard).isNull() + assertThat(server.playerActivityFeed).isNull() + assertThat(server.pvpKillFeed).isNull() + assertThat(server.statusMonitor).isNotNull() + assertThat(server.statusMonitor!!.status).isNotNull() + assertThat(server.statusMonitor!!.status).isEqualTo(Status.ACTIVE) + assertThat(server.statusMonitor!!.discordChannelId).isEqualTo(oldDocument["discordChannelId"]) + assertThat(server.statusMonitor!!.displayServerDescription).isTrue() + assertThat(server.statusMonitor!!.displayPlayerGearLevel).isTrue() + assertThat(server.statusMonitor!!.currentEmbedMessageId).isEqualTo(oldDocument["currentEmbedMessageId"]) + assertThat(server.statusMonitor!!.currentFailedAttempts).isEqualTo(0) + assertThat(server.statusMonitor!!.currentFailedApiAttempts).isEqualTo(0) + assertThat(server.statusMonitor!!.recentErrors).isEmpty() + } + } - assertThat(databaseMigrationService.migrateToLatestVersion()).isTrue() + @Test + fun `should migrate schema from 2_10_5 to 2_11_0`() { - assertThat(oldCollection.size()).isEqualTo(0) - assertThat(newCollection.size()).isEqualTo(1) + DatabaseConfigurationTestUtils.getTestDatabase(DatabaseConfigurationTestUtils.DATABASE_FILE_V2_10_5).use { database -> - val migratedDocument = newCollection.find().first() - assertThat(migratedDocument["hostname"]).isEqualTo(migratedDocument["hostName"]) - assertThat(migratedDocument["displayPlayerGearLevel"]).isEqualTo(true) - assertThat(migratedDocument["embedEnabled"]).isEqualTo(true) + val repository = database.getRepository(Schema::class.java) + repository.insert(Schema(appVersion = "V2.10.5")) + + val databaseMigrationService = DatabaseMigrationService( + database = database, + appVersionFromPom = "2.11.0" + ) - val schemas = repository.find().toList() - assertThat(schemas).hasSize(2) + database.getRepository(Server::class.java).use { serverRepository -> + assertThat(serverRepository.size()).isEqualTo(0) + } + + val oldDocument = database.getCollection("de.darkatra.vrising.discord.persistence.model.ServerStatusMonitor").use { oldCollection -> + assertThat(oldCollection.size()).isEqualTo(1) + oldCollection.find().first() + } + + assertThat(databaseMigrationService.migrateToLatestVersion()).isTrue() + + database.getCollection("de.darkatra.vrising.discord.persistence.model.ServerStatusMonitor").use { oldCollection -> + assertThat(oldCollection.size()).isEqualTo(0) + } + + val server = database.getRepository(Server::class.java).use { serverRepository -> + assertThat(serverRepository.size()).isEqualTo(1) + serverRepository.find().first() + } + assertThat(server.id).isEqualTo(oldDocument["id"]) + @Suppress("DEPRECATION") + assertThat(server.version).isEqualTo(Version(1, Instant.ofEpochMilli(oldDocument["version"] as Long))) + assertThat(server.discordServerId).isEqualTo(oldDocument["discordServerId"]) + assertThat(server.hostname).isEqualTo(oldDocument["hostname"]) + assertThat(server.queryPort).isEqualTo(oldDocument["queryPort"]) + assertThat(server.apiHostname).isEqualTo(oldDocument["apiHostname"]) + assertThat(server.apiPort).isEqualTo(oldDocument["apiPort"]) + assertThat(server.apiUsername).isEqualTo(oldDocument["apiUsername"]) + assertThat(server.apiPassword).isEqualTo(oldDocument["apiPassword"]) + assertThat(server.pvpLeaderboard).isNull() + assertThat(server.playerActivityFeed).isNotNull() + assertThat(server.playerActivityFeed!!.status).isEqualTo(Status.ACTIVE) + assertThat(server.playerActivityFeed!!.discordChannelId).isEqualTo(oldDocument["playerActivityDiscordChannelId"]) + assertThat(server.playerActivityFeed!!.lastUpdated).isNotNull() + assertThat(server.playerActivityFeed!!.currentFailedAttempts).isEqualTo(0) + assertThat(server.playerActivityFeed!!.recentErrors).isEmpty() + assertThat(server.pvpKillFeed).isNotNull() + assertThat(server.pvpKillFeed!!.status).isEqualTo(Status.ACTIVE) + assertThat(server.pvpKillFeed!!.discordChannelId).isEqualTo(oldDocument["pvpKillFeedDiscordChannelId"]) + assertThat(server.pvpKillFeed!!.lastUpdated).isNotNull() + assertThat(server.pvpKillFeed!!.currentFailedAttempts).isEqualTo(0) + assertThat(server.pvpKillFeed!!.recentErrors).isEmpty() + assertThat(server.statusMonitor).isNotNull() + assertThat(server.statusMonitor!!.status).isNotNull() + assertThat(server.statusMonitor!!.status).isEqualTo(Status.ACTIVE) + assertThat(server.statusMonitor!!.discordChannelId).isEqualTo(oldDocument["discordChannelId"]) + assertThat(server.statusMonitor!!.displayServerDescription).isEqualTo(oldDocument["displayServerDescription"]) + assertThat(server.statusMonitor!!.displayPlayerGearLevel).isEqualTo(oldDocument["displayPlayerGearLevel"]) + assertThat(server.statusMonitor!!.currentEmbedMessageId).isEqualTo(oldDocument["currentEmbedMessageId"]) + assertThat(server.statusMonitor!!.currentFailedAttempts).isEqualTo(oldDocument["currentFailedAttempts"]) + assertThat(server.statusMonitor!!.currentFailedApiAttempts).isEqualTo(oldDocument["currentFailedApiAttempts"]) + assertThat(server.statusMonitor!!.recentErrors).isEmpty() + } } } diff --git a/src/test/kotlin/de/darkatra/vrising/discord/persistence/DatabaseBackupServiceTest.kt b/src/test/kotlin/de/darkatra/vrising/discord/persistence/DatabaseBackupServiceTest.kt new file mode 100644 index 0000000..10aa5e9 --- /dev/null +++ b/src/test/kotlin/de/darkatra/vrising/discord/persistence/DatabaseBackupServiceTest.kt @@ -0,0 +1,99 @@ +package de.darkatra.vrising.discord.persistence + +import de.darkatra.vrising.discord.BotProperties +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.boot.test.system.CapturedOutput +import org.springframework.boot.test.system.OutputCaptureExtension +import java.nio.file.Files +import java.nio.file.Path +import java.util.regex.Pattern +import kotlin.io.path.absolutePathString +import kotlin.io.path.createFile +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name + +@ExtendWith(OutputCaptureExtension::class) +class DatabaseBackupServiceTest { + + @Test + fun `should create database backups`() { + + val databasePath = Files.createTempFile("v-rising-bot", "db") + val databaseBackupDirectory = Files.createTempDirectory("v-rising-bot-backup-dir") + + val databaseBackupService = DatabaseBackupService(getBotProperties(databasePath, databaseBackupDirectory)) + + databaseBackupService.performDatabaseBackup() + + val files = databaseBackupDirectory.listDirectoryEntries() + + assertThat(files).hasSize(1) + assertThat(files[0].name).matches(Pattern.compile("[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}Z")) + assertThat(files[0]).hasSameBinaryContentAs(databasePath) + } + + @Test + fun `should not create database backups when databaseBackupDirectory is a file`(capturedOutput: CapturedOutput) { + + val databasePath = Files.createTempFile("v-rising-bot", "db") + val databaseBackupDirectory = Files.createTempFile("v-rising-bot-backup-dir", "as-file") + + val databaseBackupService = DatabaseBackupService(getBotProperties(databasePath, databaseBackupDirectory)) + + databaseBackupService.performDatabaseBackup() + + assertThat(capturedOutput.out).contains("Aborting backup process because the backup directory is not a directory:") + } + + @Test + fun `should delete old database backups`() { + + val databasePath = Files.createTempFile("v-rising-bot", "db") + val databaseBackupDirectory = Files.createTempDirectory("v-rising-bot-backup-dir") + + databaseBackupDirectory.resolve("2024-01-01_00-00-00Z").createFile() + databaseBackupDirectory.resolve("2024-01-01_01-00-00Z").createFile() + + val fileToKeep = databaseBackupDirectory.resolve("2024-01-01_02-00-00Z").createFile() + + val databaseBackupService = DatabaseBackupService(getBotProperties(databasePath, databaseBackupDirectory)) + + databaseBackupService.deleteOldBackups() + + val files = databaseBackupDirectory.listDirectoryEntries() + + assertThat(files).hasSize(1) + assertThat(files[0].absolutePathString()).isEqualTo(fileToKeep.absolutePathString()) + } + + @Test + fun `should not delete old database backups if below max files`() { + + val databasePath = Files.createTempFile("v-rising-bot", "db") + val databaseBackupDirectory = Files.createTempDirectory("v-rising-bot-backup-dir") + + val fileToKeep = databaseBackupDirectory.resolve("2024-01-01_02-00-00Z").createFile() + + val databaseBackupService = DatabaseBackupService(getBotProperties(databasePath, databaseBackupDirectory)) + + databaseBackupService.deleteOldBackups() + + val files = databaseBackupDirectory.listDirectoryEntries() + + assertThat(files).hasSize(1) + assertThat(files[0].absolutePathString()).isEqualTo(fileToKeep.absolutePathString()) + } + + private fun getBotProperties(databasePath: Path, databaseBackupDirectory: Path): BotProperties { + return BotProperties().apply { + discordBotToken = "discord-token" + databasePassword = "password" + this.databasePath = databasePath + databaseBackupJobEnabled = true + databaseBackupMaxFiles = 2 + this.databaseBackupDirectory = databaseBackupDirectory + } + } +} diff --git a/src/test/kotlin/de/darkatra/vrising/discord/persistence/ServerRepositoryTest.kt b/src/test/kotlin/de/darkatra/vrising/discord/persistence/ServerRepositoryTest.kt new file mode 100644 index 0000000..30dbfe8 --- /dev/null +++ b/src/test/kotlin/de/darkatra/vrising/discord/persistence/ServerRepositoryTest.kt @@ -0,0 +1,117 @@ +package de.darkatra.vrising.discord.persistence + +import de.darkatra.vrising.discord.DatabaseConfigurationTestUtils +import de.darkatra.vrising.discord.persistence.model.ServerTestUtils +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.assertThrows + +class ServerRepositoryTest { + + private val nitrite: Nitrite = DatabaseConfigurationTestUtils.getTestDatabase() + + private val serverRepository = ServerRepository(nitrite) + + @BeforeEach + fun setUp() { + DatabaseConfigurationTestUtils.clearDatabase(nitrite) + } + + @Test + fun `should get servers`() { + + serverRepository.addServer( + ServerTestUtils.getServer() + ) + + val servers = serverRepository.getServers() + + assertThat(servers).hasSize(1) + + val server = servers.first() + assertThat(server.id).isEqualTo(ServerTestUtils.ID) + assertThat(server.discordServerId).isEqualTo(ServerTestUtils.DISCORD_SERVER_ID) + assertThat(server.hostname).isEqualTo(ServerTestUtils.HOST_NAME) + assertThat(server.queryPort).isEqualTo(ServerTestUtils.QUERY_PORT) + } + + @Test + fun `should get server`() { + + serverRepository.addServer( + ServerTestUtils.getServer() + ) + + val server = serverRepository.getServer(ServerTestUtils.ID) + + assertThat(server).isNotNull() + assertThat(server!!.id).isEqualTo(ServerTestUtils.ID) + assertThat(server.discordServerId).isEqualTo(ServerTestUtils.DISCORD_SERVER_ID) + assertThat(server.hostname).isEqualTo(ServerTestUtils.HOST_NAME) + assertThat(server.queryPort).isEqualTo(ServerTestUtils.QUERY_PORT) + } + + @Test + fun `should get server with discordServerId`() { + + serverRepository.addServer( + ServerTestUtils.getServer() + ) + + val server = serverRepository.getServer(ServerTestUtils.ID, ServerTestUtils.DISCORD_SERVER_ID) + + assertThat(server).isNotNull() + assertThat(server!!.id).isEqualTo(ServerTestUtils.ID) + assertThat(server.discordServerId).isEqualTo(ServerTestUtils.DISCORD_SERVER_ID) + assertThat(server.hostname).isEqualTo(ServerTestUtils.HOST_NAME) + assertThat(server.queryPort).isEqualTo(ServerTestUtils.QUERY_PORT) + } + + @Test + fun `should not get server with non matching discordServerId`() { + + serverRepository.addServer( + ServerTestUtils.getServer() + ) + + val server = serverRepository.getServer(ServerTestUtils.ID, "invalid-discord-server-id") + + assertThat(server).isNull() + } + + @Test + fun `should not update server status monitor with higher version`() { + + val server = ServerTestUtils.getServer() + serverRepository.addServer(server) + + val update1 = serverRepository.getServer(server.id, server.discordServerId)!!.apply { + hostname = "test-1" + } + val update2 = serverRepository.getServer(server.id, server.discordServerId)!!.apply { + hostname = "test-2" + } + + serverRepository.updateServer(update1) + + val e = assertThrows { + serverRepository.updateServer(update2) + } + + assertThat(e.message).isEqualTo("Server with id '${server.id}' was already updated by another thread.") + } + + @Test + fun `should not insert server status monitor when using updateServer`() { + + val e = assertThrows { + serverRepository.updateServer( + ServerTestUtils.getServer() + ) + } + + assertThat(e.message).isEqualTo("Server with id '${ServerTestUtils.ID}' not found.") + } +} diff --git a/src/test/kotlin/de/darkatra/vrising/discord/persistence/model/ServerTestUtils.kt b/src/test/kotlin/de/darkatra/vrising/discord/persistence/model/ServerTestUtils.kt new file mode 100644 index 0000000..46889e6 --- /dev/null +++ b/src/test/kotlin/de/darkatra/vrising/discord/persistence/model/ServerTestUtils.kt @@ -0,0 +1,26 @@ +package de.darkatra.vrising.discord.persistence.model + +import dev.kord.common.entity.Snowflake +import kotlinx.datetime.toKotlinInstant +import java.time.Instant + +object ServerTestUtils { + + const val ID = "id" + val DISCORD_SERVER_ID = Snowflake(Instant.now().toKotlinInstant()).toString() + const val HOST_NAME = "localhost" + const val QUERY_PORT = 8081 + + fun getServer(): Server { + return Server( + id = ID, + version = Version( + revision = 1, + updated = Instant.now() + ), + discordServerId = DISCORD_SERVER_ID, + hostname = HOST_NAME, + queryPort = QUERY_PORT, + ) + } +} diff --git a/src/test/kotlin/de/darkatra/vrising/discord/persistence/model/converter/EntityConverterTest.kt b/src/test/kotlin/de/darkatra/vrising/discord/persistence/model/converter/EntityConverterTest.kt new file mode 100644 index 0000000..e6e97ba --- /dev/null +++ b/src/test/kotlin/de/darkatra/vrising/discord/persistence/model/converter/EntityConverterTest.kt @@ -0,0 +1,107 @@ +package de.darkatra.vrising.discord.persistence.model.converter + +import de.darkatra.vrising.discord.migration.Schema +import de.darkatra.vrising.discord.migration.SchemaEntityConverter +import de.darkatra.vrising.discord.persistence.model.Error +import de.darkatra.vrising.discord.persistence.model.PlayerActivityFeed +import de.darkatra.vrising.discord.persistence.model.PvpKillFeed +import de.darkatra.vrising.discord.persistence.model.Server +import de.darkatra.vrising.discord.persistence.model.ServerTestUtils.DISCORD_SERVER_ID +import de.darkatra.vrising.discord.persistence.model.ServerTestUtils.HOST_NAME +import de.darkatra.vrising.discord.persistence.model.ServerTestUtils.ID +import de.darkatra.vrising.discord.persistence.model.ServerTestUtils.QUERY_PORT +import de.darkatra.vrising.discord.persistence.model.Status +import de.darkatra.vrising.discord.persistence.model.StatusMonitor +import de.darkatra.vrising.discord.persistence.model.Version +import org.assertj.core.api.Assertions.assertThat +import org.dizitart.no2.collection.Document +import org.dizitart.no2.common.mapper.SimpleNitriteMapper +import org.junit.jupiter.api.Test +import java.time.Instant + +class EntityConverterTest { + + private val mapper = SimpleNitriteMapper().apply { + registerEntityConverter(SchemaEntityConverter()) + registerEntityConverter(ErrorEntityConverter()) + registerEntityConverter(PlayerActivityFeedEntityConverter()) + registerEntityConverter(PvpKillFeedEntityConverter()) + registerEntityConverter(ServerEntityConverter()) + registerEntityConverter(StatusMonitorEntityConverter()) + } + + @Test + fun `should not change Server on persistence roundtrip`() { + + val originalServer = Server( + id = ID, + version = Version( + revision = 1, + updated = Instant.now() + ), + discordServerId = DISCORD_SERVER_ID, + hostname = HOST_NAME, + queryPort = QUERY_PORT, + apiHostname = "api-hostname", + apiPort = 8082, + apiUsername = "api-username", + apiPassword = "api-password", + playerActivityFeed = PlayerActivityFeed( + status = Status.ACTIVE, + discordChannelId = "player-activity-feed-discord-channel-id", + lastUpdated = Instant.now(), + currentFailedAttempts = 1, + recentErrors = listOf( + Error( + message = "error-message-1", + timestamp = Instant.now() + ) + ) + ), + pvpKillFeed = PvpKillFeed( + status = Status.INACTIVE, + discordChannelId = "pvp-kill-feed-discord-channel-id", + lastUpdated = Instant.now(), + currentFailedAttempts = 2, + recentErrors = listOf( + Error( + message = "error-message-2", + timestamp = Instant.now() + ) + ) + ), + statusMonitor = StatusMonitor( + status = Status.INACTIVE, + discordChannelId = "status-monitor-discord-channel-id", + displayServerDescription = true, + displayPlayerGearLevel = false, + currentEmbedMessageId = "current-embed-message-id", + currentFailedAttempts = 1, + currentFailedApiAttempts = 2, + recentErrors = listOf( + Error( + message = "error-message-3", + timestamp = Instant.now() + ) + ) + ) + ) + + val document = mapper.tryConvert(originalServer, Document::class.java) as Document + val roundtripServer = mapper.tryConvert(document, Server::class.java) as Server + + assertThat(roundtripServer).isEqualTo(originalServer) + } + + @Test + fun `should not change Schema on persistence roundtrip`() { + + val schema = Schema( + appVersion = "V1.4.2" + ) + + val document = mapper.tryConvert(schema, Document::class.java) + + assertThat(mapper.tryConvert(document, Schema::class.java)).isEqualTo(schema) + } +} diff --git a/src/test/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusMonitorRepositoryTest.kt b/src/test/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusMonitorRepositoryTest.kt deleted file mode 100644 index 73f7662..0000000 --- a/src/test/kotlin/de/darkatra/vrising/discord/serverstatus/ServerStatusMonitorRepositoryTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -package de.darkatra.vrising.discord.serverstatus - -import de.darkatra.vrising.discord.DatabaseConfigurationTestUtils -import de.darkatra.vrising.discord.persistence.ServerStatusMonitorRepository -import de.darkatra.vrising.discord.persistence.model.ServerStatusMonitorStatus -import de.darkatra.vrising.discord.serverstatus.exceptions.OutdatedServerStatusMonitorException -import de.darkatra.vrising.discord.serverstatus.model.ServerStatusMonitorTestUtils -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.assertThrows -import org.junit.jupiter.api.condition.DisabledInNativeImage - -@DisabledInNativeImage -class ServerStatusMonitorRepositoryTest { - - private val nitrite: Nitrite = DatabaseConfigurationTestUtils.getTestDatabase() - - private val serverStatusMonitorRepository = ServerStatusMonitorRepository(nitrite) - - @BeforeEach - fun setUp() { - DatabaseConfigurationTestUtils.clearDatabase(nitrite) - } - - @Test - fun `should get active server status monitors`() { - - serverStatusMonitorRepository.addServerStatusMonitor( - ServerStatusMonitorTestUtils.getServerStatusMonitor(ServerStatusMonitorStatus.ACTIVE) - ) - - val serverStatusMonitors = serverStatusMonitorRepository.getServerStatusMonitors(status = ServerStatusMonitorStatus.ACTIVE) - - assertThat(serverStatusMonitors).hasSize(1) - - val serverStatusMonitor = serverStatusMonitors.first() - 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.queryPort).isEqualTo(ServerStatusMonitorTestUtils.QUERY_PORT) - } - - @Test - fun `should get no active server status monitors`() { - - serverStatusMonitorRepository.addServerStatusMonitor( - ServerStatusMonitorTestUtils.getServerStatusMonitor(ServerStatusMonitorStatus.INACTIVE) - ) - - val serverStatusMonitors = serverStatusMonitorRepository.getServerStatusMonitors(status = ServerStatusMonitorStatus.ACTIVE) - - assertThat(serverStatusMonitors).hasSize(0) - } - - @Test - fun `should not update server status monitor with higher version`() { - - val serverStatusMonitor = ServerStatusMonitorTestUtils.getServerStatusMonitor(ServerStatusMonitorStatus.ACTIVE) - serverStatusMonitorRepository.addServerStatusMonitor(serverStatusMonitor) - - val update1 = serverStatusMonitorRepository.getServerStatusMonitor(serverStatusMonitor.id, serverStatusMonitor.discordServerId)!!.apply { - status = ServerStatusMonitorStatus.INACTIVE - } - val update2 = serverStatusMonitorRepository.getServerStatusMonitor(serverStatusMonitor.id, serverStatusMonitor.discordServerId)!!.apply { - status = ServerStatusMonitorStatus.ACTIVE - } - - serverStatusMonitorRepository.updateServerStatusMonitor(update1) - - val e = assertThrows { - serverStatusMonitorRepository.updateServerStatusMonitor(update2) - } - - assertThat(e.message).isEqualTo("Monitor with id '${serverStatusMonitor.id}' was already updated by another thread.") - } - - @Test - fun `should not insert server status monitor when using updateServerStatusMonitor`() { - - val e = assertThrows { - serverStatusMonitorRepository.updateServerStatusMonitor( - ServerStatusMonitorTestUtils.getServerStatusMonitor(ServerStatusMonitorStatus.ACTIVE) - ) - } - - assertThat(e.message).isEqualTo("Monitor with id '${ServerStatusMonitorTestUtils.ID}' not found.") - } -} diff --git a/src/test/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerStatusMonitorTestUtils.kt b/src/test/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerStatusMonitorTestUtils.kt deleted file mode 100644 index b2e4289..0000000 --- a/src/test/kotlin/de/darkatra/vrising/discord/serverstatus/model/ServerStatusMonitorTestUtils.kt +++ /dev/null @@ -1,29 +0,0 @@ -package de.darkatra.vrising.discord.serverstatus.model - -import de.darkatra.vrising.discord.persistence.model.ServerStatusMonitor -import de.darkatra.vrising.discord.persistence.model.ServerStatusMonitorStatus -import dev.kord.common.entity.Snowflake -import kotlinx.datetime.toKotlinInstant -import java.time.Instant - -object ServerStatusMonitorTestUtils { - - const val ID = "id" - val DISCORD_SERVER_ID = Snowflake(Instant.now().toKotlinInstant()).toString() - val DISCORD_CHANNEL_ID = Snowflake(Instant.now().toKotlinInstant()).toString() - const val HOST_NAME = "localhost" - const val QUERY_PORT = 8081 - - fun getServerStatusMonitor(status: ServerStatusMonitorStatus): ServerStatusMonitor { - return ServerStatusMonitor( - id = ID, - discordServerId = DISCORD_SERVER_ID, - discordChannelId = DISCORD_CHANNEL_ID, - hostname = HOST_NAME, - queryPort = QUERY_PORT, - status = status, - displayServerDescription = true, - displayPlayerGearLevel = true - ) - } -} diff --git a/src/test/resources/persistence/v1.2.db b/src/test/resources/persistence/v1.2.db new file mode 100644 index 0000000..1280a64 Binary files /dev/null and b/src/test/resources/persistence/v1.2.db differ diff --git a/src/test/resources/persistence/v2.10.5.db b/src/test/resources/persistence/v2.10.5.db new file mode 100644 index 0000000..9e04b05 Binary files /dev/null and b/src/test/resources/persistence/v2.10.5.db differ