├── .editorconfig ├── .github └── workflows │ ├── build.yml │ └── test.yml ├── .gitignore ├── .idea └── velocity-discord.iml ├── .prettierignore ├── .prettierrc.yml ├── .run └── Copy output.run.xml ├── LICENSE ├── README.md ├── build.gradle ├── codegen ├── .gitignore ├── .rustfmt.toml ├── Cargo.lock ├── Cargo.toml ├── src │ ├── generate.rs │ ├── java_type.rs │ ├── main.rs │ ├── process.rs │ ├── properties │ │ ├── boolean.rs │ │ ├── class.rs │ │ ├── color.rs │ │ ├── enum.rs │ │ ├── integer.rs │ │ ├── list.rs │ │ ├── map.rs │ │ ├── mod.rs │ │ └── string.rs │ ├── schema │ │ └── mod.rs │ ├── structures.rs │ └── utils.rs └── test.json ├── deps └── .gitkeep ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── schema.json ├── settings.gradle ├── src ├── main │ ├── java │ │ └── ooo │ │ │ └── foooooooooooo │ │ │ └── velocitydiscord │ │ │ ├── Constants.java │ │ │ ├── VelocityDiscord.java │ │ │ ├── VelocityListener.java │ │ │ ├── commands │ │ │ ├── Commands.java │ │ │ ├── ReloadCommand.java │ │ │ └── TopicPreviewCommand.java │ │ │ ├── compat │ │ │ └── LuckPerms.java │ │ │ ├── config │ │ │ ├── Config.java │ │ │ ├── PluginConfig.java │ │ │ ├── ServerConfig.java │ │ │ └── definitions │ │ │ │ ├── ChannelTopicConfig.java │ │ │ │ ├── ChatConfig.java │ │ │ │ ├── DiscordConfig.java │ │ │ │ ├── GlobalChatConfig.java │ │ │ │ ├── GlobalConfig.java │ │ │ │ ├── GlobalDiscordConfig.java │ │ │ │ ├── GlobalMinecraftConfig.java │ │ │ │ ├── LocalConfig.java │ │ │ │ ├── MinecraftConfig.java │ │ │ │ ├── SystemMessageConfig.java │ │ │ │ ├── SystemMessageType.java │ │ │ │ ├── UserMessageConfig.java │ │ │ │ ├── UserMessageType.java │ │ │ │ ├── WebhookConfig.java │ │ │ │ └── commands │ │ │ │ ├── CommandConfig.java │ │ │ │ ├── GlobalCommandConfig.java │ │ │ │ ├── GlobalListCommandConfig.java │ │ │ │ └── ListCommandConfig.java │ │ │ ├── discord │ │ │ ├── Discord.java │ │ │ ├── MessageCategory.java │ │ │ ├── MessageListener.java │ │ │ ├── commands │ │ │ │ ├── ICommand.java │ │ │ │ └── ListCommand.java │ │ │ └── message │ │ │ │ └── IQueuedMessage.java │ │ │ ├── util │ │ │ └── StringTemplate.java │ │ │ └── yep │ │ │ └── YepListener.java │ └── resources │ │ └── config.toml └── test │ ├── java │ └── ooo │ │ └── foooooooooooo │ │ └── velocitydiscord │ │ └── config │ │ ├── PluginConfigTests.java │ │ ├── TestUtils.java │ │ └── WebhookConfigTests.java │ └── resources │ ├── config.toml │ └── real_test_config.toml └── test.nu /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | indent_style = space 4 | indent_size = 2 5 | ij_continuation_indent_size = 2 6 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Gradle build 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'src/**' 7 | - 'build.gradle' 8 | - 'settings.gradle' 9 | - 'gradle.properties' 10 | - 'gradlew' 11 | - 'gradlew.bat' 12 | - 'gradle/wrapper/**' 13 | - '.github/workflows/build.yml' 14 | pull_request: 15 | paths: 16 | - 'src/**' 17 | - 'build.gradle' 18 | - 'settings.gradle' 19 | - 'gradle.properties' 20 | - 'gradlew' 21 | - 'gradlew.bat' 22 | - 'gradle/wrapper/**' 23 | - '.github/workflows/build.yml' 24 | 25 | jobs: 26 | gradle: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout sources 30 | uses: actions/checkout@v4 31 | 32 | - name: Setup Java 33 | uses: actions/setup-java@v4 34 | with: 35 | distribution: 'temurin' 36 | java-version: 21 37 | 38 | - name: Setup Gradle 39 | uses: gradle/actions/setup-gradle@v4 40 | 41 | - name: Build with Gradle 42 | run: ./gradlew build 43 | 44 | - name: Upload artifacts 45 | uses: actions/upload-artifact@v4 46 | with: 47 | name: Artifacts 48 | path: build/libs/ 49 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: JUnit Tests 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'src/**' 7 | - 'build.gradle' 8 | - 'settings.gradle' 9 | - 'gradle.properties' 10 | - 'gradlew' 11 | - 'gradlew.bat' 12 | - 'gradle/wrapper/**' 13 | - '.github/workflows/test.yml' 14 | pull_request: 15 | paths: 16 | - 'src/**' 17 | - 'build.gradle' 18 | - 'settings.gradle' 19 | - 'gradle.properties' 20 | - 'gradlew' 21 | - 'gradlew.bat' 22 | - 'gradle/wrapper/**' 23 | - '.github/workflows/test.yml' 24 | 25 | jobs: 26 | gradle: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout sources 30 | uses: actions/checkout@v4 31 | 32 | - name: Setup Java 33 | uses: actions/setup-java@v4 34 | with: 35 | distribution: 'temurin' 36 | java-version: 21 37 | 38 | - name: Setup Gradle 39 | uses: gradle/actions/setup-gradle@v4 40 | 41 | - name: Test with Gradle 42 | run: ./gradlew test 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | !.idea/velocity-discord.iml 3 | .run 4 | 5 | .vscode 6 | .gradle/ 7 | 8 | build/ 9 | run/ 10 | bin/ 11 | 12 | .env 13 | 14 | deps/*.jar 15 | -------------------------------------------------------------------------------- /.idea/velocity-discord.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | VELOCITY 8 | ADVENTURE 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | codegen/target 2 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | tabWidth: 2 2 | useTabs: false 3 | endOfLine: lf 4 | printWidth: 120 5 | singleQuote: true 6 | trailingComma: all 7 | semi: true 8 | -------------------------------------------------------------------------------- /.run/Copy output.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Velocity Discord 2 | 3 | Chat from all servers gets bridged with a discord channel 4 | 5 | ## Features 6 | 7 | - Configurable 8 | - Webhooks or embeds or normal text for messages 9 | - Player count in bot status 10 | - List command 11 | - Templating syntax for all messages 12 | - Death and Advancement messages shown 13 | - Server start/stop messages 14 | - Server status in channel topic 15 | - Reload command for config changes while the server is running 16 | 17 | > **Note** 18 | > This requires a [companion Velocity plugin](https://github.com/unilock/YepLib) 19 | > and [companion backend mod/plugin](https://github.com/unilock/YepTwo) for advancement/death messages 20 | 21 | ## Installation 22 | 23 | 1. Create a bot application [here](https://discordapp.com/developers/applications/) 24 | - Go to the `Bot` tab and click `Add bot` 25 | 2. Enable the `SERVER MEMBERS INTENT` and `MESSAGE CONTENT INTENT` under `Privileged Gateway Intents` 26 | 3. Copy the bot's token, you might have to click `Reset Token` first 27 | 4. Install the plugin on your server, start the server once, then stop the server again 28 | 5. Open the plugin config file at `plugins/discord/config.toml` 29 | 6. Under `[discord]`, paste your token in place of `TOKEN` 30 | 7. Under `[discord]`, paste the channel id you want to use 31 | - To get a channel id, you have to enable developer mode in Discord 32 | - Open Discord settings, go to `Advanced`, then turn on `Developer Mode` 33 | - Now right-click the channel you want to use and click `Copy ID` 34 | 8. Set any additional config options you want 35 | 9. Start the server and check if it works 36 | 37 | ### For Webhooks 38 | 39 | 1. Create a webhook in the channel you want to use 40 | - Right-click the channel, click `Edit Channel`, go to `Integrations`, click `Create Webhook` 41 | - Copy the webhook URL 42 | 2. Paste the webhook URL under `[discord.webhook]` in the config file 43 | 44 | ### For advancements/deaths 45 | 46 | 1. Install the [YepLib](https://github.com/unilock/YepLib) velocity plugin alongside this plugin 47 | 2. Install the [YepTwo](https://github.com/unilock/YepTwo) backend mod/plugin on each of your backend servers that you want to 48 | receive advancements/deaths from 49 | 50 | ## Configuration 51 | 52 | Default config generated on startup: 53 | 54 | ```toml 55 | # Don't change this 56 | config_version = "2.0" 57 | 58 | # Comma separated list of server names to exclude from the bridge (defined under [servers] inside your velocity.toml) 59 | # e.g., exclude_servers = ["lobby", "survival"] 60 | exclude_servers = [] 61 | excluded_servers_receive_messages = false 62 | 63 | # How often to ping all servers to check for online status (seconds) 64 | # Excluded servers will not be pinged 65 | # Use a value of 0 to disable 66 | ping_interval = 30 67 | 68 | # Server display names 69 | # If a server is not found in this list, the server name (from velocity.toml) will be used instead 70 | [server_names] 71 | # lobby = "Lobby" 72 | 73 | [discord] 74 | # Bot token from https://discordapp.com/developers/applications/ 75 | # Not server overridable 76 | token = "TOKEN" 77 | # Default channel ID to send Minecraft chat messages to 78 | channel = "000000000000000000" 79 | 80 | # Show messages from bots in Minecraft chat 81 | show_bot_messages = false 82 | # Show clickable links for attachments in Minecraft chat 83 | show_attachments_ingame = true 84 | 85 | # Activity text of the bot to show in Discord 86 | # Placeholders available: {amount} 87 | # Can be disabled with "" or false 88 | # Not server overridable 89 | activity_text = "with {amount} players online" 90 | 91 | # Enable mentioning Discord users from Minecraft chat 92 | enable_mentions = true 93 | # Enable @everyone and @here pings from Minecraft chat 94 | enable_everyone_and_here = false 95 | 96 | # Interval (in minutes) for updating the channel topic 97 | # Use a value of 0 to disable 98 | # Not server overridable 99 | update_channel_topic_interval = 0 100 | 101 | # Channel topic config (if enabled) 102 | [discord.channel_topic] 103 | # Template for the channel topic 104 | # Placeholders available: 105 | # {players} - Total number of players online 106 | # {player_list} - List of players (format is defined below) 107 | # {servers} - Number of servers 108 | # {server_list} - List of server names 109 | # {hostname} - Server hostname 110 | # {port} - Server port 111 | # {motd} - Message of the Day (MOTD) 112 | # {query_port} - Query port 113 | # {max_players} - Maximum number of players 114 | # {plugins} - Number of plugins 115 | # {plugin_list} - List of plugin names 116 | # {version} - Server version 117 | # {software} - Software name 118 | # {average_ping} - Average ping of all players 119 | # {uptime} - Server uptime in hours and minutes 120 | # {server[SERVERNAME]} - Dynamic placeholder for each server's name and status (e.g., {server[MyServer]}, {server[AnotherServer]}, {server[Lobby]}, etc.) 121 | format = """{players}/{max_players} 122 | {player_list} 123 | {hostname}:{port} 124 | Uptime: {uptime}""" 125 | 126 | # Template for server[SERVERNAME] placeholder in the channel topic 127 | # Placeholders available: {name}, {players}, {max_players}, {motd}, {version}, {protocol} 128 | server = "{name}: {players}/{max_players}" 129 | 130 | # Template for server[SERVERNAME] placeholder in the channel topic when the server is offline 131 | # Placeholders available: {name} 132 | server_offline = "{name}: Offline" 133 | 134 | # Can be disabled with "" or false to hide the list completely when no players are online 135 | player_list_no_players_header = "No players online" 136 | 137 | # Can be disabled with "" or false to hide the header and only show the player list 138 | player_list_header = "Players: " 139 | 140 | # Placeholders available: {username}, {ping} 141 | player_list_player = "{username}" 142 | 143 | # Separator between players in the list, \n can be used for new line 144 | player_list_separator = ", " 145 | 146 | # Maximum number of players to show in the topic 147 | # Set to 0 to show all players 148 | player_list_max_count = 10 149 | 150 | [discord.webhook] 151 | # Full webhook URL to send chat messages to 152 | webhook_url = "" 153 | # Full URL of an avatar service to get the player's avatar from 154 | # Placeholders available: {uuid}, {username} 155 | avatar_url = "https://visage.surgeplay.com/face/96/{uuid}" 156 | 157 | # The format of the webhook's username 158 | # Placeholders available: {username}, {server} 159 | webhook_username = "{username}" 160 | 161 | # Minecraft > Discord message formats 162 | # Uses the same formatting as the Discord client (a subset of markdown) 163 | # 164 | # Messages can be disabled by setting format to empty string ("") or false 165 | # 166 | # type can be one of the following: 167 | # "text" - Normal text only message with the associated x_message format 168 | # "embed" - Discord embed with the associated x_message format as the description field 169 | # Default for all is "text" 170 | # 171 | # embed_color is the color of the embed, in #RRGGBB format 172 | [discord.chat.message] 173 | # Placeholders available: {username}, {prefix}, {server}, {message} 174 | # Can be disabled with "" or false 175 | format = "{username}: {message}" 176 | 177 | # for user messages, the following types can be used 178 | # "text" - Normal text only message with the above 179 | # 180 | # "webhook" - Use a Discord webhook to have the bot use the player's username and avatar when sending messages 181 | # Requires a webhook URL to be set below 182 | # Ignores the above message format, and just sends the message as the content of the webhook 183 | # 184 | # "embed" - Discord embed with the above format as the description field 185 | type = "text" 186 | # Can be disabled with "" or false 187 | embed_color = "" 188 | # Channel override for this message type, set to "" or false or remove to use the default channel 189 | # Can be applied to all message types 190 | # channel = "000000000000000000" 191 | 192 | [discord.chat.join] 193 | # Placeholders available: {username}, {prefix}, {server} 194 | # Can be disabled with "" or false 195 | format = "**{username} joined the game**" 196 | type = "text" 197 | # Can be disabled with "" or false 198 | embed_color = "#40bf4f" 199 | 200 | [discord.chat.leave] 201 | # Placeholders available: {username}, {prefix}, {server} 202 | # Can be disabled with "" or false 203 | format = "**{username} left the game**" 204 | type = "text" 205 | # Can be disabled with "" or false 206 | embed_color = "#bf4040" 207 | 208 | [discord.chat.disconnect] 209 | # Possible different format for timeouts or other terminating connections 210 | # Placeholders available: {username}, {prefix} 211 | # Can be disabled with "" or false 212 | format = "**{username} disconnected**" 213 | type = "text" 214 | # Can be disabled with "" or false 215 | embed_color = "#bf4040" 216 | 217 | [discord.chat.server_switch] 218 | # Placeholders available: {username}, {prefix}, {current}, {previous} 219 | # Can be disabled with "" or false 220 | format = "**{username} moved to {current} from {previous}**" 221 | type = "text" 222 | # Can be disabled with "" or false 223 | embed_color = "#40bf4f" 224 | 225 | [discord.chat.death] 226 | # Placeholders available: {username}, {death_message} 227 | # death_message includes the username just as it is shown ingame 228 | # Can be disabled with "" or false 229 | format = "**{death_message}**" 230 | type = "text" 231 | # Can be disabled with "" or false 232 | embed_color = "#bf4040" 233 | 234 | [discord.chat.advancement] 235 | # Placeholders available: {username}, {advancement_title}, {advancement_description} 236 | # Can be disabled with "" or false 237 | format = "**{username} has made the advancement __{advancement_title}__**\n_{advancement_description}_" 238 | type = "text" 239 | # Can be disabled with "" or false 240 | embed_color = "#40bf4f" 241 | 242 | # Not server overridable 243 | [discord.chat.proxy_start] 244 | # Can be disabled with "" or false 245 | format = "**Proxy started**" 246 | type = "text" 247 | # Can be disabled with "" or false 248 | embed_color = "#40bf4f" 249 | 250 | # Not server overridable 251 | [discord.chat.proxy_stop] 252 | # Can be disabled with "" or false 253 | format = "**Proxy stopped**" 254 | type = "text" 255 | # Can be disabled with "" or false 256 | embed_color = "#bf4040" 257 | 258 | [discord.chat.server_start] 259 | # Placeholders available: {server} 260 | # Can be disabled with "" or false 261 | format = "**{server} has started**" 262 | type = "text" 263 | # Can be disabled with "" or false 264 | embed_color = "#40bf4f" 265 | 266 | [discord.chat.server_stop] 267 | # Placeholders available: {server} 268 | # Can be disabled with "" or false 269 | format = "**{server} has stopped**" 270 | type = "text" 271 | # Can be disabled with "" or false 272 | embed_color = "#bf4040" 273 | 274 | [discord.commands.list] 275 | # Not server overridable 276 | enabled = true 277 | 278 | # Ephemeral messages are only visible to the user who sent the command 279 | # Not server overridable 280 | ephemeral = true 281 | 282 | # Placeholders available: {server_name}, {online_players}, {max_players} 283 | server_format = "[{server_name} {online_players}/{max_players}]" 284 | 285 | # Placeholders available: {username} 286 | player_format = "- {username}" 287 | 288 | # Can be disabled with "" or false 289 | no_players = "No players online" 290 | 291 | # Can be disabled with "" or false 292 | server_offline = "Server offline" 293 | # Not server overridable 294 | codeblock_lang = "asciidoc" 295 | 296 | # Discord > Minecraft message formats 297 | # Uses XML-like formatting with https://docs.advntr.dev/minimessage/format.html 298 | [minecraft] 299 | # Ingame command for plugin 300 | # Not server overridable 301 | # e.g., /discord, /discord reload, /discord topic preview 302 | plugin_command = "discord" 303 | 304 | # Placeholders available: {discord} 305 | discord_chunk = "[<{discord_color}>Discord]" 306 | 307 | # Placeholders available: {role_color}, {display_name}, {username}, {nickname} 308 | # tag allows you to shift right-click the username to insert @{username} in the chat 309 | username_chunk = "<{role_color}>{nickname}" 310 | 311 | # Placeholders available: {discord_chunk}, {username_chunk}, {attachments}, {message} 312 | message = "{discord_chunk} {role_prefix} {username_chunk}: {message} {attachments}" 313 | 314 | # Placeholders available: {url}, {attachment_color} 315 | attachments = "[<{attachment_color}>Attachment]" 316 | 317 | # Placeholders available: {url}, {link_color} 318 | # Can be disabled with "" or false 319 | links = "[<{link_color}>Link]" 320 | 321 | # Colors for the <{discord_color}>, <{attachment_color}> and <{link_color}> tags 322 | discord_color = "#7289da" 323 | attachment_color = "#4abdff" 324 | link_color = "#4abdff" 325 | 326 | # Role prefix configuration 327 | # Format: "role_id" = "prefix format using MiniMessage" 328 | [minecraft.role_prefixes] 329 | # "123456789" = "[OWNER]" 330 | # "987654321" = "[ADMIN]" 331 | # "456789123" = "[MOD]" 332 | # "789123456" = "[HELPER]" 333 | 334 | # Override config for specific servers 335 | # Any config option under [discord] or [minecraft] can be overridden (other than options labelled not server overridable) 336 | # Format: [override.(velocity.toml server name).discord] or [override.(velocity.toml server name).minecraft] 337 | # Example: 338 | # [override.lobby.discord] 339 | # channel = "000000000000000000" 340 | ``` 341 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.gradleup.shadow' version '8.3.0' 3 | id 'java-library' 4 | } 5 | 6 | version = project.version 7 | 8 | configurations { 9 | shade 10 | } 11 | 12 | repositories { 13 | maven { 14 | name = 'papermc' 15 | url = 'https://repo.papermc.io/repository/maven-public/' 16 | } 17 | maven { 18 | name = 'jitpack' 19 | url = 'https://jitpack.io' 20 | } 21 | } 22 | 23 | dependencies { 24 | compileOnly("com.velocitypowered:velocity-api:$velocity_version") { 25 | changing = true 26 | } 27 | annotationProcessor("com.velocitypowered:velocity-api:$velocity_version") { 28 | changing = true 29 | } 30 | 31 | shade implementation("net.dv8tion:JDA:$jda_version") { 32 | exclude module: 'opus-java' 33 | } 34 | 35 | compileOnly "net.kyori:adventure-text-minimessage:$minimessage_version" 36 | 37 | implementation "com.electronwill.night-config:toml:$night_config_version" 38 | implementation "com.github.unilock:yeplib:$yeplib_version" 39 | 40 | testImplementation platform("org.junit:junit-bom:$junit_version") 41 | testImplementation 'org.junit.jupiter:junit-jupiter' 42 | testImplementation "com.electronwill.night-config:toml:$night_config_version" 43 | testImplementation "ch.qos.logback:logback-classic:$logback_version" 44 | 45 | compileOnly 'net.luckperms:api:5.4' 46 | } 47 | 48 | shadowJar { 49 | setArchiveClassifier(null) 50 | setConfigurations([project.configurations.shade]) 51 | relocate("net.dv8tion", "ooo.foooooooooooo.velocitydiscord.lib.net.dv8tion") 52 | minimize() 53 | } 54 | 55 | build { 56 | dependsOn shadowJar 57 | } 58 | 59 | test { 60 | useJUnitPlatform() 61 | testLogging { 62 | events "passed", "skipped", "failed" 63 | showStandardStreams = true 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /codegen/.gitignore: -------------------------------------------------------------------------------- 1 | output/ 2 | target/ 3 | -------------------------------------------------------------------------------- /codegen/.rustfmt.toml: -------------------------------------------------------------------------------- 1 | tab_spaces = 2 2 | edition = "2021" 3 | max_width = 120 4 | group_imports = "StdExternalCrate" 5 | reorder_impl_items = true 6 | reorder_modules = true 7 | condense_wildcard_suffixes = true 8 | format_code_in_doc_comments = true 9 | format_macro_matchers = true 10 | format_macro_bodies = true 11 | hex_literal_case = "Upper" 12 | normalize_comments = true 13 | normalize_doc_attributes = true 14 | unstable_features = true 15 | use_field_init_shorthand = true 16 | use_try_shorthand = true 17 | where_single_line = true 18 | wrap_comments = true 19 | imports_granularity = "Module" 20 | newline_style = "Unix" 21 | -------------------------------------------------------------------------------- /codegen/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "anyhow" 7 | version = "1.0.98" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" 10 | 11 | [[package]] 12 | name = "codegen" 13 | version = "0.1.0" 14 | dependencies = [ 15 | "anyhow", 16 | "convert_case", 17 | "serde", 18 | "serde_json", 19 | ] 20 | 21 | [[package]] 22 | name = "convert_case" 23 | version = "0.8.0" 24 | source = "registry+https://github.com/rust-lang/crates.io-index" 25 | checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" 26 | dependencies = [ 27 | "unicode-segmentation", 28 | ] 29 | 30 | [[package]] 31 | name = "itoa" 32 | version = "1.0.15" 33 | source = "registry+https://github.com/rust-lang/crates.io-index" 34 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 35 | 36 | [[package]] 37 | name = "memchr" 38 | version = "2.7.5" 39 | source = "registry+https://github.com/rust-lang/crates.io-index" 40 | checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" 41 | 42 | [[package]] 43 | name = "proc-macro2" 44 | version = "1.0.95" 45 | source = "registry+https://github.com/rust-lang/crates.io-index" 46 | checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 47 | dependencies = [ 48 | "unicode-ident", 49 | ] 50 | 51 | [[package]] 52 | name = "quote" 53 | version = "1.0.40" 54 | source = "registry+https://github.com/rust-lang/crates.io-index" 55 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 56 | dependencies = [ 57 | "proc-macro2", 58 | ] 59 | 60 | [[package]] 61 | name = "ryu" 62 | version = "1.0.20" 63 | source = "registry+https://github.com/rust-lang/crates.io-index" 64 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 65 | 66 | [[package]] 67 | name = "serde" 68 | version = "1.0.219" 69 | source = "registry+https://github.com/rust-lang/crates.io-index" 70 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 71 | dependencies = [ 72 | "serde_derive", 73 | ] 74 | 75 | [[package]] 76 | name = "serde_derive" 77 | version = "1.0.219" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 80 | dependencies = [ 81 | "proc-macro2", 82 | "quote", 83 | "syn", 84 | ] 85 | 86 | [[package]] 87 | name = "serde_json" 88 | version = "1.0.140" 89 | source = "registry+https://github.com/rust-lang/crates.io-index" 90 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 91 | dependencies = [ 92 | "itoa", 93 | "memchr", 94 | "ryu", 95 | "serde", 96 | ] 97 | 98 | [[package]] 99 | name = "syn" 100 | version = "2.0.104" 101 | source = "registry+https://github.com/rust-lang/crates.io-index" 102 | checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" 103 | dependencies = [ 104 | "proc-macro2", 105 | "quote", 106 | "unicode-ident", 107 | ] 108 | 109 | [[package]] 110 | name = "unicode-ident" 111 | version = "1.0.18" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 114 | 115 | [[package]] 116 | name = "unicode-segmentation" 117 | version = "1.12.0" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 120 | -------------------------------------------------------------------------------- /codegen/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "codegen" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | anyhow = "1.0.98" 8 | convert_case = "0.8.0" 9 | serde = { version = "1.0.219", features = ["derive"] } 10 | serde_json = "1.0.140" 11 | -------------------------------------------------------------------------------- /codegen/src/generate.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | use std::fmt::Write as _; 3 | 4 | use convert_case::{Case, Casing}; 5 | 6 | use crate::all_properties; 7 | use crate::properties::boolean::BooleanProperty; 8 | use crate::properties::color::ColorProperty; 9 | use crate::properties::r#enum::EnumProperty; 10 | use crate::properties::integer::IntegerProperty; 11 | use crate::properties::string::StringProperty; 12 | use crate::properties::{AsJava, Property}; 13 | use crate::structures::{Class, Enum, Structure}; 14 | use crate::utils::{default_property_name, pascal, string_literal}; 15 | 16 | const GENERATED_HEADER: &str = "// this file is generated\n// do not edit manually\n"; 17 | const PACKAGE_NAME: &str = "ooo.foooooooooooo.velocitydiscord.config.generated"; 18 | 19 | const MAP_IMPORT: &str = "java.util.Map"; 20 | const LIST_IMPORT: &str = "java.util.List"; 21 | const OPTIONAL_IMPORT: &str = "java.util.Optional"; 22 | const NIGHTCONFIG_IMPORT: &str = "com.electronwill.nightconfig.core.Config"; 23 | const CONFIG_UTILS_IMPORT: &str = "ooo.foooooooooooo.velocitydiscord.config.ConfigUtils"; 24 | const COLOR_IMPORT: &str = "java.awt.Color"; 25 | 26 | fn generate_class(name: &str, class: Class) -> anyhow::Result { 27 | let mut content = String::new(); 28 | let name = pascal(name); 29 | 30 | let mut imports = HashSet::new(); 31 | 32 | imports.insert(NIGHTCONFIG_IMPORT); 33 | 34 | for property in class.properties.values() { 35 | let property_imports: &[&'static str] = match property { 36 | Property::Map(_) => &[MAP_IMPORT], 37 | Property::List(_) => &[LIST_IMPORT], 38 | Property::String(StringProperty::DisableableWithDefault { .. }) => &[OPTIONAL_IMPORT, CONFIG_UTILS_IMPORT], 39 | Property::String(StringProperty::DisableableWithoutDefault) => &[OPTIONAL_IMPORT, CONFIG_UTILS_IMPORT], 40 | Property::String(_) => &[CONFIG_UTILS_IMPORT], 41 | Property::Color(ColorProperty::DisableableWithDefault { .. }) => { 42 | &[OPTIONAL_IMPORT, CONFIG_UTILS_IMPORT, COLOR_IMPORT] 43 | } 44 | Property::Color(ColorProperty::DisableableWithoutDefault) => { 45 | &[OPTIONAL_IMPORT, CONFIG_UTILS_IMPORT, COLOR_IMPORT] 46 | } 47 | Property::Color(_) => &[CONFIG_UTILS_IMPORT, COLOR_IMPORT], 48 | Property::Integer(IntegerProperty::DisableableWithDefault { .. }) => &[OPTIONAL_IMPORT, CONFIG_UTILS_IMPORT], 49 | Property::Integer(IntegerProperty::DisableableWithoutDefault) => &[OPTIONAL_IMPORT, CONFIG_UTILS_IMPORT], 50 | Property::Integer(_) => &[CONFIG_UTILS_IMPORT], 51 | Property::Boolean(BooleanProperty::NormalWithDefault { .. }) => &[], 52 | Property::Boolean(BooleanProperty::NormalWithoutDefault) => &[], 53 | Property::Enum(EnumProperty::DisableableWithDefault { .. }) => &[OPTIONAL_IMPORT], 54 | Property::Enum(EnumProperty::DisableableWithoutDefault { .. }) => &[OPTIONAL_IMPORT], 55 | Property::Enum(_) => &[], 56 | Property::Class(_) => &[], 57 | }; 58 | 59 | for import in property_imports { 60 | imports.insert(*import); 61 | } 62 | } 63 | 64 | writeln!(content, "{GENERATED_HEADER}")?; 65 | writeln!(content, "package {PACKAGE_NAME};")?; 66 | writeln!(content)?; 67 | 68 | let optional_used = imports.contains(OPTIONAL_IMPORT); 69 | 70 | let mut imports: Vec<_> = imports.into_iter().collect(); 71 | imports.sort_unstable(); 72 | 73 | for import in imports { 74 | writeln!(content, "import {import};")?; 75 | } 76 | 77 | writeln!(content)?; 78 | 79 | if optional_used { 80 | writeln!(content, r#"@SuppressWarnings("OptionalUsedAsFieldOrParameterType")"#)?; 81 | } 82 | 83 | writeln!(content, "public class {name} {{")?; 84 | 85 | let mut properties = class.properties.into_iter().collect::>(); 86 | properties.sort_unstable_by_key(|(prop_name, _)| prop_name.to_owned()); 87 | 88 | for (prop_name, prop) in &properties { 89 | let property = all_properties!(prop, prop.as_property_type()); 90 | 91 | writeln!(content, " public {property} {prop_name};")?; 92 | } 93 | 94 | writeln!(content)?; 95 | 96 | // load 97 | // this.channel = ConfigUtils.parseDisableableString(config, "channel"); 98 | // override 99 | // this.channel = ConfigUtils.maybeOverride(config, "channel", this.channel, () 100 | // -> ConfigUtils.parseDisableableString(config, "channel")); 101 | 102 | // defaults 103 | 104 | let mut has_defaults = false; 105 | 106 | for (prop_name, prop) in &properties { 107 | let property = all_properties!( 108 | prop, 109 | prop.as_default().map(|default| (prop.as_property_type(), default)) 110 | ); 111 | 112 | if let Some((kind, default)) = property { 113 | has_defaults = true; 114 | let default_key = default_property_name(prop_name); 115 | writeln!(content, " private static final {kind} {default_key} = {default};")?; 116 | } 117 | } 118 | 119 | if has_defaults { 120 | writeln!(content)?; 121 | } 122 | 123 | writeln!(content, " public {name} load(Config config) {{")?; 124 | writeln!(content, " if (config == null) return this;")?; 125 | for (name, prop) in &properties { 126 | let key = string_literal(name); 127 | let default_property = default_property_name(name); 128 | 129 | let parse_function = all_properties!(prop, prop.as_parse_function(&key, &default_property)); 130 | 131 | writeln!(content, " this.{name} = {parse_function};")?; 132 | } 133 | 134 | writeln!(content)?; 135 | writeln!(content, " return this;")?; 136 | writeln!(content, " }}")?; 137 | 138 | writeln!(content)?; 139 | 140 | writeln!(content, " public void override(Config config) {{")?; 141 | for (name, prop) in properties { 142 | let key = string_literal(&name); 143 | let default_property = default_property_name(&name); 144 | 145 | if matches!(prop, Property::Class(_)) { 146 | // dont override classes, call override on them 147 | writeln!(content, " this.{name}.override(config.get({key}));")?; 148 | continue; 149 | }; 150 | 151 | let parse_function = all_properties!(prop, prop.as_parse_function(&key, &default_property)); 152 | 153 | writeln!( 154 | content, 155 | " this.{name} = ConfigUtils.maybeOverride(config, {key}, this.{name}, () -> {parse_function});" 156 | )?; 157 | } 158 | writeln!(content, " }}")?; 159 | 160 | writeln!(content, "}}")?; 161 | 162 | Ok(content) 163 | } 164 | 165 | fn generate_enum(Enum { name, variants }: Enum) -> anyhow::Result { 166 | let mut content = String::new(); 167 | let name = pascal(name); 168 | 169 | writeln!(content, "{GENERATED_HEADER}")?; 170 | writeln!(content, "package {PACKAGE_NAME};")?; 171 | writeln!(content)?; 172 | 173 | writeln!(content, "public enum {name} {{")?; 174 | 175 | let key_to_variant: HashMap = variants 176 | .iter() 177 | .map(|v| (v.to_owned(), v.to_case(Case::UpperSnake))) 178 | .collect(); 179 | 180 | for (i, variant) in key_to_variant.values().enumerate() { 181 | if i > 0 { 182 | writeln!(content, ",")?; 183 | } 184 | 185 | write!(content, " {variant}")?; 186 | } 187 | writeln!(content, ";")?; 188 | 189 | writeln!(content)?; 190 | 191 | writeln!(content, " public static {name} from(String value) {{")?; 192 | writeln!(content, " return switch (value) {{")?; 193 | for (key, variant) in key_to_variant { 194 | let key = string_literal(&key); 195 | writeln!(content, " case {key} -> {variant};")?; 196 | } 197 | writeln!( 198 | content, 199 | " default -> throw new IllegalArgumentException(\"Unknown {name} enum value: \" + value);" 200 | )?; 201 | writeln!(content, " }};")?; 202 | writeln!(content, " }}")?; 203 | 204 | writeln!(content, "}}")?; 205 | 206 | Ok(content) 207 | } 208 | 209 | pub fn generate_structure(name: &str, structure: Structure) -> anyhow::Result<(String, String)> { 210 | let content = match structure { 211 | Structure::Class(class) => generate_class(name, class)?, 212 | Structure::Enum(r#enum) => generate_enum(r#enum)?, 213 | }; 214 | 215 | Ok((pascal(name), content)) 216 | } 217 | -------------------------------------------------------------------------------- /codegen/src/java_type.rs: -------------------------------------------------------------------------------- 1 | use anyhow::bail; 2 | 3 | use crate::schema::SimpleType; 4 | 5 | pub enum JavaType { 6 | String, 7 | Integer, 8 | Boolean, 9 | } 10 | 11 | impl TryFrom for JavaType { 12 | type Error = anyhow::Error; 13 | 14 | fn try_from(value: SimpleType) -> Result { 15 | match value { 16 | SimpleType::String => Ok(JavaType::String), 17 | SimpleType::Integer => Ok(JavaType::Integer), 18 | SimpleType::Boolean => Ok(JavaType::Boolean), 19 | _ => bail!("unsupported type: {value}"), 20 | } 21 | } 22 | } 23 | 24 | impl std::fmt::Display for JavaType { 25 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 26 | match self { 27 | JavaType::String => write!(f, "String"), 28 | JavaType::Integer => write!(f, "Integer"), 29 | JavaType::Boolean => write!(f, "Boolean"), 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /codegen/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::fs::{File, OpenOptions}; 2 | use std::io::Write; 3 | use std::path::Path; 4 | 5 | use anyhow::{Context as _, bail}; 6 | 7 | use crate::generate::generate_structure; 8 | use crate::process::{Context, collect_definitions, process_class}; 9 | use crate::schema::Schema; 10 | use crate::utils::display_path; 11 | 12 | pub mod generate; 13 | pub mod java_type; 14 | #[macro_use] 15 | pub mod properties; 16 | pub mod process; 17 | pub mod schema; 18 | pub mod structures; 19 | pub mod utils; 20 | 21 | const REMOVE_OLD_ARG: &str = "--remove-old"; 22 | 23 | fn main() -> anyhow::Result<()> { 24 | let args = std::env::args().collect::>(); 25 | if args.len() != 3 && args.len() != 4 { 26 | bail!("Usage: {} [{REMOVE_OLD_ARG}]", args[0]); 27 | } 28 | 29 | if args.len() == 4 && args[3] != REMOVE_OLD_ARG { 30 | bail!("Unknown argument: {}", args[3]); 31 | } 32 | 33 | let remove_old = args.len() == 4 && args[3] == REMOVE_OLD_ARG; 34 | 35 | let schema_file = &args[1]; 36 | let output_dir = &args[2]; 37 | 38 | // check if schema file exists 39 | let schema_file = Path::new(schema_file); 40 | if !schema_file.exists() { 41 | bail!("Schema file '{}' does not exist", display_path(schema_file)); 42 | } 43 | 44 | // check if output directory exists 45 | let output_dir = Path::new(output_dir); 46 | if !output_dir.exists() { 47 | bail!("Output directory '{}' does not exist", display_path(output_dir)); 48 | } 49 | 50 | if remove_old { 51 | println!("Removing existing generated files in {}", display_path(output_dir)); 52 | 53 | for entry in output_dir 54 | .read_dir() 55 | .with_context(|| format!("Failed to read output directory: {}", display_path(output_dir)))? 56 | { 57 | let entry = entry.with_context(|| "Failed to read directory entry")?; 58 | let path = entry.path(); 59 | if path.is_file() { 60 | let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or(""); 61 | if file_name.ends_with(".java") { 62 | std::fs::remove_file(&path) 63 | .with_context(|| format!("Failed to remove existing: {}", display_path(&path))) 64 | .map_err(|e| e.context(format!("Error removing existing: {}", display_path(&path))))?; 65 | } 66 | } 67 | } 68 | } 69 | 70 | let schema = File::open(schema_file).with_context(|| "Failed to open schema.json file")?; 71 | let schema: Schema = 72 | serde_json::from_reader(schema).with_context(|| "Failed to parse schema.json as valid JSON schema")?; 73 | 74 | let mut context = Context::new(&schema); 75 | 76 | collect_definitions(&mut context).with_context(|| "Failed to collect definitions from schema")?; 77 | process_class(&mut context, "root".to_owned(), &schema)?; 78 | 79 | let structures = context 80 | .into_structures() 81 | .into_iter() 82 | .map(|(name, structure)| { 83 | generate_structure(&name, structure).with_context(|| format!("Failed to generate structure for {name}")) 84 | }) 85 | .collect::>>()?; 86 | 87 | for (name, content) in structures { 88 | let file_name = format!("{}.java", name); 89 | let file_path = output_dir.join(file_name); 90 | let display_path = display_path(&file_path); 91 | 92 | let mut file = OpenOptions::new() 93 | .create(true) 94 | .write(true) 95 | .truncate(true) 96 | .open(&file_path) 97 | .with_context(|| format!("Failed to open file for writing: {display_path}"))?; 98 | 99 | file 100 | .write_all(content.as_bytes()) 101 | .with_context(|| format!("Failed to write content to file: {display_path}"))?; 102 | 103 | println!("generated: {display_path}"); 104 | } 105 | 106 | Ok(()) 107 | } 108 | -------------------------------------------------------------------------------- /codegen/src/process.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::bail; 4 | use serde_json::Value; 5 | 6 | use crate::java_type::JavaType; 7 | use crate::properties::Property; 8 | use crate::properties::boolean::BooleanProperty; 9 | use crate::properties::class::ClassProperty; 10 | use crate::properties::color::ColorProperty; 11 | use crate::properties::r#enum::EnumProperty; 12 | use crate::properties::integer::IntegerProperty; 13 | use crate::properties::list::ListProperty; 14 | use crate::properties::map::MapProperty; 15 | use crate::properties::string::StringProperty; 16 | use crate::schema::{ItemsUnion, Schema, SimpleType, Type}; 17 | use crate::structures::{Class, Enum, Structure}; 18 | 19 | const REFERENCE_PREFIX: &str = "#/definitions/"; 20 | 21 | struct References { 22 | map: HashMap, 23 | } 24 | 25 | impl References { 26 | fn new() -> Self { 27 | Self { map: HashMap::new() } 28 | } 29 | 30 | fn get(&self, reference: &str) -> Option<&String> { 31 | self.map.get(reference.trim_start_matches(REFERENCE_PREFIX)) 32 | } 33 | 34 | fn insert(&mut self, reference: &str, name: String) { 35 | self 36 | .map 37 | .insert(reference.trim_start_matches(REFERENCE_PREFIX).to_owned(), name); 38 | } 39 | } 40 | 41 | pub struct Context<'a> { 42 | pub root: &'a Schema, 43 | structures: HashMap, 44 | definitions: References, 45 | } 46 | 47 | impl<'a> Context<'a> { 48 | pub fn new(root: &'a Schema) -> Self { 49 | Self { 50 | root, 51 | structures: HashMap::new(), 52 | definitions: References::new(), 53 | } 54 | } 55 | 56 | pub fn into_structures(self) -> HashMap { 57 | self.structures 58 | } 59 | 60 | fn get_definition(&mut self, reference: &str) -> anyhow::Result { 61 | let resolved = self.definitions.get(reference).map(ToOwned::to_owned); 62 | 63 | if let Some(name) = resolved { 64 | return Ok(name); 65 | } 66 | 67 | if let Some(definitions) = &self.root.definitions { 68 | let reference = reference.trim_start_matches(REFERENCE_PREFIX); 69 | let resolved = definitions.get(reference); 70 | if let Some(schema) = resolved { 71 | let name = process_class(self, reference.to_owned(), schema)?; 72 | self.insert_definition(reference, name.to_owned()); 73 | return Ok(name); 74 | } 75 | } 76 | 77 | bail!("Failed to resolve reference '{reference}' in schema definitions"); 78 | } 79 | 80 | fn insert_definition(&mut self, reference: &str, name: String) { 81 | self.definitions.insert(reference, name); 82 | } 83 | 84 | fn insert_structure(&mut self, name: String, structure: Structure) { 85 | self.structures.insert(name, structure); 86 | } 87 | } 88 | 89 | pub fn collect_definitions(context: &mut Context) -> anyhow::Result<()> { 90 | println!( 91 | "processing {} definitions", 92 | context.root.definitions.as_ref().map_or(0, |d| d.len()) 93 | ); 94 | 95 | if let Some(definitions) = &context.root.definitions { 96 | for (reference, schema) in definitions.iter() { 97 | let name = process_class(context, reference.to_owned(), schema)?; 98 | context.insert_definition(reference, name); 99 | } 100 | } 101 | 102 | println!("Collected {} definitions from schema", context.definitions.map.len()); 103 | for (reference, name) in context.definitions.map.iter() { 104 | println!(" - {reference}: {name}"); 105 | } 106 | 107 | Ok(()) 108 | } 109 | 110 | fn process_property( 111 | context: &mut Context, 112 | parent_key: &str, 113 | key: &str, 114 | value: &Schema, 115 | ) -> anyhow::Result> { 116 | if key == "$schema" { 117 | eprintln!("Skipping $schema property"); 118 | return Ok(None); 119 | } 120 | 121 | if value.kind.is_none() { 122 | if let Some(reference) = &value.reference { 123 | let name = context.get_definition(reference)?; 124 | return Ok(Some(Property::Class(ClassProperty { name }))); 125 | } 126 | 127 | bail!("Property '{key}' is missing required 'type' field"); 128 | } 129 | 130 | match &value.kind { 131 | // string or color or enum 132 | Some(Type::Single(SimpleType::String)) => { 133 | // enum 134 | if let Some(r#enum) = &value.r#enum { 135 | let variants: Vec = r#enum.iter().map(|v| v.to_owned()).collect(); 136 | let name = format!("{parent_key}_{key}"); 137 | 138 | context.insert_structure( 139 | name.clone(), 140 | Structure::Enum(Enum { 141 | name: name.clone(), 142 | variants, 143 | }), 144 | ); 145 | 146 | let property = match &value.default { 147 | Some(Value::String(default)) => EnumProperty::NormalWithDefault { 148 | name, 149 | default: default.to_owned(), 150 | }, 151 | None => EnumProperty::NormalWithoutDefault { name }, 152 | Some(other) => bail!("Property '{key}' has invalid default value type, expected string, got {other:?}"), 153 | }; 154 | 155 | return Ok(Some(Property::Enum(property))); 156 | } 157 | 158 | // color 159 | match &value.parsed_as.as_deref() { 160 | Some("java.awt.Color") => { 161 | let property = match &value.default { 162 | Some(Value::String(default)) => ColorProperty::NormalWithDefault { 163 | default: default.to_owned(), 164 | }, 165 | None => ColorProperty::NormalWithoutDefault, 166 | Some(other) => bail!("Property '{key}' has invalid default value type, expected string, got {other:?}"), 167 | }; 168 | 169 | return Ok(Some(Property::Color(property))); 170 | } 171 | Some(parsed_as) => bail!("Property '{key}' has unsupported parsed_as value: {parsed_as}"), 172 | _ => {} 173 | } 174 | 175 | // string 176 | let property = match &value.default { 177 | Some(Value::String(default)) => StringProperty::NormalWithDefault { 178 | default: default.to_owned(), 179 | }, 180 | None => StringProperty::NormalWithoutDefault, 181 | Some(other) => bail!("Property '{key}' has invalid default value type, expected string, got {other:?}"), 182 | }; 183 | 184 | Ok(Some(Property::String(property))) 185 | } 186 | // integer 187 | Some(Type::Single(SimpleType::Integer)) => { 188 | let property = match &value.default { 189 | Some(Value::Number(default)) if default.is_i64() => IntegerProperty::NormalWithDefault { 190 | default: default.as_i64().unwrap(), 191 | }, 192 | None => IntegerProperty::NormalWithoutDefault, 193 | Some(other) => bail!("Property '{key}' has invalid default value type, expected integer, got {other:?}"), 194 | }; 195 | 196 | Ok(Some(Property::Integer(property))) 197 | } 198 | // boolean 199 | Some(Type::Single(SimpleType::Boolean)) => { 200 | let property = match &value.default { 201 | Some(Value::Bool(default)) => BooleanProperty::NormalWithDefault { default: *default }, 202 | None => BooleanProperty::NormalWithoutDefault, 203 | Some(other) => bail!("Property '{key}' has invalid default value type, expected boolean, got {other:?}"), 204 | }; 205 | 206 | Ok(Some(Property::Boolean(property))) 207 | } 208 | // disableable types 209 | Some(Type::Union(types)) => { 210 | if types.len() != 2 || !types.contains(&SimpleType::Boolean) { 211 | bail!( 212 | "Property '{key}' has invalid union type - expected exactly two types with one being boolean, got {types:?}" 213 | ); 214 | } 215 | 216 | if types.contains(&SimpleType::String) { 217 | // disableable enum 218 | if let Some(r#enum) = &value.r#enum { 219 | let variants: Vec = r#enum.iter().map(|v| v.to_owned()).collect(); 220 | let name = format!("{parent_key}_{key}"); 221 | 222 | context.insert_structure( 223 | name.clone(), 224 | Structure::Enum(Enum { 225 | name: name.clone(), 226 | variants, 227 | }), 228 | ); 229 | 230 | let property = match &value.default { 231 | Some(Value::String(default)) => EnumProperty::DisableableWithDefault { 232 | name, 233 | default: default.to_owned(), 234 | }, 235 | None => EnumProperty::DisableableWithoutDefault { name }, 236 | Some(other) => bail!("Property '{key}' has invalid default value type, expected string, got {other:?}"), 237 | }; 238 | 239 | return Ok(Some(Property::Enum(property))); 240 | } 241 | 242 | // disableable color 243 | match &value.parsed_as.as_deref() { 244 | Some("java.awt.Color") => { 245 | let property = match &value.default { 246 | Some(Value::String(default)) => ColorProperty::DisableableWithDefault { 247 | default: default.to_owned(), 248 | }, 249 | None => ColorProperty::DisableableWithoutDefault, 250 | Some(other) => bail!("Property '{key}' has invalid default value type, expected string, got {other:?}"), 251 | }; 252 | 253 | return Ok(Some(Property::Color(property))); 254 | } 255 | Some(parsed_as) => bail!("Property '{key}' has unsupported parsed_as value: {parsed_as}"), 256 | _ => {} 257 | } 258 | 259 | // disableable string 260 | let property = match &value.default { 261 | Some(Value::String(default)) => StringProperty::DisableableWithDefault { 262 | default: default.to_owned(), 263 | }, 264 | None => StringProperty::DisableableWithoutDefault, 265 | Some(other) => { 266 | bail!("Property '{key}' has invalid default value type, expected string or null, got {other:?}") 267 | } 268 | }; 269 | 270 | return Ok(Some(Property::String(property))); 271 | } 272 | 273 | // disableable integer 274 | if types.contains(&SimpleType::Integer) { 275 | let property = match &value.default { 276 | Some(Value::Number(default)) if default.is_i64() => IntegerProperty::DisableableWithDefault { 277 | default: default.as_i64().unwrap(), 278 | }, 279 | None => IntegerProperty::DisableableWithoutDefault, 280 | Some(other) => { 281 | bail!("Property '{key}' has invalid default value type, expected integer or null, got {other:?}") 282 | } 283 | }; 284 | 285 | return Ok(Some(Property::Integer(property))); 286 | } 287 | 288 | bail!("Property '{key}' has unsupported union type combination: {types:?}"); 289 | } 290 | // array 291 | Some(Type::Single(SimpleType::Array)) => match &value.items { 292 | Some(ItemsUnion::Schema(schema)) => match &schema.kind { 293 | Some(Type::Single(kind)) => { 294 | let kind = match kind { 295 | SimpleType::String => JavaType::String, 296 | SimpleType::Integer => JavaType::Integer, 297 | SimpleType::Boolean => JavaType::Boolean, 298 | _ => bail!("Property '{key}' has array with unsupported item type: {kind:?}"), 299 | }; 300 | 301 | Ok(Some(Property::List(ListProperty { kind }))) 302 | } 303 | None => bail!("Property '{key}' has array items without a type"), 304 | other => bail!("Property '{key}' has array with invalid item type: {other:?}"), 305 | }, 306 | None => bail!("Property '{key}' is an array but missing 'items' field"), 307 | other => bail!("Property '{key}' has invalid 'items' field: {other:?}"), 308 | }, 309 | // objects or maps 310 | Some(Type::Single(SimpleType::Object)) => { 311 | // treat as map if value.additional_properties is defined 312 | if let Some(additional) = value.additional_properties.as_deref() { 313 | match &additional.kind { 314 | Some(Type::Single(kind)) => { 315 | let kind = match kind { 316 | SimpleType::String => JavaType::String, 317 | SimpleType::Integer => JavaType::Integer, 318 | SimpleType::Boolean => JavaType::Boolean, 319 | _ => bail!("Property '{key}' has map with unsupported value type: {kind:?}"), 320 | }; 321 | 322 | return Ok(Some(Property::Map(MapProperty { value_kind: kind }))); 323 | } 324 | None => bail!("Property '{key}' is an object but missing 'additionalProperties' type"), 325 | other => bail!("Property '{key}' has map with invalid value type: {other:?}"), 326 | } 327 | } 328 | 329 | // otherwise treat as class 330 | // let nested_name = process_class(context, format!("{parent_key}_{key}"), 331 | // value)?; 332 | let nested_name = process_class(context, key.to_owned(), value)?; 333 | Ok(Some(Property::Class(ClassProperty { name: nested_name }))) 334 | } 335 | Some(other) => bail!("Property '{key}' has unsupported type: {other:?}"), 336 | None => bail!("Property '{key}' is missing required 'type' field"), 337 | } 338 | } 339 | 340 | pub fn process_class(context: &mut Context, name: String, schema: &Schema) -> anyhow::Result { 341 | // if $ref at root level, just return the definition name 342 | if let Some(reference) = &schema.reference { 343 | return context.get_definition(reference); 344 | } 345 | 346 | let mut properties = HashMap::new(); 347 | 348 | // if allOf, resolve references as sub classes, and objects as properties 349 | if let Some(all_of) = &schema.all_of { 350 | for item in all_of.iter() { 351 | if let Some(reference) = &item.reference { 352 | let name = context.get_definition(reference)?; 353 | properties.insert(name.to_owned(), Property::Class(ClassProperty { name })); 354 | } 355 | } 356 | } 357 | 358 | if let Some(props) = &schema.properties { 359 | for (key, value) in props.iter() { 360 | match process_property(context, &name, key, value) { 361 | Ok(Some(property)) => { 362 | properties.insert(key.to_owned(), property); 363 | } 364 | Ok(None) => {} 365 | Err(e) => eprintln!("Error processing property {key} in class {name}: {e:?}"), 366 | } 367 | } 368 | } 369 | 370 | let class = Class { properties }; 371 | context.insert_structure(name.to_owned(), Structure::Class(class)); 372 | Ok(name) 373 | } 374 | -------------------------------------------------------------------------------- /codegen/src/properties/boolean.rs: -------------------------------------------------------------------------------- 1 | use crate::properties::AsJava; 2 | 3 | pub enum BooleanProperty { 4 | NormalWithDefault { default: bool }, 5 | NormalWithoutDefault, 6 | } 7 | 8 | impl AsJava for BooleanProperty { 9 | fn as_property_type(&self) -> String { 10 | match self { 11 | BooleanProperty::NormalWithDefault { .. } => "Boolean", 12 | BooleanProperty::NormalWithoutDefault => "Boolean", 13 | } 14 | .into() 15 | } 16 | 17 | fn as_default(&self) -> Option { 18 | match self { 19 | BooleanProperty::NormalWithDefault { default } => Some(default.to_string()), 20 | BooleanProperty::NormalWithoutDefault => None, 21 | } 22 | } 23 | 24 | fn as_parse_function(&self, key: &str, default_property: &str) -> String { 25 | match self { 26 | BooleanProperty::NormalWithDefault { .. } => format!("config.getOrElse({key}, {default_property})"), 27 | BooleanProperty::NormalWithoutDefault => format!("config.get({key})"), 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /codegen/src/properties/class.rs: -------------------------------------------------------------------------------- 1 | use crate::properties::AsJava; 2 | use crate::utils::pascal; 3 | 4 | pub struct ClassProperty { 5 | pub name: String, 6 | } 7 | 8 | impl AsJava for ClassProperty { 9 | fn as_property_type(&self) -> String { 10 | pascal(&self.name) 11 | } 12 | 13 | fn as_parse_function(&self, key: &str, _: &str) -> String { 14 | let name = pascal(&self.name); 15 | format!("new {name}().load(config.get({key}))") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /codegen/src/properties/color.rs: -------------------------------------------------------------------------------- 1 | use crate::properties::AsJava; 2 | use crate::utils::string_literal; 3 | 4 | #[allow(clippy::enum_variant_names)] 5 | pub enum ColorProperty { 6 | DisableableWithDefault { default: String }, 7 | DisableableWithoutDefault, 8 | NormalWithDefault { default: String }, 9 | NormalWithoutDefault, 10 | } 11 | 12 | impl AsJava for ColorProperty { 13 | fn as_property_type(&self) -> String { 14 | match self { 15 | ColorProperty::DisableableWithDefault { .. } => "Optional", 16 | ColorProperty::DisableableWithoutDefault => "Optional", 17 | ColorProperty::NormalWithDefault { .. } => "Color", 18 | ColorProperty::NormalWithoutDefault => "Color", 19 | } 20 | .into() 21 | } 22 | 23 | fn as_default(&self) -> Option { 24 | match self { 25 | ColorProperty::DisableableWithDefault { default } => { 26 | Some(format!("Optional.of(Color.decode({}))", string_literal(default))) 27 | } 28 | ColorProperty::NormalWithDefault { default } => Some(format!("Color.decode({})", string_literal(default))), 29 | _ => None, 30 | } 31 | } 32 | 33 | fn as_parse_function(&self, key: &str, default_property: &str) -> String { 34 | match self { 35 | ColorProperty::DisableableWithDefault { .. } => { 36 | format!("ConfigUtils.parseDisableableColorWithDefault(config, {key}, {default_property})") 37 | } 38 | ColorProperty::DisableableWithoutDefault => format!("ConfigUtils.parseDisableableColor(config, {key})"), 39 | ColorProperty::NormalWithDefault { .. } => { 40 | format!("ConfigUtils.parseColorWithDefault(config, {key}, {default_property})") 41 | } 42 | ColorProperty::NormalWithoutDefault => format!("ConfigUtils.parseColor(config, {key})"), 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /codegen/src/properties/enum.rs: -------------------------------------------------------------------------------- 1 | use crate::properties::AsJava; 2 | use crate::utils::{pascal, string_literal}; 3 | 4 | #[allow(clippy::enum_variant_names)] 5 | pub enum EnumProperty { 6 | DisableableWithDefault { name: String, default: String }, 7 | DisableableWithoutDefault { name: String }, 8 | NormalWithDefault { name: String, default: String }, 9 | NormalWithoutDefault { name: String }, 10 | } 11 | 12 | impl AsJava for EnumProperty { 13 | fn as_property_type(&self) -> String { 14 | match self { 15 | EnumProperty::DisableableWithDefault { name, .. } => format!("Optional<{}>", pascal(name)), 16 | EnumProperty::DisableableWithoutDefault { name } => format!("Optional<{}>", pascal(name)), 17 | EnumProperty::NormalWithDefault { name, .. } => pascal(name), 18 | EnumProperty::NormalWithoutDefault { name } => pascal(name), 19 | } 20 | } 21 | 22 | fn as_default(&self) -> Option { 23 | match self { 24 | EnumProperty::DisableableWithDefault { name, default } => { 25 | let name = pascal(name); 26 | let default = string_literal(default); 27 | Some(format!("Optional.of({name}.from({default})))",)) 28 | } 29 | EnumProperty::NormalWithDefault { default, name } => { 30 | let name = pascal(name); 31 | let default = string_literal(default); 32 | Some(format!("{name}.from({default})")) 33 | } 34 | _ => None, 35 | } 36 | } 37 | 38 | fn as_parse_function(&self, key: &str, default_property: &str) -> String { 39 | match self { 40 | EnumProperty::DisableableWithDefault { name, .. } => { 41 | let name = pascal(name); 42 | format!("{name}.from(ConfigUtils.parseDisableableStringWithDefault(config, {key}, {default_property}))") 43 | } 44 | EnumProperty::DisableableWithoutDefault { name } => { 45 | let name = pascal(name); 46 | format!("{name}.from(ConfigUtils.parseDisableableString(config, {key}))") 47 | } 48 | EnumProperty::NormalWithDefault { name, .. } => { 49 | let name = pascal(name); 50 | format!("{name}.from(ConfigUtils.parseStringWithDefault(config, {key}, {default_property}))") 51 | } 52 | EnumProperty::NormalWithoutDefault { name } => { 53 | let name = pascal(name); 54 | format!("{name}.from(ConfigUtils.parseString(config, {key}))") 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /codegen/src/properties/integer.rs: -------------------------------------------------------------------------------- 1 | use crate::properties::AsJava; 2 | 3 | #[allow(clippy::enum_variant_names)] 4 | pub enum IntegerProperty { 5 | DisableableWithDefault { default: i64 }, 6 | DisableableWithoutDefault, 7 | NormalWithDefault { default: i64 }, 8 | NormalWithoutDefault, 9 | } 10 | 11 | impl AsJava for IntegerProperty { 12 | fn as_property_type(&self) -> String { 13 | match self { 14 | IntegerProperty::DisableableWithDefault { .. } => "Optional", 15 | IntegerProperty::DisableableWithoutDefault => "Optional", 16 | IntegerProperty::NormalWithDefault { .. } => "Integer", 17 | IntegerProperty::NormalWithoutDefault => "Integer", 18 | } 19 | .into() 20 | } 21 | 22 | fn as_default(&self) -> Option { 23 | match self { 24 | IntegerProperty::DisableableWithDefault { default } => Some(format!("Optional.of({default})")), 25 | IntegerProperty::NormalWithDefault { default } => Some(default.to_string()), 26 | _ => None, 27 | } 28 | } 29 | 30 | fn as_parse_function(&self, key: &str, default_property: &str) -> String { 31 | match self { 32 | IntegerProperty::DisableableWithDefault { .. } => { 33 | format!("ConfigUtils.parseDisableableIntegerWithDefault(config, {key}, {default_property})") 34 | } 35 | IntegerProperty::DisableableWithoutDefault => format!("ConfigUtils.parseDisableableInteger(config, {key})"), 36 | IntegerProperty::NormalWithDefault { .. } => format!("config.getOrElse({key}, {default_property})"), 37 | IntegerProperty::NormalWithoutDefault => format!("config.get({key})"), 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /codegen/src/properties/list.rs: -------------------------------------------------------------------------------- 1 | use crate::java_type::JavaType; 2 | use crate::properties::AsJava; 3 | 4 | pub struct ListProperty { 5 | pub kind: JavaType, 6 | } 7 | 8 | impl AsJava for ListProperty { 9 | fn as_property_type(&self) -> String { 10 | format!("List<{}>", self.kind) 11 | } 12 | 13 | fn as_parse_function(&self, key: &str, _: &str) -> String { 14 | format!("config.get({key})") 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /codegen/src/properties/map.rs: -------------------------------------------------------------------------------- 1 | use crate::java_type::JavaType; 2 | use crate::properties::AsJava; 3 | 4 | // only map string to string for now 5 | pub struct MapProperty { 6 | pub value_kind: JavaType, 7 | } 8 | 9 | impl AsJava for MapProperty { 10 | fn as_property_type(&self) -> String { 11 | format!("Map", self.value_kind) 12 | } 13 | 14 | fn as_parse_function(&self, key: &str, _: &str) -> String { 15 | format!("config.get({key})") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /codegen/src/properties/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::properties::boolean::BooleanProperty; 2 | use crate::properties::class::ClassProperty; 3 | use crate::properties::color::ColorProperty; 4 | use crate::properties::r#enum::EnumProperty; 5 | use crate::properties::integer::IntegerProperty; 6 | use crate::properties::list::ListProperty; 7 | use crate::properties::map::MapProperty; 8 | use crate::properties::string::StringProperty; 9 | 10 | pub mod boolean; 11 | pub mod class; 12 | pub mod color; 13 | pub mod r#enum; 14 | pub mod integer; 15 | pub mod list; 16 | pub mod map; 17 | pub mod string; 18 | 19 | pub trait AsJava { 20 | fn as_property_type(&self) -> String; 21 | 22 | fn as_default(&self) -> Option { 23 | None 24 | } 25 | 26 | fn as_parse_function(&self, key: &str, default_property: &str) -> String; 27 | } 28 | 29 | pub enum Property { 30 | Map(MapProperty), 31 | List(ListProperty), 32 | Enum(EnumProperty), 33 | String(StringProperty), 34 | Color(ColorProperty), 35 | Integer(IntegerProperty), 36 | Boolean(BooleanProperty), 37 | Class(ClassProperty), 38 | } 39 | 40 | /// ```no_run 41 | /// let property = all_properties!(prop, property.as_property_type()); 42 | /// ``` 43 | /// 44 | /// generates 45 | /// 46 | /// ```no_run 47 | /// let property = match prop { 48 | /// Property::Map(property) => property.as_property_type(), 49 | /// Property::List(property) => property.as_property_type(), 50 | /// Property::Enum(property) => property.as_property_type(), 51 | /// Property::String(property) => property.as_property_type(), 52 | /// Property::Integer(property) => property.as_property_type(), 53 | /// Property::Boolean(property) => property.as_property_type(), 54 | /// Property::Class(property) => property.as_property_type(), 55 | /// }; 56 | /// ``` 57 | #[macro_export] 58 | macro_rules! all_properties { 59 | ($prop:ident, $property:expr) => { 60 | match $prop { 61 | Property::Map($prop) => $property, 62 | Property::List($prop) => $property, 63 | Property::Enum($prop) => $property, 64 | Property::String($prop) => $property, 65 | Property::Color($prop) => $property, 66 | Property::Integer($prop) => $property, 67 | Property::Boolean($prop) => $property, 68 | Property::Class($prop) => $property, 69 | } 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /codegen/src/properties/string.rs: -------------------------------------------------------------------------------- 1 | use crate::properties::AsJava; 2 | use crate::utils::string_literal; 3 | 4 | #[allow(clippy::enum_variant_names)] 5 | pub enum StringProperty { 6 | DisableableWithDefault { default: String }, 7 | DisableableWithoutDefault, 8 | NormalWithDefault { default: String }, 9 | NormalWithoutDefault, 10 | } 11 | 12 | impl AsJava for StringProperty { 13 | fn as_property_type(&self) -> String { 14 | match self { 15 | StringProperty::DisableableWithDefault { .. } => "Optional", 16 | StringProperty::DisableableWithoutDefault => "Optional", 17 | StringProperty::NormalWithDefault { .. } => "String", 18 | StringProperty::NormalWithoutDefault => "String", 19 | } 20 | .into() 21 | } 22 | 23 | fn as_default(&self) -> Option { 24 | match self { 25 | StringProperty::DisableableWithDefault { default } => Some(format!("Optional.of({})", string_literal(default))), 26 | StringProperty::NormalWithDefault { default } => Some(string_literal(default)), 27 | _ => None, 28 | } 29 | } 30 | 31 | fn as_parse_function(&self, key: &str, default_property: &str) -> String { 32 | match self { 33 | StringProperty::DisableableWithDefault { .. } => { 34 | format!("ConfigUtils.parseDisableableStringWithDefault(config, {key}, {default_property})") 35 | } 36 | StringProperty::DisableableWithoutDefault => format!("ConfigUtils.parseDisableableString(config, {key})"), 37 | StringProperty::NormalWithDefault { .. } => { 38 | format!("ConfigUtils.parseStringWithDefault(config, {key}, {default_property})") 39 | } 40 | StringProperty::NormalWithoutDefault => format!("ConfigUtils.parseString(config, {key})"), 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /codegen/src/schema/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::Deserialize; 4 | 5 | #[derive(Deserialize, Debug)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct Schema { 8 | pub title: Option, 9 | 10 | #[serde(rename = "type")] 11 | pub kind: Option, 12 | 13 | pub definitions: Option>, 14 | 15 | #[serde(rename = "$ref")] 16 | pub reference: Option, 17 | 18 | pub additional_items: Option>, 19 | pub additional_properties: Option>, 20 | 21 | pub all_of: Option>, 22 | pub any_of: Option>, 23 | pub one_of: Option>, 24 | 25 | pub default: Option, 26 | 27 | /// if enum value is not a string, just error 28 | #[serde(rename = "enum")] 29 | pub r#enum: Option>, 30 | 31 | pub items: Option, 32 | 33 | pub properties: Option>, 34 | 35 | #[serde(rename = "required")] 36 | pub required_properties: Option>, 37 | 38 | pub parsed_as: Option, 39 | } 40 | 41 | #[derive(Deserialize, Debug)] 42 | #[serde(untagged)] 43 | pub enum ItemsUnion { 44 | Schema(Box), 45 | Array(Vec), 46 | } 47 | 48 | #[derive(Deserialize, Debug)] 49 | #[serde(untagged)] 50 | pub enum DependencyValue { 51 | Schema(Box), 52 | Array(Vec), 53 | } 54 | 55 | #[derive(Deserialize, Debug)] 56 | #[serde(untagged)] 57 | pub enum Type { 58 | Single(SimpleType), 59 | Union(Vec), 60 | } 61 | 62 | impl std::fmt::Display for Type { 63 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 64 | match self { 65 | Type::Single(t) => write!(f, "{}", t), 66 | Type::Union(types) => { 67 | let types_str: Vec = types.iter().map(|t| t.to_string()).collect(); 68 | write!(f, "{}", types_str.join(" | ")) 69 | } 70 | } 71 | } 72 | } 73 | 74 | #[derive(Deserialize, Debug, PartialEq, Eq)] 75 | #[serde(rename_all = "snake_case")] 76 | pub enum SimpleType { 77 | Array, 78 | Boolean, 79 | Integer, 80 | Null, 81 | Number, 82 | Object, 83 | String, 84 | } 85 | 86 | impl std::fmt::Display for SimpleType { 87 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 88 | match self { 89 | SimpleType::Array => write!(f, "array"), 90 | SimpleType::Boolean => write!(f, "boolean"), 91 | SimpleType::Integer => write!(f, "integer"), 92 | SimpleType::Null => write!(f, "null"), 93 | SimpleType::Number => write!(f, "number"), 94 | SimpleType::Object => write!(f, "object"), 95 | SimpleType::String => write!(f, "string"), 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /codegen/src/structures.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::properties::Property; 4 | 5 | pub struct Class { 6 | pub properties: HashMap, 7 | } 8 | 9 | pub struct Enum { 10 | pub name: String, 11 | pub variants: Vec, 12 | } 13 | 14 | pub enum Structure { 15 | Class(Class), 16 | Enum(Enum), 17 | } 18 | -------------------------------------------------------------------------------- /codegen/src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use convert_case::{Case, Casing}; 4 | 5 | pub fn string_literal(value: &str) -> String { 6 | format!( 7 | "\"{}\"", 8 | value 9 | .replace('"', "\\\"") 10 | .replace('\\', "\\\\") 11 | .replace('\n', "\\n") 12 | .replace('\r', "\\r") 13 | .replace('\t', "\\t") 14 | ) 15 | } 16 | 17 | pub fn default_property_name(key: &str) -> String { 18 | format!("{}_DEFAULT", key.to_case(Case::UpperSnake)) 19 | } 20 | 21 | pub fn display_path(path: &Path) -> String { 22 | path 23 | .to_str() 24 | .unwrap_or("") 25 | .replace(r"\\", "/") 26 | .replace('\\', "/") 27 | } 28 | 29 | pub fn pascal(str: S) -> String 30 | where 31 | S: AsRef, 32 | S: ToString, 33 | { 34 | str.to_case(Case::Pascal) 35 | } 36 | 37 | pub fn camel(str: S) -> String 38 | where 39 | S: AsRef, 40 | S: ToString, 41 | { 42 | str.to_case(Case::Camel) 43 | } 44 | -------------------------------------------------------------------------------- /codegen/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "type": "object", 4 | "definitions": { 5 | "message": { 6 | "type": "object", 7 | "properties": { 8 | "type": { 9 | "type": "string", 10 | "enum": ["message"] 11 | } 12 | } 13 | } 14 | }, 15 | "properties": { 16 | "join": { 17 | "$ref": "#/definitions/message" 18 | }, 19 | "override": { 20 | "type": "object", 21 | "additionalProperties": { 22 | "properties": { 23 | "join": { 24 | "$ref": "#/definitions/message" 25 | } 26 | } 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /deps/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fooooooooooooooo/VelocityDiscord/c3500d4192bc196848f132dc61058d5e454bf883/deps/.gitkeep -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # plugin 2 | version = 2.0.0 3 | 4 | # dependencies 5 | velocity_version = 3.4.0-SNAPSHOT 6 | jda_version = 5.1.0 7 | yeplib_version = 2.3.0 8 | minimessage_version = 4.17.0 9 | night_config_version = 3.8.1 10 | 11 | # test dependencies 12 | junit_version = 5.10.2 13 | logback_version = 1.5.16 14 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fooooooooooooooo/VelocityDiscord/c3500d4192bc196848f132dc61058d5e454bf883/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/Constants.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord; 2 | 3 | public class Constants { 4 | public static final String PluginName = "Velocity Discord Bridge"; 5 | public static final String PluginDescription = "Velocity Discord Chat Bridge"; 6 | public static final String PluginVersion = "2.0.0"; 7 | public static final String PluginUrl = "https://github.com/fooooooooooooooo/VelocityDiscord"; 8 | 9 | public static final String YeplibId = "yeplib"; 10 | public static final String LuckPermsId = "luckperms"; 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/VelocityDiscord.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord; 2 | 3 | import com.google.inject.Inject; 4 | import com.velocitypowered.api.event.Subscribe; 5 | import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; 6 | import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; 7 | import com.velocitypowered.api.plugin.Dependency; 8 | import com.velocitypowered.api.plugin.Plugin; 9 | import com.velocitypowered.api.plugin.annotation.DataDirectory; 10 | import com.velocitypowered.api.proxy.ProxyServer; 11 | import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; 12 | import com.velocitypowered.api.scheduler.ScheduledTask; 13 | import ooo.foooooooooooo.velocitydiscord.commands.Commands; 14 | import ooo.foooooooooooo.velocitydiscord.compat.LuckPerms; 15 | import ooo.foooooooooooo.velocitydiscord.config.PluginConfig; 16 | import ooo.foooooooooooo.velocitydiscord.discord.Discord; 17 | import ooo.foooooooooooo.velocitydiscord.yep.YepListener; 18 | import org.slf4j.Logger; 19 | 20 | import javax.annotation.Nullable; 21 | import java.nio.file.Path; 22 | import java.util.concurrent.TimeUnit; 23 | 24 | @Plugin(id = "discord", name = Constants.PluginName, description = Constants.PluginDescription, version = 25 | Constants.PluginVersion, url = Constants.PluginUrl, authors = {"fooooooooooooooo"}, dependencies = { 26 | @Dependency(id = Constants.YeplibId, optional = true), 27 | @Dependency(id = Constants.LuckPermsId, optional = true) 28 | }) 29 | public class VelocityDiscord { 30 | public static final MinecraftChannelIdentifier YepIdentifier = MinecraftChannelIdentifier.create("velocity", "yep"); 31 | 32 | public static Logger LOGGER; 33 | public static PluginConfig CONFIG; 34 | public static ProxyServer SERVER; 35 | 36 | public static boolean pluginDisabled = false; 37 | 38 | private static VelocityDiscord instance; 39 | 40 | private final Path dataDirectory; 41 | 42 | @Nullable 43 | private VelocityListener listener = null; 44 | 45 | @Nullable 46 | private Discord discord = null; 47 | 48 | @Nullable 49 | private YepListener yep = null; 50 | 51 | @Nullable 52 | private LuckPerms luckPerms = null; 53 | 54 | private ScheduledTask pingScheduler = null; 55 | private ScheduledTask topicScheduler = null; 56 | 57 | @Inject 58 | public VelocityDiscord(ProxyServer server, Logger logger, @DataDirectory Path dataDirectory) { 59 | SERVER = server; 60 | LOGGER = logger; 61 | 62 | this.dataDirectory = dataDirectory; 63 | 64 | LOGGER.info("Loading {} v{}", Constants.PluginName, Constants.PluginVersion); 65 | 66 | reloadConfig(); 67 | 68 | VelocityDiscord.instance = this; 69 | 70 | if (pluginDisabled || CONFIG == null) { 71 | return; 72 | } 73 | 74 | this.discord = new Discord(); 75 | 76 | if (server.getPluginManager().isLoaded(Constants.YeplibId)) { 77 | this.yep = new YepListener(); 78 | } 79 | 80 | this.listener = new VelocityListener(this.discord); 81 | } 82 | 83 | public static Discord getDiscord() { 84 | return instance.discord; 85 | } 86 | 87 | public static VelocityListener getListener() { 88 | return instance.listener; 89 | } 90 | 91 | public static VelocityDiscord getInstance() { 92 | return instance; 93 | } 94 | 95 | public static LuckPerms getLuckPerms() { 96 | return instance.luckPerms; 97 | } 98 | 99 | @Subscribe 100 | public void onProxyInitialization(ProxyInitializeEvent event) { 101 | if (this.listener != null) { 102 | register(this.listener); 103 | } 104 | 105 | if (this.yep != null) { 106 | register(this.yep); 107 | } 108 | 109 | SERVER.getChannelRegistrar().register(YepIdentifier); 110 | 111 | if (CONFIG != null) { 112 | tryStartPingScheduler(); 113 | tryStartTopicScheduler(); 114 | } 115 | 116 | Commands.registerCommands(SERVER.getCommandManager(), CONFIG); 117 | 118 | try { 119 | if (SERVER.getPluginManager().getPlugin("luckperms").isPresent()) { 120 | this.luckPerms = new LuckPerms(); 121 | LOGGER.info("LuckPerms found, prefix can be displayed"); 122 | } 123 | } catch (Exception e) { 124 | LOGGER.warn("Error getting LuckPerms instance: {}", e.getMessage()); 125 | } finally { 126 | if (this.luckPerms == null) { 127 | LOGGER.info("LuckPerms not found, prefix will not be displayed"); 128 | } 129 | } 130 | } 131 | 132 | @Subscribe 133 | public void onProxyShutdown(ProxyShutdownEvent event) { 134 | if (this.discord != null) { 135 | this.discord.shutdown(); 136 | } 137 | } 138 | 139 | private void register(Object listener) { 140 | SERVER.getEventManager().register(this, listener); 141 | } 142 | 143 | public String reloadConfig() { 144 | String error = null; 145 | 146 | if (CONFIG == null) { 147 | CONFIG = new PluginConfig(this.dataDirectory); 148 | } else { 149 | LOGGER.info("Reloading config"); 150 | 151 | error = CONFIG.reloadConfig(this.dataDirectory); 152 | 153 | // disable server ping scheduler if it was disabled 154 | if (CONFIG.global.pingIntervalSeconds == 0 && this.pingScheduler != null) { 155 | this.pingScheduler.cancel(); 156 | this.pingScheduler = null; 157 | } 158 | 159 | tryStartPingScheduler(); 160 | 161 | // disable channel topic scheduler if it was disabled 162 | if (CONFIG.global.discord.updateChannelTopicIntervalMinutes == 0 && this.topicScheduler != null) { 163 | this.topicScheduler.cancel(); 164 | this.topicScheduler = null; 165 | } 166 | 167 | tryStartTopicScheduler(); 168 | 169 | if (this.discord != null) { 170 | this.discord.onConfigReload(); 171 | } 172 | 173 | // unregister and re register commands 174 | Commands.unregisterCommands(SERVER.getCommandManager()); 175 | Commands.registerCommands(SERVER.getCommandManager(), CONFIG); 176 | 177 | if (error != null) { 178 | LOGGER.error("Error reloading config:"); 179 | for (var line : error.split("\n")) { 180 | LOGGER.error(line); 181 | } 182 | } else { 183 | LOGGER.info("Config reloaded"); 184 | } 185 | } 186 | 187 | pluginDisabled = CONFIG.isConfigNotSetup(); 188 | 189 | if (pluginDisabled) { 190 | LOGGER.error(""" 191 | This is the first time you are running this plugin. Please configure it in the config.toml file. Disabling plugin."""); 192 | } 193 | 194 | return error; 195 | } 196 | 197 | private void tryStartPingScheduler() { 198 | if (CONFIG.global.pingIntervalEnabled() || this.pingScheduler != null) { 199 | this.pingScheduler = SERVER.getScheduler().buildTask( 200 | this, () -> { 201 | if (this.listener != null) this.listener.checkServerHealth(); 202 | } 203 | ).repeat(CONFIG.global.pingIntervalSeconds, TimeUnit.SECONDS).schedule(); 204 | } 205 | } 206 | 207 | private void tryStartTopicScheduler() { 208 | if (!CONFIG.global.discord.updateChannelTopicEnabled()) return; 209 | 210 | var interval = CONFIG.global.discord.updateChannelTopicIntervalMinutes; 211 | if (interval < 10) { 212 | LOGGER.warn("Invalid update_channel_topic_interval value: {}. Must be between > 10, setting to 10", interval); 213 | interval = 10; 214 | } 215 | 216 | this.topicScheduler = SERVER.getScheduler().buildTask( 217 | this, () -> { 218 | LOGGER.debug("Updating channel topic"); 219 | if (this.discord != null) this.discord.updateChannelTopic(); 220 | } 221 | ).repeat(interval, TimeUnit.MINUTES).schedule(); 222 | 223 | LOGGER.info("Scheduled task to update channel topic every {} minutes", interval); 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/VelocityListener.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord; 2 | 3 | import com.velocitypowered.api.event.Subscribe; 4 | import com.velocitypowered.api.event.connection.DisconnectEvent; 5 | import com.velocitypowered.api.event.player.PlayerChatEvent; 6 | import com.velocitypowered.api.event.player.ServerConnectedEvent; 7 | import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; 8 | import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; 9 | import com.velocitypowered.api.proxy.server.RegisteredServer; 10 | import com.velocitypowered.api.proxy.server.ServerPing; 11 | import ooo.foooooooooooo.velocitydiscord.discord.Discord; 12 | 13 | import java.util.HashMap; 14 | import java.util.Map; 15 | import java.util.Optional; 16 | import java.util.UUID; 17 | import java.util.concurrent.CompletableFuture; 18 | 19 | public class VelocityListener { 20 | private final Discord discord; 21 | 22 | private final Map serverState = new HashMap<>(); 23 | 24 | private boolean firstHealthCheck = true; 25 | 26 | public VelocityListener(Discord discord) { 27 | this.discord = discord; 28 | } 29 | 30 | @Subscribe 31 | public void onPlayerChat(PlayerChatEvent event) { 32 | var currentServer = event.getPlayer().getCurrentServer(); 33 | 34 | if (currentServer.isEmpty()) { 35 | return; 36 | } 37 | 38 | var server = currentServer.get().getServerInfo().getName(); 39 | 40 | if (VelocityDiscord.CONFIG.serverDisabled(server)) { 41 | return; 42 | } 43 | 44 | var username = event.getPlayer().getUsername(); 45 | var uuid = event.getPlayer().getUniqueId(); 46 | 47 | var prefix = getPrefix(uuid); 48 | 49 | this.discord.onPlayerChat(username, uuid.toString(), prefix, server, event.getMessage()); 50 | } 51 | 52 | @Subscribe 53 | public void onConnect(ServerConnectedEvent event) { 54 | updatePlayerCount(); 55 | 56 | var server = event.getServer().getServerInfo().getName(); 57 | 58 | if (VelocityDiscord.CONFIG.serverDisabled(server)) { 59 | return; 60 | } 61 | 62 | setServerOnline(server); 63 | 64 | var username = event.getPlayer().getUsername(); 65 | var previousServer = event.getPreviousServer(); 66 | var previousName = previousServer.map(s -> s.getServerInfo().getName()).orElse(null); 67 | 68 | var uuid = event.getPlayer().getUniqueId(); 69 | 70 | var prefix = getPrefix(uuid); 71 | 72 | // if previousServer is disabled but the current server is not, treat it as a join 73 | if (previousServer.isPresent() && !VelocityDiscord.CONFIG.serverDisabled(previousName)) { 74 | this.discord.onServerSwitch(username, uuid.toString(), prefix, server, previousName); 75 | } else { 76 | this.discord.onJoin(event.getPlayer(), prefix, server); 77 | } 78 | } 79 | 80 | @Subscribe 81 | public void onDisconnect(DisconnectEvent event) { 82 | updatePlayerCount(); 83 | 84 | var currentServer = event.getPlayer().getCurrentServer(); 85 | 86 | var username = event.getPlayer().getUsername(); 87 | var uuid = event.getPlayer().getUniqueId(); 88 | var prefix = getPrefix(uuid); 89 | 90 | if (currentServer.isEmpty()) { 91 | this.discord.onDisconnect(username, uuid.toString(), prefix, ""); 92 | } else { 93 | var name = currentServer.get().getServerInfo().getName(); 94 | 95 | if (VelocityDiscord.CONFIG.serverDisabled(name)) { 96 | return; 97 | } 98 | 99 | setServerOnline(name); 100 | 101 | this.discord.onLeave(username, uuid.toString(), prefix, name); 102 | } 103 | } 104 | 105 | @Subscribe 106 | public void onProxyInitialize(ProxyInitializeEvent event) { 107 | this.discord.onProxyInitialize(); 108 | updatePlayerCount(); 109 | checkServerHealth(); 110 | } 111 | 112 | @Subscribe 113 | public void onProxyShutdown(ProxyShutdownEvent event) { 114 | this.discord.onProxyShutdown(); 115 | } 116 | 117 | // theoretically can get notified of a server going offline by listening to 118 | // com.velocitypowered.api.event.player.KickedFromServerEvent and then parsing 119 | // the reason Component to check if its server shutting down message or something 120 | // but this seems like it would fail to work if literally anything in the message changes 121 | private void onServerOffline(String server) { 122 | this.discord.onServerStop(server); 123 | } 124 | 125 | private void onServerOnline(String server) { 126 | this.discord.onServerStart(server); 127 | } 128 | 129 | private void updatePlayerCount() { 130 | this.discord.updateActivityPlayerAmount(VelocityDiscord.SERVER.getPlayerCount()); 131 | } 132 | 133 | private Optional getPrefix(UUID uuid) { 134 | var luckPerms = VelocityDiscord.getLuckPerms(); 135 | if (luckPerms == null) return Optional.empty(); 136 | 137 | var user = luckPerms.getUserManager().getUser(uuid); 138 | if (user != null) { 139 | return Optional.ofNullable(user.getCachedData().getMetaData().getPrefix()); 140 | } 141 | 142 | return Optional.empty(); 143 | } 144 | 145 | /** 146 | * Ping all servers and update online state 147 | */ 148 | public void checkServerHealth() { 149 | var servers = VelocityDiscord.SERVER.getAllServers(); 150 | 151 | CompletableFuture 152 | .allOf(servers 153 | .parallelStream() 154 | .map((server) -> server.ping().handle((ping, ex) -> handlePing(server, ping, ex))) 155 | .toArray(CompletableFuture[]::new)) 156 | .join(); 157 | 158 | this.firstHealthCheck = false; 159 | } 160 | 161 | private CompletableFuture handlePing(RegisteredServer server, ServerPing ping, Throwable ex) { 162 | var name = server.getServerInfo().getName(); 163 | 164 | if (VelocityDiscord.CONFIG.serverDisabled(name)) { 165 | return CompletableFuture.completedFuture(null); 166 | } 167 | 168 | var state = this.serverState.getOrDefault(name, ServerState.empty()); 169 | 170 | if (ex != null) { 171 | if (state.online) { 172 | if (!this.firstHealthCheck) { 173 | onServerOffline(name); 174 | } 175 | state.online = false; 176 | this.serverState.put(name, state); 177 | } 178 | 179 | return CompletableFuture.completedFuture(null); 180 | } 181 | 182 | if (!state.online && !this.firstHealthCheck) { 183 | onServerOnline(name); 184 | } 185 | 186 | var players = 0; 187 | var maxPlayers = 0; 188 | 189 | if (ping.getPlayers().isPresent()) { 190 | players = ping.getPlayers().get().getOnline(); 191 | maxPlayers = ping.getPlayers().get().getMax(); 192 | } 193 | 194 | state.online = true; 195 | state.players = players; 196 | state.maxPlayers = maxPlayers; 197 | 198 | this.serverState.put(name, state); 199 | 200 | return CompletableFuture.completedFuture(null); 201 | } 202 | 203 | public ServerState getServerState(RegisteredServer server) { 204 | var name = server.getServerInfo().getName(); 205 | return this.serverState.getOrDefault(name, ServerState.empty()); 206 | } 207 | 208 | private void setServerOnline(String server) { 209 | var state = this.serverState.getOrDefault(server, ServerState.empty()); 210 | 211 | if (!state.online) { 212 | onServerOnline(server); 213 | state.online = true; 214 | this.serverState.put(server, state); 215 | } 216 | } 217 | 218 | public static class ServerState { 219 | public boolean online; 220 | public int players; 221 | public int maxPlayers; 222 | 223 | public ServerState(boolean online, int players, int maxPlayers) { 224 | this.online = online; 225 | this.players = players; 226 | this.maxPlayers = maxPlayers; 227 | } 228 | 229 | public static ServerState empty() { 230 | return new ServerState(false, 0, 0); 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/commands/Commands.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.commands; 2 | 3 | import com.velocitypowered.api.command.BrigadierCommand; 4 | import com.velocitypowered.api.command.CommandManager; 5 | import com.velocitypowered.api.command.CommandMeta; 6 | import ooo.foooooooooooo.velocitydiscord.VelocityDiscord; 7 | import ooo.foooooooooooo.velocitydiscord.config.PluginConfig; 8 | 9 | public final class Commands { 10 | private static CommandMeta COMMAND; 11 | 12 | public static void registerCommands(CommandManager commandManager, PluginConfig config) { 13 | var node = BrigadierCommand 14 | .literalArgumentBuilder(config.global.minecraft.pluginCommand) 15 | .then(ReloadCommand.create()) 16 | .then(TopicPreviewCommand.create()) 17 | .build(); 18 | 19 | var command = new BrigadierCommand(node); 20 | 21 | var meta = commandManager.metaBuilder(command).plugin(VelocityDiscord.getInstance()).build(); 22 | 23 | COMMAND = meta; 24 | 25 | commandManager.register(meta, command); 26 | } 27 | 28 | public static void unregisterCommands(CommandManager commandManager) { 29 | commandManager.unregister(COMMAND); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/commands/ReloadCommand.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.commands; 2 | 3 | import com.mojang.brigadier.Command; 4 | import com.mojang.brigadier.builder.LiteralArgumentBuilder; 5 | import com.mojang.brigadier.context.CommandContext; 6 | import com.velocitypowered.api.command.BrigadierCommand; 7 | import com.velocitypowered.api.command.CommandSource; 8 | import ooo.foooooooooooo.velocitydiscord.VelocityDiscord; 9 | 10 | import java.text.MessageFormat; 11 | 12 | public final class ReloadCommand { 13 | public static LiteralArgumentBuilder create() { 14 | return BrigadierCommand 15 | .literalArgumentBuilder("reload") 16 | .requires(source -> source.hasPermission("discord.reload")) 17 | .executes(ReloadCommand::execute); 18 | } 19 | 20 | private static int execute(CommandContext source) { 21 | String error; 22 | 23 | try { 24 | error = VelocityDiscord.getInstance().reloadConfig(); 25 | } catch (Exception e) { 26 | error = e.getMessage(); 27 | } 28 | 29 | if (error == null) { 30 | source.getSource().sendPlainMessage("Config reloaded"); 31 | return Command.SINGLE_SUCCESS; 32 | } else { 33 | source 34 | .getSource() 35 | .sendPlainMessage(MessageFormat.format( 36 | "Error reloading config:\n{0}\n\nFix the error and reload again", 37 | error 38 | )); 39 | return 0; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/commands/TopicPreviewCommand.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.commands; 2 | 3 | import com.mojang.brigadier.Command; 4 | import com.mojang.brigadier.builder.LiteralArgumentBuilder; 5 | import com.mojang.brigadier.context.CommandContext; 6 | import com.velocitypowered.api.command.BrigadierCommand; 7 | import com.velocitypowered.api.command.CommandSource; 8 | import ooo.foooooooooooo.velocitydiscord.VelocityDiscord; 9 | 10 | public final class TopicPreviewCommand { 11 | public static LiteralArgumentBuilder create() { 12 | return BrigadierCommand 13 | .literalArgumentBuilder("topic") 14 | .then(BrigadierCommand 15 | .literalArgumentBuilder("preview") 16 | .requires(source -> source.hasPermission("discord.topic.preview")) 17 | .executes(TopicPreviewCommand::execute)); 18 | } 19 | 20 | private static int execute(CommandContext source) { 21 | var discord = VelocityDiscord.getDiscord(); 22 | 23 | if (discord == null) { 24 | source.getSource().sendPlainMessage("Plugin not initialized"); 25 | return 0; 26 | } 27 | 28 | var topic = discord.generateChannelTopic(); 29 | 30 | source.getSource().sendPlainMessage("Generated channel topic: \n\n" + topic + "\n"); 31 | 32 | return Command.SINGLE_SUCCESS; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/compat/LuckPerms.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.compat; 2 | 3 | import net.luckperms.api.LuckPermsProvider; 4 | import net.luckperms.api.model.user.UserManager; 5 | 6 | public class LuckPerms { 7 | private final net.luckperms.api.LuckPerms luckPerms; 8 | 9 | public LuckPerms() { 10 | this.luckPerms = LuckPermsProvider.get(); 11 | } 12 | 13 | public UserManager getUserManager() { 14 | return this.luckPerms.getUserManager(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/Config.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config; 2 | 3 | import javax.annotation.Nullable; 4 | import java.awt.*; 5 | import java.util.HashMap; 6 | import java.util.Optional; 7 | 8 | @SuppressWarnings({ 9 | "OptionalUsedAsFieldOrParameterType", 10 | "unused" 11 | }) 12 | public class Config { 13 | private static final String INVALID_VALUE_FORMAT_STRING = 14 | "ERROR: `%s` is not a valid value for `%s`, acceptable values: `false`, any string"; 15 | 16 | public static final Color GREEN = new Color(0x40bf4f); 17 | public static final Color RED = new Color(0xbf4040); 18 | 19 | private final com.electronwill.nightconfig.core.Config config; 20 | 21 | public Config(com.electronwill.nightconfig.core.Config config) { 22 | this.config = config; 23 | } 24 | 25 | public boolean isEmpty() { 26 | return this.config.isEmpty(); 27 | } 28 | 29 | public T getOrDefault(String key, T defaultValue) { 30 | return this.config.getOrElse(key, defaultValue); 31 | } 32 | 33 | public T get(String key) { 34 | return this.config.get(key); 35 | } 36 | 37 | public HashMap getMapOrDefault(String key, HashMap defaultValue) { 38 | var value = this.config.getRaw(key); 39 | if (value instanceof com.electronwill.nightconfig.core.Config subConfig) { 40 | var map = new HashMap(); 41 | for (var entry : subConfig.entrySet()) { 42 | map.put(entry.getKey(), entry.getValue()); 43 | } 44 | return map; 45 | } else if (value == null) { 46 | return defaultValue; 47 | } else { 48 | throw new RuntimeException(String.format("ERROR: `%s` is not a valid map for `%s`", value, key)); 49 | } 50 | } 51 | 52 | public @Nullable Config getConfig(String key) { 53 | var value = this.config.getRaw(key); 54 | if (value instanceof com.electronwill.nightconfig.core.Config subConfig) { 55 | return new Config(subConfig); 56 | } else if (value == null) { 57 | return null; 58 | } else { 59 | throw new RuntimeException(String.format("ERROR: `%s` is not a valid config for `%s`", value, key)); 60 | } 61 | } 62 | 63 | public Optional getOptional(String key, Optional defaultValue) { 64 | var value = this.config.getRaw(key); 65 | 66 | switch (value) { 67 | case null -> { 68 | return defaultValue; 69 | } 70 | case Boolean bool -> { 71 | if (!bool) { 72 | return Optional.empty(); 73 | } else { 74 | throw new RuntimeException(String.format(INVALID_VALUE_FORMAT_STRING, "true", key)); 75 | } 76 | } 77 | case String str -> { 78 | if (str.isEmpty()) { 79 | return Optional.empty(); 80 | } 81 | 82 | return Optional.of(str); 83 | } 84 | default -> throw new RuntimeException(String.format(INVALID_VALUE_FORMAT_STRING, value, key)); 85 | } 86 | } 87 | 88 | public Optional getDisableableString(String path) { 89 | return getOptional(path, Optional.empty()); 90 | } 91 | 92 | public Optional getDisableableStringOrDefault(String path, Optional defaultValue) { 93 | return getOptional(path, defaultValue); 94 | } 95 | 96 | public Color getColor(String path) { 97 | return Color.decode(this.config.get(path)); 98 | } 99 | 100 | public Color getColorOrDefault(String path, Color defaultValue) { 101 | return Color.decode(getOrDefault(path, encodeColor(defaultValue))); 102 | } 103 | 104 | public Optional getDisableableColor(String path) { 105 | return getDisableableString(path).map(Color::decode); 106 | } 107 | 108 | public Optional getDisableableColorOrDefault(String path, Optional defaultValue) { 109 | return getDisableableStringOrDefault(path, defaultValue.map(Config::encodeColor)).map(Color::decode); 110 | } 111 | 112 | public static String encodeColor(Color color) { 113 | return String.format("#%02x%02x%02x", color.getRed(), color.getGreen(), color.getBlue()); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/PluginConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config; 2 | 3 | import com.electronwill.nightconfig.core.CommentedConfig; 4 | import com.electronwill.nightconfig.core.file.FileConfig; 5 | import ooo.foooooooooooo.velocitydiscord.Constants; 6 | import ooo.foooooooooooo.velocitydiscord.config.definitions.*; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | 10 | import javax.annotation.Nullable; 11 | import java.io.IOException; 12 | import java.nio.file.Files; 13 | import java.nio.file.Path; 14 | import java.util.HashMap; 15 | import java.util.Objects; 16 | 17 | public class PluginConfig implements ServerConfig { 18 | private static final Logger logger = LoggerFactory.getLogger(PluginConfig.class); 19 | 20 | private static final String[] splitVersion = Constants.PluginVersion.split("\\."); 21 | public static final String ConfigVersion = splitVersion[0] + '.' + splitVersion[1]; 22 | private static final String configMajorVersion = splitVersion[0]; 23 | 24 | private static boolean configCreatedThisRun = false; 25 | 26 | private HashMap serverOverridesMap = new HashMap<>(); 27 | 28 | private Config config; 29 | 30 | public GlobalConfig global = new GlobalConfig(); 31 | public LocalConfig local = new LocalConfig(); 32 | 33 | public PluginConfig(Path dataDir) { 34 | this.config = PluginConfig.loadFile(dataDir); 35 | this.loadConfig(); 36 | this.onLoad(); 37 | } 38 | 39 | public PluginConfig(Config config) { 40 | this.config = config; 41 | this.loadConfig(); 42 | this.onLoad(); 43 | } 44 | 45 | private void loadConfig() { 46 | if (this.config == null || this.config.isEmpty()) { 47 | throw new RuntimeException("ERROR: Config is empty"); 48 | } 49 | 50 | this.global.load(this.config); 51 | this.local.load(this.config); 52 | } 53 | 54 | private void onLoad() { 55 | var error = checkErrors(this.config); 56 | if (error != null) logger.error(error); 57 | error = this.local.checkErrors(); 58 | if (error != null) logger.error(error); 59 | 60 | this.serverOverridesMap = loadOverrides(this.global, this.config); 61 | } 62 | 63 | private static Config loadFile(Path dataDir) { 64 | if (Files.notExists(dataDir)) { 65 | try { 66 | Files.createDirectory(dataDir); 67 | } catch (IOException e) { 68 | throw new RuntimeException("ERROR: Could not create data directory at " + dataDir.toAbsolutePath()); 69 | } 70 | } 71 | 72 | var configFile = dataDir.resolve("config.toml"); 73 | 74 | // create default config if it doesn't exist 75 | if (Files.notExists(configFile)) { 76 | PluginConfig.configCreatedThisRun = true; 77 | 78 | try (var in = PluginConfig.class.getResourceAsStream("/config.toml")) { 79 | Files.copy(Objects.requireNonNull(in), configFile); 80 | } catch (IOException e) { 81 | throw new RuntimeException("ERROR: Can't write default configuration file (permissions/filesystem error?)"); 82 | } 83 | } 84 | 85 | var fileConfig = FileConfig.of(configFile); 86 | fileConfig.load(); 87 | 88 | return new Config(fileConfig); 89 | } 90 | 91 | private static boolean versionCompatible(String newVersion) { 92 | return newVersion != null && newVersion.split("\\.")[0].equals(configMajorVersion); 93 | } 94 | 95 | private static @Nullable String checkErrors(Config config) { 96 | if (config == null || config.isEmpty()) { 97 | return ("ERROR: Config is empty"); 98 | } 99 | 100 | // check for compatible config version 101 | String version = config.get("config_version"); 102 | 103 | if (!versionCompatible(version)) { 104 | return String.format( 105 | "ERROR: Can't use the existing configuration file: version mismatch (mod: %s, config: %s)", 106 | ConfigVersion, 107 | version 108 | ); 109 | } 110 | 111 | return null; 112 | } 113 | 114 | // Assume it's the first run if the config hasn't been edited or has been created this run 115 | public boolean isConfigNotSetup() { 116 | return configCreatedThisRun || this.global.discord.isTokenUnset() || this.local.discord.isDefaultChannel(); 117 | } 118 | 119 | public static HashMap loadOverrides(GlobalConfig baseGlobalConfig, Config baseConfig) { 120 | // server overrides 121 | 122 | CommentedConfig overrideConfig = baseConfig.get("override"); 123 | 124 | if (overrideConfig == null) { 125 | logger.debug("No server overrides found"); 126 | return new HashMap<>(); 127 | } 128 | 129 | var overrides = new HashMap(); 130 | 131 | for (var entry : overrideConfig.entrySet()) { 132 | if (entry.getValue() instanceof com.electronwill.nightconfig.core.Config serverOverride) { 133 | var serverName = entry.getKey(); 134 | 135 | // todo: maybe better than this 136 | if (baseGlobalConfig.excludedServers.contains(serverName) && !baseGlobalConfig.excludedServersReceiveMessages) { 137 | logger.info("Ignoring override for excluded server: {}", serverName); 138 | continue; 139 | } 140 | 141 | var config = new Config(serverOverride); 142 | overrides.put(serverName, new ServerOverrideConfig(baseConfig, config)); 143 | } else { 144 | logger.warn("Invalid server override for `{}`: `{}`", entry.getKey(), entry.getValue()); 145 | } 146 | } 147 | 148 | return overrides; 149 | } 150 | 151 | public boolean serverDisabled(String name) { 152 | return this.global.excludedServers.contains(name); 153 | } 154 | 155 | public String serverName(String name) { 156 | return this.global.serverDisplayNames.getOrDefault(name, name); 157 | } 158 | 159 | public @Nullable String reloadConfig(Path dataDirectory) { 160 | try { 161 | var newConfig = PluginConfig.loadFile(dataDirectory); 162 | 163 | var errors = checkErrors(newConfig); 164 | if (errors != null && !errors.isEmpty()) return errors; 165 | 166 | var newGlobal = new GlobalConfig(); 167 | var newLocal = new LocalConfig(); 168 | 169 | newGlobal.load(newConfig); 170 | newLocal.load(newConfig); 171 | 172 | var overrides = loadOverrides(newGlobal, newConfig); 173 | 174 | errors = newLocal.checkErrors(); 175 | if (errors != null && !errors.isEmpty()) return errors; 176 | 177 | this.serverOverridesMap = overrides; 178 | 179 | this.config = newConfig; 180 | 181 | this.global = newGlobal; 182 | this.local = newLocal; 183 | 184 | return null; 185 | } catch (Exception e) { 186 | return "ERROR: " + e.getMessage(); 187 | } 188 | } 189 | 190 | public ServerConfig getServerConfig(String serverName) { 191 | var override = this.serverOverridesMap.get(serverName); 192 | if (override != null) return override; 193 | return this; 194 | } 195 | 196 | @Override 197 | public DiscordConfig getDiscordConfig() { 198 | return this.local.discord; 199 | } 200 | 201 | @Override 202 | public ChatConfig getChatConfig() { 203 | return this.local.discord.chat; 204 | } 205 | 206 | @Override 207 | public MinecraftConfig getMinecraftConfig() { 208 | return this.local.minecraft; 209 | } 210 | 211 | public static class ServerOverrideConfig implements ServerConfig { 212 | public final LocalConfig local = new LocalConfig(); 213 | 214 | public ServerOverrideConfig(Config baseConfig, Config overrideConfig) { 215 | this.local.load(baseConfig); 216 | // load the override config on top of the base config 217 | this.local.load(overrideConfig); 218 | } 219 | 220 | @Override 221 | public DiscordConfig getDiscordConfig() { 222 | return this.local.discord; 223 | } 224 | 225 | @Override 226 | public ChatConfig getChatConfig() { 227 | return this.local.discord.chat; 228 | } 229 | 230 | @Override 231 | public MinecraftConfig getMinecraftConfig() { 232 | return this.local.minecraft; 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/ServerConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.definitions.ChatConfig; 4 | import ooo.foooooooooooo.velocitydiscord.config.definitions.DiscordConfig; 5 | import ooo.foooooooooooo.velocitydiscord.config.definitions.MinecraftConfig; 6 | 7 | public interface ServerConfig { 8 | DiscordConfig getDiscordConfig(); 9 | 10 | ChatConfig getChatConfig(); 11 | 12 | MinecraftConfig getMinecraftConfig(); 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/ChannelTopicConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.Config; 4 | 5 | import java.util.Optional; 6 | 7 | @SuppressWarnings("OptionalUsedAsFieldOrParameterType") 8 | public class ChannelTopicConfig { 9 | 10 | /// Template for the channel topic 11 | /// 12 | /// Placeholders available: 13 | /// - `players`: Total number of players online 14 | /// - `player_list`: List of players (format is defined below) 15 | /// - `servers`: Number of servers 16 | /// - `server_list`: List of server names 17 | /// - `hostname`: Server hostname 18 | /// - `port`: Server port 19 | /// - `motd`: Message of the Day (MOTD) 20 | /// - `query_port`: Query port 21 | /// - `max_players`: Maximum number of players 22 | /// - `plugins`: Number of plugins 23 | /// - `plugin_list`: List of plugin names 24 | /// - `version`: Server version 25 | /// - `software`: Software name 26 | /// - `average_ping`: Average ping of all players 27 | /// - `uptime`: Server uptime in hours and minutes 28 | /// - `server[SERVERNAME]`: Dynamic placeholder for each server's name and status (e.g., `server[MyServer]`, ` 29 | /// server[AnotherServer]`, `server[Lobby]`, etc.) 30 | public Optional format = 31 | Optional.of("{players}/{max_players} {player_list} {hostname}:{port} Uptime: {uptime}"); 32 | 33 | /// Template for `server[SERVERNAME]` placeholder in the channel topic 34 | /// 35 | /// Placeholders available: `name`, `players`, `max_players`, `motd`, `version`, `protocol` 36 | public Optional serverFormat = Optional.of("{name}: {players}/{max_players}"); 37 | 38 | /// Template for `server[SERVERNAME]` placeholder in the channel topic when the server is offline 39 | /// 40 | /// Placeholders available: `name` 41 | public Optional serverOfflineFormat = Optional.of("{name}: Offline"); 42 | 43 | public Optional playerListNoPlayersHeader = Optional.of("No players online"); 44 | 45 | public Optional playerListHeader = Optional.of("Players: "); 46 | 47 | /// Placeholders available: `username`, `ping` 48 | public String playerListPlayerFormat = "{username}"; 49 | 50 | /// Separator between players in the list, \n can be used for new line 51 | public String playerListSeparator = ", "; 52 | 53 | /// Maximum number of players to show in the topic 54 | /// 55 | /// Set to 0 to show all players 56 | public int playerListMaxCount = 10; 57 | 58 | public void load(Config config) { 59 | if (config == null) return; 60 | 61 | this.format = config.getDisableableStringOrDefault("format", this.format); 62 | this.serverFormat = config.getDisableableStringOrDefault("server", this.serverFormat); 63 | this.serverOfflineFormat = config.getDisableableStringOrDefault("server_offline", this.serverOfflineFormat); 64 | this.playerListNoPlayersHeader = 65 | config.getDisableableStringOrDefault("player_list_no_players_header", this.playerListNoPlayersHeader); 66 | this.playerListHeader = config.getDisableableStringOrDefault("player_list_header", this.playerListHeader); 67 | this.playerListPlayerFormat = config.getOrDefault("player_list_player", this.playerListPlayerFormat); 68 | this.playerListSeparator = config.getOrDefault("player_list_separator", this.playerListSeparator); 69 | this.playerListMaxCount = config.getOrDefault("player_list_max_count", this.playerListMaxCount); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/ChatConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.Config; 4 | 5 | public class ChatConfig { 6 | public UserMessageConfig message = new UserMessageConfig("{username}: {message}", null); 7 | public UserMessageConfig join = new UserMessageConfig("**{username} joined the game**", Config.GREEN); 8 | public UserMessageConfig leave = new UserMessageConfig("**{username} left the game**", Config.RED); 9 | public UserMessageConfig disconnect = new UserMessageConfig("**{username} disconnected**", Config.RED); 10 | public UserMessageConfig death = new UserMessageConfig("**{death_message}**", Config.RED); 11 | public UserMessageConfig advancement = new UserMessageConfig( 12 | "**{username} has made the advancement __{advancement_title}__**\n_{advancement_description}_", 13 | Config.GREEN 14 | ); 15 | 16 | public UserMessageConfig serverSwitch = 17 | new UserMessageConfig("**{username} moved to {current} from {previous}**", Config.GREEN); 18 | 19 | public SystemMessageConfig serverStart = new SystemMessageConfig("**{server} has started**", Config.GREEN); 20 | public SystemMessageConfig serverStop = new SystemMessageConfig("**{server} has stopped**", Config.RED); 21 | 22 | public void load(Config config) { 23 | if (config == null) return; 24 | 25 | this.message.load(config.getConfig("message")); 26 | this.join.load(config.getConfig("join")); 27 | this.leave.load(config.getConfig("leave")); 28 | this.disconnect.load(config.getConfig("disconnect")); 29 | this.death.load(config.getConfig("death")); 30 | this.advancement.load(config.getConfig("advancement")); 31 | 32 | this.serverSwitch.load(config.getConfig("server_switch")); 33 | 34 | this.serverStart.load(config.getConfig("server_start")); 35 | this.serverStop.load(config.getConfig("server_stop")); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/DiscordConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.Config; 4 | import ooo.foooooooooooo.velocitydiscord.config.definitions.commands.CommandConfig; 5 | import ooo.foooooooooooo.velocitydiscord.discord.MessageCategory; 6 | 7 | import java.util.Arrays; 8 | 9 | public class DiscordConfig { 10 | private static final String DEFAULT_CHANNEL_ID = "000000000000000000"; 11 | 12 | /** 13 | * Default channel ID to send Minecraft chat messages to 14 | */ 15 | public String mainChannelId = DEFAULT_CHANNEL_ID; 16 | 17 | /** 18 | * Show messages from bots in Minecraft chat 19 | */ 20 | public boolean showBotMessages = false; 21 | /** 22 | * Show clickable links for attachments in Minecraft chat 23 | */ 24 | public boolean showAttachmentsIngame = true; 25 | 26 | /** 27 | * Enable mentioning Discord users from Minecraft chat 28 | */ 29 | public boolean enableMentions = true; 30 | /** 31 | * Enable @everyone and @here pings from Minecraft chat 32 | */ 33 | public boolean enableEveryoneAndHere = false; 34 | 35 | /** 36 | * Interval (in minutes) for updating the channel topic 37 | */ 38 | public int updateChannelTopicIntervalMinutes = 0; 39 | 40 | public ChatConfig chat = new ChatConfig(); 41 | 42 | public CommandConfig commands = new CommandConfig(); 43 | public ChannelTopicConfig channelTopic = new ChannelTopicConfig(); 44 | 45 | public WebhookConfig webhook = new WebhookConfig(); 46 | 47 | public void load(Config config) { 48 | if (config == null) return; 49 | 50 | this.mainChannelId = config.getOrDefault("channel", this.mainChannelId); 51 | this.showBotMessages = config.getOrDefault("show_bot_messages", this.showBotMessages); 52 | this.showAttachmentsIngame = config.getOrDefault("show_attachments_ingame", this.showAttachmentsIngame); 53 | this.enableMentions = config.getOrDefault("enable_mentions", this.enableMentions); 54 | this.enableEveryoneAndHere = config.getOrDefault("enable_everyone_and_here", this.enableEveryoneAndHere); 55 | this.updateChannelTopicIntervalMinutes = 56 | config.getOrDefault("update_channel_topic_interval", this.updateChannelTopicIntervalMinutes); 57 | 58 | this.chat.load(config.getConfig("chat")); 59 | 60 | this.commands.load(config.getConfig("commands")); 61 | this.channelTopic.load(config.getConfig("channel_topic")); 62 | 63 | this.webhook.load(config.getConfig("webhook")); 64 | } 65 | 66 | public boolean isWebhookUsed() { 67 | return Arrays.stream(new UserMessageType[]{ 68 | this.chat.message.type, 69 | this.chat.death.type, 70 | this.chat.advancement.type, 71 | this.chat.join.type, 72 | this.chat.leave.type, 73 | this.chat.disconnect.type, 74 | this.chat.serverSwitch.type, 75 | }).anyMatch(t -> t == UserMessageType.WEBHOOK); 76 | } 77 | 78 | public WebhookConfig getWebhookConfig(MessageCategory type) { 79 | var messageSpecificWebhook = switch (type) { 80 | case ADVANCEMENT -> this.chat.advancement.webhook; 81 | case MESSAGE -> this.chat.message.webhook; 82 | case JOIN -> this.chat.join.webhook; 83 | case DEATH -> this.chat.death.webhook; 84 | case LEAVE -> this.chat.leave.webhook; 85 | case DISCONNECT -> this.chat.disconnect.webhook; 86 | case SERVER_SWITCH -> this.chat.serverSwitch.webhook; 87 | }; 88 | 89 | return messageSpecificWebhook.orElse(this.webhook); 90 | } 91 | 92 | public boolean isDefaultChannel() { 93 | return this.mainChannelId.equals(DEFAULT_CHANNEL_ID); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/GlobalChatConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.Config; 4 | 5 | public class GlobalChatConfig { 6 | public SystemMessageConfig proxyStart = new SystemMessageConfig("**Proxy started**", Config.GREEN); 7 | public SystemMessageConfig proxyStop = new SystemMessageConfig("**Proxy stopped**", Config.RED); 8 | 9 | public void load(Config config) { 10 | if (config == null) return; 11 | 12 | this.proxyStart.load(config.getConfig("proxy_start")); 13 | this.proxyStop.load(config.getConfig("proxy_stop")); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/GlobalConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions; 2 | 3 | 4 | import ooo.foooooooooooo.velocitydiscord.config.Config; 5 | 6 | import java.util.ArrayList; 7 | import java.util.HashMap; 8 | import java.util.List; 9 | 10 | public class GlobalConfig { 11 | public List excludedServers = new ArrayList<>(); 12 | public boolean excludedServersReceiveMessages = false; 13 | 14 | /** 15 | * How often to ping all servers to check for online status (seconds) 16 | */ 17 | public int pingIntervalSeconds = 30; 18 | 19 | public HashMap serverDisplayNames = new HashMap<>(); 20 | 21 | public GlobalDiscordConfig discord = new GlobalDiscordConfig(); 22 | public GlobalMinecraftConfig minecraft = new GlobalMinecraftConfig(); 23 | 24 | public void load(Config config) { 25 | this.excludedServers = config.getOrDefault("exclude_servers", this.excludedServers); 26 | this.excludedServersReceiveMessages = 27 | config.getOrDefault("excluded_servers_receive_messages", this.excludedServersReceiveMessages); 28 | 29 | this.pingIntervalSeconds = config.getOrDefault("ping_interval", this.pingIntervalSeconds); 30 | 31 | this.serverDisplayNames = config.getMapOrDefault("server_names", this.serverDisplayNames); 32 | 33 | this.discord.load(config.getConfig("discord")); 34 | this.minecraft.load(config.getConfig("minecraft")); 35 | } 36 | 37 | public boolean pingIntervalEnabled() { 38 | return this.pingIntervalSeconds > 0; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/GlobalDiscordConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions; 2 | 3 | 4 | import ooo.foooooooooooo.velocitydiscord.config.Config; 5 | import ooo.foooooooooooo.velocitydiscord.config.definitions.commands.GlobalCommandConfig; 6 | 7 | import java.util.Optional; 8 | 9 | @SuppressWarnings("OptionalUsedAsFieldOrParameterType") 10 | public class GlobalDiscordConfig { 11 | public String token; 12 | 13 | /** 14 | * Activity text of the bot to show in Discord 15 | *

16 | * Placeholders available: {amount} 17 | */ 18 | public Optional activityText = Optional.of("with {amount} players online"); 19 | 20 | /** 21 | * Set the interval (in minutes) for updating the channel topic 22 | *

23 | * Use a value of 0 to disable 24 | */ 25 | public int updateChannelTopicIntervalMinutes = 0; 26 | 27 | public GlobalChatConfig chat = new GlobalChatConfig(); 28 | public GlobalCommandConfig commands = new GlobalCommandConfig(); 29 | 30 | public void load(Config config) { 31 | if (config == null) return; 32 | 33 | this.token = config.get("token"); 34 | this.activityText = config.getDisableableStringOrDefault("activity_text", this.activityText); 35 | this.updateChannelTopicIntervalMinutes = 36 | config.getOrDefault("update_channel_topic_interval", this.updateChannelTopicIntervalMinutes); 37 | 38 | this.chat.load(config.getConfig("chat")); 39 | this.commands.load(config.getConfig("commands")); 40 | } 41 | 42 | public boolean updateChannelTopicEnabled() { 43 | return this.updateChannelTopicIntervalMinutes > 0; 44 | } 45 | 46 | public boolean isTokenUnset() { 47 | return this.token.equals("TOKEN") || this.token.isBlank(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/GlobalMinecraftConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.Config; 4 | 5 | public class GlobalMinecraftConfig { 6 | public String pluginCommand = "discord"; 7 | 8 | public void load(Config config) { 9 | if (config == null) return; 10 | 11 | this.pluginCommand = config.getOrDefault("plugin_command", this.pluginCommand); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/LocalConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.Config; 4 | import ooo.foooooooooooo.velocitydiscord.discord.MessageCategory; 5 | 6 | import java.util.ArrayList; 7 | 8 | public class LocalConfig { 9 | public DiscordConfig discord = new DiscordConfig(); 10 | public MinecraftConfig minecraft = new MinecraftConfig(); 11 | 12 | public void load(Config config) { 13 | this.discord.load(config.getConfig("discord")); 14 | this.minecraft.load(config.getConfig("minecraft")); 15 | } 16 | 17 | 18 | public String checkErrors() { 19 | if (this.discord.isWebhookUsed() && this.discord.webhook.isInvalid()) { 20 | var invalidCategories = new ArrayList(); 21 | 22 | var chat = this.discord.chat; 23 | 24 | // check each message category 25 | if (chat.message.isInvalidWebhook()) invalidCategories.add(MessageCategory.MESSAGE); 26 | if (chat.join.isInvalidWebhook()) invalidCategories.add(MessageCategory.JOIN); 27 | if (chat.leave.isInvalidWebhook()) invalidCategories.add(MessageCategory.LEAVE); 28 | if (chat.disconnect.isInvalidWebhook()) invalidCategories.add(MessageCategory.DISCONNECT); 29 | if (chat.serverSwitch.isInvalidWebhook()) invalidCategories.add(MessageCategory.SERVER_SWITCH); 30 | if (chat.advancement.isInvalidWebhook()) invalidCategories.add(MessageCategory.ADVANCEMENT); 31 | if (chat.death.isInvalidWebhook()) invalidCategories.add(MessageCategory.DEATH); 32 | 33 | if (!invalidCategories.isEmpty()) { 34 | var errorFormat = """ 35 | ERROR: `discord.webhook` and `discord.chat.%s.webhook` are unset or invalid, but `discord.chat.%s.type` is set to `webhook`"""; 36 | var error = new StringBuilder(); 37 | 38 | for (var category : invalidCategories) { 39 | error.append(String.format(errorFormat, category.toString(), category)).append("\n"); 40 | } 41 | 42 | return error.toString(); 43 | } 44 | } 45 | 46 | return null; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/MinecraftConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.Config; 4 | 5 | import java.util.HashMap; 6 | import java.util.Optional; 7 | 8 | @SuppressWarnings("OptionalUsedAsFieldOrParameterType") 9 | public class MinecraftConfig { 10 | /// Placeholders available: `discord` 11 | public String discordChunkFormat = "[<{discord_color}>Discord]"; 12 | 13 | /// Placeholders available: `role_color`, `display_name`, `username`, `nickname` 14 | /// 15 | /// `` tag allows you to shift right-click the username to insert `@username` in the chat 16 | public String usernameChunkFormat = 17 | "<{role_color}>{nickname}"; 18 | 19 | /// Placeholders available: {discord_chunk}, {username_chunk}, {attachments}, {message} 20 | public String messageFormat = 21 | "{discord_chunk} {role_prefix} {username_chunk}: {message} {attachments}"; 22 | 23 | /// Placeholders available: `url`, `attachment_color` 24 | public String attachmentFormat = 25 | "[<{attachment_color}>Attachment]"; 26 | 27 | /// Placeholders available: `url`, `link_color` 28 | public Optional linkFormat = Optional.of(""" 29 | [<{link_color}>Link]"""); 30 | 31 | public String discordColor = "#7289da"; 32 | public String attachmentColor = "#4abdff"; 33 | public String linkColor = "#4abdff"; 34 | 35 | public HashMap rolePrefixes = new HashMap<>(); 36 | 37 | public void load(Config config) { 38 | if (config == null) return; 39 | 40 | this.discordChunkFormat = config.getOrDefault("discord_chunk", this.discordChunkFormat); 41 | this.usernameChunkFormat = config.getOrDefault("username_chunk", this.usernameChunkFormat); 42 | this.messageFormat = config.getOrDefault("message", this.messageFormat); 43 | this.attachmentFormat = config.getOrDefault("attachments", this.attachmentFormat); 44 | this.linkFormat = config.getDisableableStringOrDefault("links", this.linkFormat); 45 | 46 | this.discordColor = config.getOrDefault("discord_color", this.discordColor); 47 | this.attachmentColor = config.getOrDefault("attachment_color", this.attachmentColor); 48 | this.linkColor = config.getOrDefault("link_color", this.linkColor); 49 | 50 | this.rolePrefixes = config.getMapOrDefault("role_prefixes", this.rolePrefixes); 51 | } 52 | 53 | public String getRolePrefix(String role) { 54 | return this.rolePrefixes.getOrDefault(role, ""); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/SystemMessageConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.Config; 4 | 5 | import java.awt.*; 6 | import java.util.Optional; 7 | 8 | @SuppressWarnings("OptionalUsedAsFieldOrParameterType") 9 | public class SystemMessageConfig { 10 | public SystemMessageType type = SystemMessageType.TEXT; 11 | public Optional channel = Optional.empty(); 12 | 13 | public Optional format; 14 | public Optional embedColor; 15 | 16 | public SystemMessageConfig(String format, Color embedColor) { 17 | this.format = Optional.ofNullable(format); 18 | this.embedColor = Optional.ofNullable(embedColor); 19 | } 20 | 21 | public void load(Config config) { 22 | if (config == null) return; 23 | 24 | this.type = SystemMessageType.get(config, "type", this.type); 25 | this.channel = config.getDisableableStringOrDefault("channel", this.channel); 26 | 27 | this.format = config.getDisableableStringOrDefault("format", this.format); 28 | this.embedColor = config.getDisableableColorOrDefault("embed_color", this.embedColor); 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/SystemMessageType.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.Config; 4 | 5 | public enum SystemMessageType { 6 | TEXT, EMBED; 7 | 8 | public static SystemMessageType get(Config config, String key, SystemMessageType defaultValue) { 9 | var type = config.getOrDefault(key, defaultValue.toString()); 10 | return switch (type) { 11 | case "text" -> TEXT; 12 | case "embed" -> EMBED; 13 | case "" -> defaultValue; 14 | default -> throw new IllegalArgumentException("Unknown system message type: " + type); 15 | }; 16 | } 17 | 18 | @Override 19 | public String toString() { 20 | return switch (this) { 21 | case TEXT -> "text"; 22 | case EMBED -> "embed"; 23 | }; 24 | } 25 | } 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/UserMessageConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.Config; 4 | 5 | import java.awt.*; 6 | import java.util.Optional; 7 | 8 | @SuppressWarnings("OptionalUsedAsFieldOrParameterType") 9 | public class UserMessageConfig { 10 | public UserMessageType type = UserMessageType.TEXT; 11 | public Optional channelId = Optional.empty(); 12 | 13 | public Optional format; 14 | public Optional embedColor; 15 | 16 | public Optional webhook = Optional.empty(); 17 | 18 | public UserMessageConfig(String format, Color embedColor) { 19 | this.format = Optional.ofNullable(format); 20 | this.embedColor = Optional.ofNullable(embedColor); 21 | } 22 | 23 | public void load(Config config) { 24 | if (config == null) return; 25 | 26 | this.type = UserMessageType.get(config, "type", this.type); 27 | this.channelId = config.getDisableableStringOrDefault("channel", this.channelId); 28 | 29 | this.format = config.getDisableableStringOrDefault("format", this.format); 30 | this.embedColor = config.getDisableableColorOrDefault("embed_color", this.embedColor); 31 | 32 | var webhookConfig = config.getConfig("webhook"); 33 | 34 | if (webhookConfig == null) { 35 | this.webhook = Optional.empty(); 36 | } else { 37 | this.webhook = Optional.of(new WebhookConfig()); 38 | this.webhook.get().load(webhookConfig); 39 | } 40 | } 41 | 42 | public boolean isInvalidWebhook() { 43 | return this.type == UserMessageType.WEBHOOK && ( 44 | this.webhook.isEmpty() || (this.webhook.get().isInvalid()) 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/UserMessageType.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.Config; 4 | 5 | public enum UserMessageType { 6 | TEXT, EMBED, WEBHOOK; 7 | 8 | public static UserMessageType get(Config config, String key, UserMessageType defaultValue) { 9 | var type = config.getOrDefault(key, defaultValue.toString()); 10 | return switch (type) { 11 | case "text" -> TEXT; 12 | case "embed" -> EMBED; 13 | case "webhook" -> WEBHOOK; 14 | case "" -> defaultValue; 15 | default -> throw new IllegalArgumentException("Unknown system message type: " + type); 16 | }; 17 | } 18 | 19 | @Override 20 | public String toString() { 21 | return switch (this) { 22 | case TEXT -> "text"; 23 | case EMBED -> "embed"; 24 | case WEBHOOK -> "webhook"; 25 | }; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/WebhookConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.Config; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | 7 | import java.util.regex.Pattern; 8 | 9 | public class WebhookConfig { 10 | private static final Logger logger = LoggerFactory.getLogger(WebhookConfig.class); 11 | 12 | private static final Pattern WEBHOOK_URL_REGEX = Pattern.compile( 13 | "https?://(?:[^\\s.]+\\.)?discord(?:app)?\\.com/api(?:/v\\d+)?/webhooks/(?\\d+)/(?[^\\s/]+)", 14 | Pattern.CASE_INSENSITIVE 15 | ); 16 | 17 | /// Full webhook URL to send chat messages to 18 | public String url = ""; 19 | 20 | /// Full URL of an avatar service to get the player's avatar from 21 | /// 22 | /// Placeholders available: `uuid`, `username` 23 | public String avatarUrl = "https://visage.surgeplay.com/face/96/{uuid}"; 24 | 25 | /// The format of the webhook's username 26 | /// 27 | /// Placeholders available: `username`, `server` 28 | public String username = "{username}"; 29 | 30 | public boolean valid = false; 31 | public String id; 32 | 33 | public void load(Config config) { 34 | if (config == null) return; 35 | 36 | this.url = config.getOrDefault("url", this.url); 37 | this.avatarUrl = config.getOrDefault("avatar_url", this.avatarUrl); 38 | this.username = config.getOrDefault("username", this.username); 39 | 40 | var matcher = WEBHOOK_URL_REGEX.matcher(this.url); 41 | this.valid = matcher.matches(); 42 | 43 | if (this.valid) { 44 | this.id = matcher.group("id"); 45 | } else if (!this.url.isEmpty()) { 46 | logger.warn("Invalid webhook URL: {}", this.url); 47 | } 48 | } 49 | 50 | public boolean isInvalid() { 51 | return this.url.isEmpty() || !this.valid; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/commands/CommandConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions.commands; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.Config; 4 | 5 | public class CommandConfig { 6 | public ListCommandConfig list = new ListCommandConfig(); 7 | 8 | public void load(Config config) { 9 | if (config == null) return; 10 | 11 | this.list.load(config.getConfig("list")); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/commands/GlobalCommandConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions.commands; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.Config; 4 | 5 | public class GlobalCommandConfig { 6 | public GlobalListCommandConfig list = new GlobalListCommandConfig(); 7 | 8 | public void load(Config config) { 9 | if (config == null) return; 10 | 11 | this.list.load(config.getConfig("list")); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/commands/GlobalListCommandConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions.commands; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.Config; 4 | 5 | public class GlobalListCommandConfig { 6 | public boolean enabled = true; 7 | 8 | /** 9 | * Ephemeral messages are only visible to the user who sent the command 10 | */ 11 | public boolean ephemeral = true; 12 | 13 | public String codeblockLang = "asciidoc"; 14 | 15 | public void load(Config config) { 16 | if (config == null) return; 17 | 18 | this.enabled = config.getOrDefault("enabled", this.enabled); 19 | this.ephemeral = config.getOrDefault("ephemeral", this.ephemeral); 20 | this.codeblockLang = config.getOrDefault("codeblock_lang", this.codeblockLang); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/config/definitions/commands/ListCommandConfig.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config.definitions.commands; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.Config; 4 | 5 | import java.util.Optional; 6 | 7 | @SuppressWarnings("OptionalUsedAsFieldOrParameterType") 8 | public class ListCommandConfig { 9 | /// Placeholders available: `server_name`, `online_players`, `max_players` 10 | public String serverFormat = "[{server_name} {online_players}/{max_players}]"; 11 | 12 | /// Placeholders available: `username` 13 | public String playerFormat = "- {username}"; 14 | 15 | public Optional noPlayersFormat = Optional.of("No players online"); 16 | 17 | public Optional serverOfflineFormat = Optional.of("Server offline"); 18 | 19 | public void load(Config config) { 20 | if (config == null) return; 21 | 22 | this.serverFormat = config.getOrDefault("server_format", this.serverFormat); 23 | this.playerFormat = config.getOrDefault("player_format", this.playerFormat); 24 | this.noPlayersFormat = config.getDisableableStringOrDefault("no_players", this.noPlayersFormat); 25 | this.serverOfflineFormat = config.getDisableableStringOrDefault("server_offline", this.serverOfflineFormat); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/discord/MessageCategory.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.discord; 2 | 3 | public enum MessageCategory { 4 | JOIN, SERVER_SWITCH, DISCONNECT, LEAVE, DEATH, ADVANCEMENT, MESSAGE; 5 | 6 | @Override 7 | public String toString() { 8 | return switch (this) { 9 | case JOIN -> "join"; 10 | case SERVER_SWITCH -> "server_switch"; 11 | case DISCONNECT -> "disconnect"; 12 | case LEAVE -> "leave"; 13 | case DEATH -> "death"; 14 | case ADVANCEMENT -> "advancement"; 15 | case MESSAGE -> "message"; 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/discord/MessageListener.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.discord; 2 | 3 | import net.dv8tion.jda.api.JDA; 4 | import net.dv8tion.jda.api.entities.Message; 5 | import net.dv8tion.jda.api.entities.channel.ChannelType; 6 | import net.dv8tion.jda.api.events.message.MessageReceivedEvent; 7 | import net.dv8tion.jda.api.hooks.ListenerAdapter; 8 | import net.kyori.adventure.text.minimessage.MiniMessage; 9 | import ooo.foooooooooooo.velocitydiscord.VelocityDiscord; 10 | import ooo.foooooooooooo.velocitydiscord.util.StringTemplate; 11 | 12 | import javax.annotation.Nonnull; 13 | import java.awt.*; 14 | import java.net.URI; 15 | import java.util.*; 16 | import java.util.List; 17 | import java.util.regex.Matcher; 18 | import java.util.regex.Pattern; 19 | 20 | public class MessageListener extends ListenerAdapter { 21 | private static final Pattern LINK_REGEX = Pattern.compile( 22 | "[hH][tT]{2}[pP][sS]?://([a-zA-Z0-9\\u00a1-\\uffff]+(:[a-zA-Z0-9\\u00a1-\\uffff]+)?@)" + 23 | "?[a-zA-Z0-9\\u00a1-\\uffff][a-zA-Z0-9\\u00a1-\\uffff_-]{0,62}(?:\\.[a-zA-Z0-9\\u00a1-\\uffff_-]{1,62})*" + 24 | "(?::\\d{1,5})?(?:/[a-zA-Z0-9\\u00a1-\\uffff_\\-().]*)*(?:[?#][a-zA-Z0-9\\u00a1-\\uffff_\\-()?/=&#%.*]*)?"); 25 | 26 | private final HashMap serverChannels; 27 | private final HashMap> channelToServersMap = new HashMap<>(); 28 | 29 | private JDA jda; 30 | 31 | public MessageListener(HashMap serverChannels) { 32 | this.serverChannels = serverChannels; 33 | onServerChannelsUpdated(); 34 | } 35 | 36 | public void onServerChannelsUpdated() { 37 | this.channelToServersMap.clear(); 38 | 39 | for (var entry : this.serverChannels.entrySet()) { 40 | this.channelToServersMap 41 | .computeIfAbsent(entry.getValue().chatChannel.getIdLong(), (k) -> new ArrayList<>()) 42 | .add(entry.getKey()); 43 | } 44 | } 45 | 46 | @Override 47 | public void onMessageReceived(@Nonnull MessageReceivedEvent event) { 48 | if (!event.isFromType(ChannelType.TEXT)) { 49 | VelocityDiscord.LOGGER.trace("ignoring non text channel message"); 50 | return; 51 | } 52 | 53 | if (this.jda == null) { 54 | this.jda = event.getJDA(); 55 | } 56 | 57 | var channel = event.getChannel().asTextChannel(); 58 | var targetServerNames = this.channelToServersMap.get(channel.getIdLong()); 59 | 60 | if (targetServerNames == null) { 61 | return; 62 | } 63 | 64 | VelocityDiscord.LOGGER.trace( 65 | "Received message from Discord channel {} for servers {}", 66 | channel.getName(), 67 | targetServerNames 68 | ); 69 | 70 | var messages = new HashMap(); 71 | for (var serverName : targetServerNames) { 72 | messages.put(serverName, serializeMinecraftMessage(event, serverName)); 73 | } 74 | 75 | for (var server : VelocityDiscord.SERVER.getAllServers()) { 76 | var serverName = server.getServerInfo().getName(); 77 | if (!VelocityDiscord.CONFIG.global.excludedServersReceiveMessages && 78 | VelocityDiscord.CONFIG.serverDisabled(serverName)) { 79 | continue; 80 | } 81 | 82 | var message = messages.get(serverName); 83 | if (message == null) continue; 84 | 85 | server.sendMessage(MiniMessage.miniMessage().deserialize(message).asComponent()); 86 | } 87 | } 88 | 89 | private String serializeMinecraftMessage(MessageReceivedEvent event, String server) { 90 | var serverConfig = VelocityDiscord.CONFIG.getServerConfig(server); 91 | var serverMinecraftConfig = serverConfig.getMinecraftConfig(); 92 | var serverDiscordConfig = serverConfig.getDiscordConfig(); 93 | 94 | var author = event.getAuthor(); 95 | if (!serverDiscordConfig.showBotMessages && author.isBot()) { 96 | VelocityDiscord.LOGGER.debug("ignoring bot message"); 97 | return null; 98 | } 99 | 100 | if (author.getId().equals(this.jda.getSelfUser().getId()) || ( 101 | author.getId().equals(serverDiscordConfig.webhook.id) 102 | )) { 103 | VelocityDiscord.LOGGER.debug("ignoring own message"); 104 | return null; 105 | } 106 | 107 | var message = event.getMessage(); 108 | var guild = event.getGuild(); 109 | 110 | var color = Color.white; 111 | var nickname = author.getName(); // Nickname defaults to username 112 | var rolePrefix = ""; 113 | 114 | var member = guild.getMember(author); 115 | if (member != null) { 116 | color = member.getColor(); 117 | if (color == null) { 118 | color = Color.white; 119 | } 120 | nickname = member.getEffectiveName(); 121 | 122 | // Get the role prefix 123 | var highestRole = member 124 | .getRoles() 125 | .stream() 126 | .filter(role -> !serverMinecraftConfig.getRolePrefix(role.getId()).isEmpty()) 127 | .findFirst(); 128 | 129 | rolePrefix = highestRole.map(role -> serverMinecraftConfig.getRolePrefix(role.getId())).orElse(""); 130 | } 131 | 132 | var hex = "#" + Integer.toHexString(color.getRGB()).substring(2); 133 | 134 | // parse configured message formats 135 | var discord_chunk = new StringTemplate(serverMinecraftConfig.discordChunkFormat) 136 | .add("discord_color", serverMinecraftConfig.discordColor) 137 | .toString(); 138 | 139 | var display_name = author.getGlobalName(); 140 | 141 | if (display_name == null) { 142 | display_name = author.getName(); 143 | } 144 | 145 | var username_chunk = new StringTemplate(serverMinecraftConfig.usernameChunkFormat) 146 | .add("role_color", hex) 147 | .add("username", escapeTags(author.getName())) 148 | .add("display_name", escapeTags(display_name)) 149 | .add("nickname", escapeTags(nickname)) 150 | .toString(); 151 | 152 | var attachment_chunk = serverMinecraftConfig.attachmentFormat; 153 | var message_chunk = new StringTemplate(serverMinecraftConfig.messageFormat) 154 | .add("discord_chunk", discord_chunk) 155 | .add("role_prefix", escapeTags(rolePrefix)) 156 | .add("username_chunk", username_chunk) 157 | .add("message", message.getContentDisplay()); 158 | 159 | var attachmentChunks = new ArrayList(); 160 | 161 | List attachments = new ArrayList<>(); 162 | if (serverDiscordConfig.showAttachmentsIngame) { 163 | attachments = message.getAttachments(); 164 | } 165 | 166 | for (var attachment : attachments) { 167 | var chunk = new StringTemplate(attachment_chunk) 168 | .add("url", attachment.getUrl()) 169 | .add("attachment_color", serverMinecraftConfig.attachmentColor) 170 | .toString(); 171 | 172 | attachmentChunks.add(chunk); 173 | } 174 | 175 | var content = message.getContentDisplay(); 176 | 177 | // Remove leading whitespace from attachments if there's no content 178 | if (content.isBlank()) { 179 | message_chunk = message_chunk.replace(" {attachments}", "{attachments}"); 180 | } 181 | 182 | if (serverMinecraftConfig.linkFormat.isPresent()) { 183 | // Replace links with the link format 184 | content = LINK_REGEX.matcher(content).replaceAll(match -> { 185 | var url = match.group(); 186 | if (validateUrl(url)) { 187 | var replacement = new StringTemplate(serverMinecraftConfig.linkFormat.get()) 188 | .add("url", url) 189 | .add("link_color", serverMinecraftConfig.linkColor) 190 | .toString(); 191 | 192 | return Matcher.quoteReplacement(replacement); 193 | } else { 194 | return Matcher.quoteReplacement(url); 195 | } 196 | }); 197 | } 198 | 199 | message_chunk.add("message", content); 200 | message_chunk.add("attachments", String.join(" ", attachmentChunks)); 201 | 202 | return message_chunk.toString(); 203 | } 204 | 205 | private static final Set SUPPORTED_URI_PROTOCOLS = Set.of("http", "https"); 206 | 207 | private boolean validateUrl(String url) { 208 | try { 209 | return SUPPORTED_URI_PROTOCOLS.contains(new URI(url).getScheme().toLowerCase(Locale.ROOT)); 210 | } catch (Exception e) { 211 | return false; 212 | } 213 | } 214 | 215 | /** 216 | * `<` and `>` within another tag break everything, and `‹` `›` are very close in minecraft font 217 | */ 218 | private String escapeTags(String input) { 219 | return input.replace("<", "‹").replace(">", "›"); 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/discord/commands/ICommand.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.discord.commands; 2 | 3 | import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; 4 | 5 | public interface ICommand { 6 | void handle(SlashCommandInteraction interaction); 7 | 8 | String description(); 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/discord/commands/ListCommand.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.discord.commands; 2 | 3 | import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; 4 | import ooo.foooooooooooo.velocitydiscord.VelocityDiscord; 5 | import ooo.foooooooooooo.velocitydiscord.util.StringTemplate; 6 | 7 | public class ListCommand implements ICommand { 8 | public static final String COMMAND_NAME = "list"; 9 | 10 | public ListCommand() {} 11 | 12 | @Override 13 | public void handle(SlashCommandInteraction interaction) { 14 | final var servers = VelocityDiscord.SERVER.getAllServers(); 15 | 16 | final var sb = new StringBuilder(); 17 | sb.append("```").append(VelocityDiscord.CONFIG.global.discord.commands.list.codeblockLang).append('\n'); 18 | 19 | for (var server : servers) { 20 | var name = server.getServerInfo().getName(); 21 | 22 | if (VelocityDiscord.CONFIG.serverDisabled(name)) { 23 | continue; 24 | } 25 | 26 | var serverDiscordConfig = VelocityDiscord.CONFIG.getServerConfig(name).getDiscordConfig(); 27 | 28 | var players = server.getPlayersConnected(); 29 | 30 | var state = VelocityDiscord.getListener().getServerState(server); 31 | 32 | var serverInfo = new StringTemplate(serverDiscordConfig.commands.list.serverFormat) 33 | .add("server_name", VelocityDiscord.CONFIG.serverName(name)) 34 | .add("online_players", state.players) 35 | .add("max_players", state.maxPlayers) 36 | .toString(); 37 | 38 | sb.append(serverInfo).append('\n'); 39 | 40 | if (!state.online && serverDiscordConfig.commands.list.serverOfflineFormat.isPresent()) { 41 | sb.append(serverDiscordConfig.commands.list.serverOfflineFormat.get()).append('\n'); 42 | } else if (state.players == 0 && serverDiscordConfig.commands.list.noPlayersFormat.isPresent()) { 43 | sb.append(serverDiscordConfig.commands.list.noPlayersFormat.get()).append('\n'); 44 | } else { 45 | for (var player : players) { 46 | var user = new StringTemplate(serverDiscordConfig.commands.list.playerFormat) 47 | .add("username", player.getUsername()) 48 | .toString(); 49 | 50 | sb.append(user).append('\n'); 51 | } 52 | } 53 | 54 | sb.append('\n'); 55 | } 56 | sb.append("```"); 57 | 58 | interaction 59 | .reply(sb.toString()) 60 | .setEphemeral(VelocityDiscord.CONFIG.global.discord.commands.list.ephemeral) 61 | .queue(); 62 | } 63 | 64 | @Override 65 | public String description() { 66 | return "List all servers and their players"; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/discord/message/IQueuedMessage.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.discord.message; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.discord.Discord; 4 | 5 | public interface IQueuedMessage { 6 | void send(Discord discord); 7 | } 8 | 9 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/util/StringTemplate.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.util; 2 | 3 | import javax.annotation.Nonnull; 4 | import java.util.HashMap; 5 | import java.util.Map; 6 | 7 | public class StringTemplate { 8 | private final Map variables = new HashMap<>(); 9 | @Nonnull 10 | private String template; 11 | 12 | public StringTemplate(@Nonnull String template) { 13 | this.template = template; 14 | } 15 | 16 | public StringTemplate add(@Nonnull String key, @Nonnull String value) { 17 | this.variables.put(key, value); 18 | 19 | return this; 20 | } 21 | 22 | public StringTemplate add(@Nonnull String key, int value) { 23 | this.variables.put(key, String.valueOf(value)); 24 | 25 | return this; 26 | } 27 | 28 | public StringTemplate add(@Nonnull String key, boolean value) { 29 | this.variables.put(key, String.valueOf(value)); 30 | 31 | return this; 32 | } 33 | 34 | public StringTemplate add(@Nonnull String key, double value) { 35 | this.variables.put(key, String.valueOf(value)); 36 | 37 | return this; 38 | } 39 | 40 | @Override 41 | @Nonnull 42 | public String toString() { 43 | var result = this.template; 44 | 45 | for (var entry : this.variables.entrySet()) { 46 | result = result.replace("{" + entry.getKey() + "}", entry.getValue()); 47 | } 48 | 49 | return result; 50 | } 51 | 52 | public StringTemplate replace(@Nonnull String target, @Nonnull String replacement) { 53 | this.template = this.template.replace(target, replacement); 54 | 55 | return this; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/ooo/foooooooooooo/velocitydiscord/yep/YepListener.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.yep; 2 | 3 | import cc.unilock.yeplib.api.event.YepAdvancementEvent; 4 | import cc.unilock.yeplib.api.event.YepDeathEvent; 5 | import cc.unilock.yeplib.api.event.YepMessageEvent; 6 | import com.velocitypowered.api.event.Subscribe; 7 | import ooo.foooooooooooo.velocitydiscord.VelocityDiscord; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | 11 | public class YepListener { 12 | private static final Logger logger = LoggerFactory.getLogger(YepListener.class); 13 | 14 | public YepListener() { 15 | VelocityDiscord.LOGGER.info("YepListener created"); 16 | } 17 | 18 | @Subscribe 19 | public void onYepMessage(YepMessageEvent event) { 20 | logger.debug("Received YepMessageEvent: {}", event); 21 | } 22 | 23 | @Subscribe 24 | public void onYepAdvancement(YepAdvancementEvent event) { 25 | if (VelocityDiscord.CONFIG.serverDisabled(event.getSource().getServer().getServerInfo().getName())) return; 26 | 27 | var uuid = event.getPlayer().getUniqueId().toString(); 28 | var server = event.getSource().getServer().getServerInfo().getName(); 29 | 30 | VelocityDiscord 31 | .getDiscord() 32 | .onPlayerAdvancement( 33 | event.getUsername(), 34 | uuid, 35 | server, 36 | event.getDisplayName(), 37 | event.getTitle(), 38 | event.getDescription() 39 | ); 40 | } 41 | 42 | @Subscribe 43 | public void onYepDeath(YepDeathEvent event) { 44 | if (VelocityDiscord.CONFIG.serverDisabled(event.getSource().getServer().getServerInfo().getName())) return; 45 | 46 | var uuid = event.getPlayer().getUniqueId().toString(); 47 | var server = event.getSource().getServer().getServerInfo().getName(); 48 | 49 | VelocityDiscord 50 | .getDiscord() 51 | .onPlayerDeath(event.getUsername(), uuid, server, event.getDisplayName(), event.getMessage()); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/resources/config.toml: -------------------------------------------------------------------------------- 1 | #:schema https://raw.githubusercontent.com/fooooooooooooooo/VelocityDiscord/refs/heads/master/schema.json 2 | 3 | # Don't change this 4 | config_version = "2.0" 5 | 6 | # Comma separated list of server names to exclude from the bridge (defined under [servers] inside your velocity.toml) 7 | # e.g., exclude_servers = ["lobby", "survival"] 8 | exclude_servers = [] 9 | excluded_servers_receive_messages = false 10 | 11 | # How often to ping all servers to check for online status (seconds) 12 | # Excluded servers will not be pinged 13 | # Use a value of 0 to disable 14 | ping_interval = 30 15 | 16 | # Server display names 17 | # If a server is not found in this list, the server name (from velocity.toml) will be used instead 18 | [server_names] 19 | # lobby = "Lobby" 20 | 21 | [discord] 22 | # Bot token from https://discordapp.com/developers/applications/ 23 | # Not server overridable 24 | token = "TOKEN" 25 | # Default channel ID to send Minecraft chat messages to 26 | channel = "000000000000000000" 27 | 28 | # Show messages from bots in Minecraft chat 29 | show_bot_messages = false 30 | # Show clickable links for attachments in Minecraft chat 31 | show_attachments_ingame = true 32 | 33 | # Activity text of the bot to show in Discord 34 | # Placeholders available: {amount} 35 | # Can be disabled with "" or false 36 | # Not server overridable 37 | activity_text = "with {amount} players online" 38 | 39 | # Enable mentioning Discord users from Minecraft chat 40 | enable_mentions = true 41 | # Enable @everyone and @here pings from Minecraft chat 42 | enable_everyone_and_here = false 43 | 44 | # Interval (in minutes) for updating the channel topic 45 | # Use a value of 0 to disable 46 | # Not server overridable 47 | update_channel_topic_interval = 0 48 | 49 | # Channel topic config (if enabled) 50 | [discord.channel_topic] 51 | # Template for the channel topic 52 | # Placeholders available: 53 | # {players} - Total number of players online 54 | # {player_list} - List of players (format is defined below) 55 | # {servers} - Number of servers 56 | # {server_list} - List of server names 57 | # {hostname} - Server hostname 58 | # {port} - Server port 59 | # {motd} - Message of the Day (MOTD) 60 | # {query_port} - Query port 61 | # {max_players} - Maximum number of players 62 | # {plugins} - Number of plugins 63 | # {plugin_list} - List of plugin names 64 | # {version} - Server version 65 | # {software} - Software name 66 | # {average_ping} - Average ping of all players 67 | # {uptime} - Server uptime in hours and minutes 68 | # {server[SERVERNAME]} - Dynamic placeholder for each server's name and status (e.g., {server[MyServer]}, {server[AnotherServer]}, {server[Lobby]}, etc.) 69 | format = """{players}/{max_players} 70 | {player_list} 71 | {hostname}:{port} 72 | Uptime: {uptime}""" 73 | 74 | # Template for server[SERVERNAME] placeholder in the channel topic 75 | # Placeholders available: {name}, {players}, {max_players}, {motd}, {version}, {protocol} 76 | server = "{name}: {players}/{max_players}" 77 | 78 | # Template for server[SERVERNAME] placeholder in the channel topic when the server is offline 79 | # Placeholders available: {name} 80 | server_offline = "{name}: Offline" 81 | 82 | # Can be disabled with "" or false to hide the list completely when no players are online 83 | player_list_no_players_header = "No players online" 84 | 85 | # Can be disabled with "" or false to hide the header and only show the player list 86 | player_list_header = "Players: " 87 | 88 | # Placeholders available: {username}, {ping} 89 | player_list_player = "{username}" 90 | 91 | # Separator between players in the list, \n can be used for new line 92 | player_list_separator = ", " 93 | 94 | # Maximum number of players to show in the topic 95 | # Set to 0 to show all players 96 | player_list_max_count = 10 97 | 98 | [discord.webhook] 99 | # Full webhook URL to send chat messages to 100 | url = "" 101 | # Full URL of an avatar service to get the player's avatar from 102 | # Placeholders available: {uuid}, {username} 103 | avatar_url = "https://visage.surgeplay.com/face/96/{uuid}" 104 | 105 | # The format of the webhook's username 106 | # Placeholders available: {username}, {server} 107 | username = "{username}" 108 | 109 | # Minecraft > Discord message formats 110 | # Uses the same formatting as the Discord client (a subset of markdown) 111 | # 112 | # Messages can be disabled by setting format to empty string ("") or false 113 | # 114 | # type can be one of the following: 115 | # "text" - Normal text only message with the associated x_message format 116 | # "embed" - Discord embed with the associated x_message format as the description field 117 | # Default for all is "text" 118 | # 119 | # embed_color is the color of the embed, in #RRGGBB format 120 | [discord.chat.message] 121 | # Placeholders available: {username}, {prefix}, {server}, {message} 122 | # Can be disabled with "" or false 123 | format = "{username}: {message}" 124 | 125 | # for user messages, the following types can be used 126 | # "text" - Normal text only message with the above 127 | # 128 | # "webhook" - Use a Discord webhook to have the bot use the player's username and avatar when sending messages 129 | # Requires a webhook URL to be set below 130 | # Ignores the above message format, and just sends the message as the content of the webhook 131 | # 132 | # "embed" - Discord embed with the above format as the description field 133 | type = "text" 134 | # Can be disabled with "" or false 135 | embed_color = "" 136 | # Channel override for this message type, set to "" or false or remove to use the default channel 137 | # Can be applied to all message types 138 | # channel = "000000000000000000" 139 | 140 | [discord.chat.join] 141 | # Placeholders available: {username}, {prefix}, {server} 142 | # Can be disabled with "" or false 143 | format = "**{username} joined the game**" 144 | type = "text" 145 | # Can be disabled with "" or false 146 | embed_color = "#40bf4f" 147 | 148 | [discord.chat.leave] 149 | # Placeholders available: {username}, {prefix}, {server} 150 | # Can be disabled with "" or false 151 | format = "**{username} left the game**" 152 | type = "text" 153 | # Can be disabled with "" or false 154 | embed_color = "#bf4040" 155 | 156 | [discord.chat.disconnect] 157 | # Possible different format for timeouts or other terminating connections 158 | # Placeholders available: {username}, {prefix} 159 | # Can be disabled with "" or false 160 | format = "**{username} disconnected**" 161 | type = "text" 162 | # Can be disabled with "" or false 163 | embed_color = "#bf4040" 164 | 165 | [discord.chat.server_switch] 166 | # Placeholders available: {username}, {prefix}, {current}, {previous} 167 | # Can be disabled with "" or false 168 | format = "**{username} moved to {current} from {previous}**" 169 | type = "text" 170 | # Can be disabled with "" or false 171 | embed_color = "#40bf4f" 172 | 173 | [discord.chat.death] 174 | # Placeholders available: {username}, {death_message} 175 | # death_message includes the username just as it is shown ingame 176 | # Can be disabled with "" or false 177 | format = "**{death_message}**" 178 | type = "text" 179 | # Can be disabled with "" or false 180 | embed_color = "#bf4040" 181 | 182 | [discord.chat.advancement] 183 | # Placeholders available: {username}, {advancement_title}, {advancement_description} 184 | # Can be disabled with "" or false 185 | format = "**{username} has made the advancement __{advancement_title}__**\n_{advancement_description}_" 186 | type = "text" 187 | # Can be disabled with "" or false 188 | embed_color = "#40bf4f" 189 | 190 | # Not server overridable 191 | [discord.chat.proxy_start] 192 | # Can be disabled with "" or false 193 | format = "**Proxy started**" 194 | type = "text" 195 | # Can be disabled with "" or false 196 | embed_color = "#40bf4f" 197 | 198 | # Not server overridable 199 | [discord.chat.proxy_stop] 200 | # Can be disabled with "" or false 201 | format = "**Proxy stopped**" 202 | type = "text" 203 | # Can be disabled with "" or false 204 | embed_color = "#bf4040" 205 | 206 | [discord.chat.server_start] 207 | # Placeholders available: {server} 208 | # Can be disabled with "" or false 209 | format = "**{server} has started**" 210 | type = "text" 211 | # Can be disabled with "" or false 212 | embed_color = "#40bf4f" 213 | 214 | [discord.chat.server_stop] 215 | # Placeholders available: {server} 216 | # Can be disabled with "" or false 217 | format = "**{server} has stopped**" 218 | type = "text" 219 | # Can be disabled with "" or false 220 | embed_color = "#bf4040" 221 | 222 | [discord.commands.list] 223 | # Not server overridable 224 | enabled = true 225 | 226 | # Ephemeral messages are only visible to the user who sent the command 227 | # Not server overridable 228 | ephemeral = true 229 | 230 | # Placeholders available: {server_name}, {online_players}, {max_players} 231 | server_format = "[{server_name} {online_players}/{max_players}]" 232 | 233 | # Placeholders available: {username} 234 | player_format = "- {username}" 235 | 236 | # Can be disabled with "" or false 237 | no_players = "No players online" 238 | 239 | # Can be disabled with "" or false 240 | server_offline = "Server offline" 241 | # Not server overridable 242 | codeblock_lang = "asciidoc" 243 | 244 | # Discord > Minecraft message formats 245 | # Uses XML-like formatting with https://docs.advntr.dev/minimessage/format.html 246 | [minecraft] 247 | # Ingame command for plugin 248 | # Not server overridable 249 | # e.g., /discord, /discord reload, /discord topic preview 250 | plugin_command = "discord" 251 | 252 | # Placeholders available: {discord} 253 | discord_chunk = "[<{discord_color}>Discord]" 254 | 255 | # Placeholders available: {role_color}, {display_name}, {username}, {nickname} 256 | # tag allows you to shift right-click the username to insert @{username} in the chat 257 | username_chunk = "<{role_color}>{nickname}" 258 | 259 | # Placeholders available: {discord_chunk}, {username_chunk}, {attachments}, {message} 260 | message = "{discord_chunk} {role_prefix} {username_chunk}: {message} {attachments}" 261 | 262 | # Placeholders available: {url}, {attachment_color} 263 | attachments = "[<{attachment_color}>Attachment]" 264 | 265 | # Placeholders available: {url}, {link_color} 266 | # Can be disabled with "" or false 267 | links = "[<{link_color}>Link]" 268 | 269 | # Colors for the <{discord_color}>, <{attachment_color}> and <{link_color}> tags 270 | discord_color = "#7289da" 271 | attachment_color = "#4abdff" 272 | link_color = "#4abdff" 273 | 274 | # Role prefix configuration 275 | # Format: "role_id" = "prefix format using MiniMessage" 276 | [minecraft.role_prefixes] 277 | # "123456789" = "[OWNER]" 278 | # "987654321" = "[ADMIN]" 279 | # "456789123" = "[MOD]" 280 | # "789123456" = "[HELPER]" 281 | 282 | # Override config for specific servers 283 | # Any config option under [discord] or [minecraft] can be overridden (other than options labelled not server overridable) 284 | # Format: [override.(velocity.toml server name).discord] or [override.(velocity.toml server name).minecraft] 285 | # Example: 286 | # [override.lobby.discord] 287 | # channel = "000000000000000000" 288 | -------------------------------------------------------------------------------- /src/test/java/ooo/foooooooooooo/velocitydiscord/config/PluginConfigTests.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.definitions.UserMessageType; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.io.TempDir; 6 | 7 | import java.awt.*; 8 | import java.nio.file.Path; 9 | import java.util.List; 10 | import java.util.Optional; 11 | 12 | import static org.junit.jupiter.api.Assertions.*; 13 | 14 | public class PluginConfigTests { 15 | @Test 16 | public void allConfigKeysLoadedCorrectly(@TempDir Path tempDir) { 17 | var config = TestUtils.createConfig(TestUtils.getResource("config.toml"), tempDir); 18 | var pluginConfig = new PluginConfig(config); 19 | 20 | // global config 21 | assertEquals(List.of("survival"), pluginConfig.global.excludedServers); 22 | assertTrue(pluginConfig.global.excludedServersReceiveMessages); 23 | assertEquals(123, pluginConfig.global.pingIntervalSeconds); 24 | assertEquals("lobby_test_name", pluginConfig.global.serverDisplayNames.get("lobby")); 25 | 26 | // global discord config 27 | var globalDiscord = pluginConfig.global.discord; 28 | assertEquals("test_token", globalDiscord.token); 29 | assertEquals(Optional.of("activity_text_test"), globalDiscord.activityText); 30 | assertEquals(123, globalDiscord.updateChannelTopicIntervalMinutes); 31 | 32 | // local discord config 33 | var discord = pluginConfig.local.discord; 34 | assertEquals("123456789012345678", discord.mainChannelId); 35 | assertTrue(discord.showBotMessages); 36 | assertFalse(discord.showAttachmentsIngame); 37 | assertFalse(discord.enableMentions); 38 | assertTrue(discord.enableEveryoneAndHere); 39 | 40 | // channel topic config 41 | var topic = discord.channelTopic; 42 | assertEquals(Optional.of("format_test"), topic.format); 43 | assertEquals(Optional.of("server_test"), topic.serverFormat); 44 | assertEquals(Optional.of("server_offline_test"), topic.serverOfflineFormat); 45 | assertEquals(Optional.of("players_no_players_header_test"), topic.playerListNoPlayersHeader); 46 | assertEquals(Optional.of("player_list_header_test"), topic.playerListHeader); 47 | assertEquals("player_list_player_test", topic.playerListPlayerFormat); 48 | assertEquals("player_list_separator_test", topic.playerListSeparator); 49 | assertEquals(123, topic.playerListMaxCount); 50 | 51 | // webhook config 52 | var webhook = discord.webhook; 53 | assertEquals("url_test", webhook.url); 54 | assertEquals("avatar_url_test", webhook.avatarUrl); 55 | assertEquals("username_test", webhook.username); 56 | 57 | // chat message config 58 | var chat = discord.chat; 59 | var messageConfig = chat.message; 60 | assertEquals(Optional.of("format_test"), messageConfig.format); 61 | assertEquals(UserMessageType.EMBED, messageConfig.type); 62 | assertEquals(Optional.of(Color.decode("#ff00ff")), messageConfig.embedColor); 63 | 64 | // join config 65 | var joinConfig = chat.join; 66 | assertEquals(Optional.of("format_test"), joinConfig.format); 67 | assertEquals(UserMessageType.EMBED, joinConfig.type); 68 | assertEquals(Optional.of(Color.decode("#ff00ff")), joinConfig.embedColor); 69 | 70 | // leave config 71 | var leaveConfig = chat.leave; 72 | assertEquals(Optional.of("format_test"), leaveConfig.format); 73 | assertEquals(UserMessageType.EMBED, leaveConfig.type); 74 | assertEquals(Optional.of(Color.decode("#ff00ff")), leaveConfig.embedColor); 75 | 76 | // server switch config 77 | var serverSwitchConfig = chat.serverSwitch; 78 | assertEquals(Optional.of("format_test"), serverSwitchConfig.format); 79 | assertEquals(UserMessageType.EMBED, serverSwitchConfig.type); 80 | assertEquals(Optional.of(Color.decode("#ff00ff")), serverSwitchConfig.embedColor); 81 | 82 | // death config 83 | var deathConfig = chat.death; 84 | assertEquals(Optional.of("format_test"), deathConfig.format); 85 | assertEquals(UserMessageType.EMBED, deathConfig.type); 86 | assertEquals(Optional.of(Color.decode("#ff00ff")), deathConfig.embedColor); 87 | 88 | // minecraft config 89 | var minecraft = pluginConfig.local.minecraft; 90 | assertEquals("discord_chunk_test", minecraft.discordChunkFormat); 91 | assertEquals("username_chunk_test", minecraft.usernameChunkFormat); 92 | assertEquals("message_test", minecraft.messageFormat); 93 | assertEquals("attachments_test", minecraft.attachmentFormat); 94 | assertEquals(Optional.of("links_test"), minecraft.linkFormat); 95 | assertEquals("#ff00ff", minecraft.discordColor); 96 | assertEquals("#ff00ff", minecraft.attachmentColor); 97 | assertEquals("#ff00ff", minecraft.linkColor); 98 | assertEquals("role_prefix_test_1", minecraft.rolePrefixes.get("123456789")); 99 | assertEquals("role_prefix_test_2", minecraft.rolePrefixes.get("987654321")); 100 | 101 | // list command config 102 | var globalListCommand = pluginConfig.global.discord.commands.list; 103 | assertFalse(globalListCommand.enabled); 104 | assertFalse(globalListCommand.ephemeral); 105 | assertEquals("codeblock_lang_test", globalListCommand.codeblockLang); 106 | 107 | var listCommand = discord.commands.list; 108 | assertEquals("server_format_test", listCommand.serverFormat); 109 | assertEquals("player_format_test", listCommand.playerFormat); 110 | assertEquals(Optional.of("no_players_test"), listCommand.noPlayersFormat); 111 | assertEquals(Optional.of("server_offline_test"), listCommand.serverOfflineFormat); 112 | } 113 | 114 | @Test 115 | public void serverDisplayNamesWorks(@TempDir Path tempDir) { 116 | var config = TestUtils.createConfig(TestUtils.getResource("real_test_config.toml"), tempDir); 117 | var pluginConfig = new PluginConfig(config); 118 | 119 | assertEquals("Server A", pluginConfig.global.serverDisplayNames.get("server_a")); 120 | assertEquals("Server B", pluginConfig.global.serverDisplayNames.get("server_b")); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/test/java/ooo/foooooooooooo/velocitydiscord/config/TestUtils.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config; 2 | 3 | import com.electronwill.nightconfig.core.file.FileConfig; 4 | import org.jetbrains.annotations.NotNull; 5 | 6 | import java.io.FileWriter; 7 | import java.io.IOException; 8 | import java.nio.file.Path; 9 | 10 | public class TestUtils { 11 | public static Config createConfig(String s, @NotNull Path tempDir) { 12 | var test = tempDir.resolve("test.toml"); 13 | 14 | try (var w = new FileWriter(test.toFile())) { 15 | w.write(s); 16 | } catch (IOException e) { 17 | throw new RuntimeException(e); 18 | } 19 | 20 | var toml = FileConfig.of(test); 21 | toml.load(); 22 | 23 | return new Config(toml); 24 | } 25 | 26 | public static String getResource(String name) { 27 | var resource = PluginConfigTests.class.getClassLoader().getResourceAsStream(name); 28 | 29 | try (resource) { 30 | if (resource == null) throw new RuntimeException("Resource not found: " + name); 31 | return new String(resource.readAllBytes()); 32 | } catch (IOException e) { 33 | throw new RuntimeException("Failed to read resource: " + name, e); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/test/java/ooo/foooooooooooo/velocitydiscord/config/WebhookConfigTests.java: -------------------------------------------------------------------------------- 1 | package ooo.foooooooooooo.velocitydiscord.config; 2 | 3 | import ooo.foooooooooooo.velocitydiscord.config.definitions.WebhookConfig; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.io.TempDir; 6 | 7 | import java.nio.file.Path; 8 | 9 | import static org.junit.jupiter.api.Assertions.*; 10 | 11 | public class WebhookConfigTests { 12 | @Test 13 | public void webhookIdParsedCorrectly(@TempDir Path tempDir) { 14 | var content = """ 15 | url = "https://discord.com/api/webhooks/1290368230789893527/tokentokentokentokentokentokentokentokentokentokentokentokentoken" 16 | username = "{username}" 17 | """; 18 | 19 | var config = TestUtils.createConfig(content, tempDir); 20 | var webhookConfig = new WebhookConfig(); 21 | webhookConfig.load(config); 22 | 23 | assertNotNull(webhookConfig.id); 24 | assertEquals("1290368230789893527", webhookConfig.id); 25 | assertFalse(webhookConfig.isInvalid()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/test/resources/config.toml: -------------------------------------------------------------------------------- 1 | #:schema https://raw.githubusercontent.com/fooooooooooooooo/VelocityDiscord/refs/heads/master/schema.json 2 | 3 | # Don't change this 4 | config_version = "2.0" 5 | 6 | # Comma separated list of server names to exclude from the bridge (defined under [servers] inside your velocity.toml) 7 | # e.g., exclude_servers = ["lobby", "survival"] 8 | exclude_servers = [ 9 | "survival" 10 | ] 11 | excluded_servers_receive_messages = true 12 | 13 | # How often to ping all servers to check for online status (seconds) 14 | # Excluded servers will not be pinged 15 | # Use a value of 0 to disable 16 | ping_interval = 123 17 | 18 | # Server display names 19 | # If a server is not found in this list, the server name (from velocity.toml) will be used instead 20 | [server_names] 21 | lobby = "lobby_test_name" 22 | 23 | [discord] 24 | # Bot token from https://discordapp.com/developers/applications/ 25 | # Not server overridable 26 | token = "test_token" 27 | # Default channel ID to send Minecraft chat messages to 28 | channel = "123456789012345678" 29 | 30 | # Show messages from bots in Minecraft chat 31 | show_bot_messages = true 32 | # Show clickable links for attachments in Minecraft chat 33 | show_attachments_ingame = false 34 | 35 | # Activity text of the bot to show in Discord 36 | # Placeholders available: {amount} 37 | # Can be disabled with "" or false 38 | # Not server overridable 39 | activity_text = "activity_text_test" 40 | 41 | # Enable mentioning Discord users from Minecraft chat 42 | enable_mentions = false 43 | # Enable @everyone and @here pings from Minecraft chat 44 | enable_everyone_and_here = true 45 | 46 | # Set the interval (in minutes) for updating the channel topic 47 | # Use a value of 0 to disable 48 | # Not server overridable 49 | update_channel_topic_interval = 123 50 | 51 | # Channel topic config (if enabled) 52 | [discord.channel_topic] 53 | # Template for the channel topic 54 | # Placeholders available: 55 | # {players} - Total number of players online 56 | # {player_list} - List of players (format is defined below) 57 | # {servers} - Number of servers 58 | # {server_list} - List of server names 59 | # {hostname} - Server hostname 60 | # {port} - Server port 61 | # {motd} - Message of the Day (MOTD) 62 | # {query_port} - Query port 63 | # {max_players} - Maximum number of players 64 | # {plugins} - Number of plugins 65 | # {plugin_list} - List of plugin names 66 | # {version} - Server version 67 | # {software} - Software name 68 | # {average_ping} - Average ping of all players 69 | # {uptime} - Server uptime in hours and minutes 70 | # {server[SERVERNAME]} - Dynamic placeholder for each server's name and status (e.g., {server[MyServer]}, {server[AnotherServer]}, {server[Lobby]}, etc.) 71 | format = "format_test" 72 | 73 | # Template for server[SERVERNAME] placeholder in the channel topic 74 | # Placeholders available: {name}, {players}, {max_players}, {motd}, {version}, {protocol} 75 | server = "server_test" 76 | 77 | # Template for server[SERVERNAME] placeholder in the channel topic when the server is offline 78 | # Placeholders available: {name} 79 | server_offline = "server_offline_test" 80 | 81 | # Can be disabled with "" or false to hide the list completely when no players are online 82 | player_list_no_players_header = "players_no_players_header_test" 83 | 84 | # Can be disabled with "" or false to hide the header and only show the player list 85 | player_list_header = "player_list_header_test" 86 | 87 | # Placeholders available: {username}, {ping} 88 | player_list_player = "player_list_player_test" 89 | 90 | # Separator between players in the list, \n can be used for new line 91 | player_list_separator = "player_list_separator_test" 92 | 93 | # Maximum number of players to show in the topic 94 | # Set to < 1 to show all players 95 | player_list_max_count = 123 96 | 97 | [discord.webhook] 98 | # Full webhook URL to send more fancy Minecraft chat messages to 99 | url = "url_test" 100 | # Full URL of an avatar service to get the player's avatar from 101 | # Placeholders available: {uuid}, {username} 102 | avatar_url = "avatar_url_test" 103 | # The format of the webhook's username 104 | # Placeholders available: {username}, {server} 105 | username = "username_test" 106 | 107 | # Minecraft > Discord message formats 108 | # Uses the same formatting as the Discord client (a subset of markdown) 109 | # Messages can be disabled with empty string ("") or false 110 | # 111 | # x_message_type can be one of the following: 112 | # "text" - Normal text only message with the associated x_message format 113 | # "embed" - Discord embed with the associated x_message format as the description field 114 | # Default for all is "text" 115 | # 116 | # x_message_embed_color is the color of the embed, in #RRGGBB format 117 | [discord.chat.message] 118 | # Placeholders available: {username}, {prefix}, {server}, {message} 119 | # Can be disabled with "" or false 120 | format = "format_test" 121 | 122 | # for user messages, the following types can be used 123 | # "text" - Normal text only message with the above 124 | # 125 | # "webhook" - Use a Discord webhook to have the bot use the player's username and avatar when sending messages 126 | # Requires a webhook URL to be set below 127 | # Ignores the above message format, and just sends the message as the content of the webhook 128 | # 129 | # "embed" - Discord embed with the above format as the description field 130 | type = "embed" 131 | # Can be disabled with "" or false 132 | embed_color = "#ff00ff" 133 | # Channel override for this message type, set to "" or false or remove to use the default channel 134 | # Can be applied to all message types 135 | # channel = "000000000000000000" 136 | [discord.chat.message.webhook] 137 | url = "message_webhook_url_test" 138 | username = "message_webhook_username_test" 139 | avatar_url = "message_webhook_avatar_url_test" 140 | 141 | [discord.chat.join] 142 | # Placeholders available: {username}, {prefix}, {server} 143 | # Can be disabled with "" or false 144 | format = "format_test" 145 | type = "embed" 146 | # Can be disabled with "" or false 147 | embed_color = "#ff00ff" 148 | [discord.chat.join.webhook] 149 | url = "join_webhook_url_test" 150 | username = "join_webhook_username_test" 151 | avatar_url = "join_webhook_avatar_url_test" 152 | 153 | [discord.chat.leave] 154 | # Placeholders available: {username}, {prefix}, {server} 155 | # Can be disabled with "" or false 156 | format = "format_test" 157 | type = "embed" 158 | # Can be disabled with "" or false 159 | embed_color = "#ff00ff" 160 | [discord.chat.leave.webhook] 161 | url = "leave_webhook_url_test" 162 | username = "leave_webhook_username_test" 163 | avatar_url = "leave_webhook_avatar_url_test" 164 | 165 | [discord.chat.disconnect] 166 | # Possible different format for timeouts or other terminating connections 167 | # Placeholders available: {username}, {prefix} 168 | # Can be disabled with "" or false 169 | format = "format_test" 170 | type = "embed" 171 | # Can be disabled with "" or false 172 | embed_color = "#ff00ff" 173 | [discord.chat.disconnect.webhook] 174 | url = "disconnect_webhook_url_test" 175 | username = "disconnect_webhook_username_test" 176 | avatar_url = "disconnect_webhook_avatar_url_test" 177 | 178 | [discord.chat.server_switch] 179 | # Placeholders available: {username}, {prefix}, {current}, {previous} 180 | # Can be disabled with "" or false 181 | format = "format_test" 182 | type = "embed" 183 | # Can be disabled with "" or false 184 | embed_color = "#ff00ff" 185 | [discord.chat.server_switch.webhook] 186 | url = "server_switch_webhook_url_test" 187 | username = "server_switch_webhook_username_test" 188 | avatar_url = "server_switch_webhook_avatar_url_test" 189 | 190 | [discord.chat.death] 191 | # Placeholders available: {username}, {death_message} 192 | # death_message includes the username just as it is shown ingame 193 | # Can be disabled with "" or false 194 | format = "format_test" 195 | type = "embed" 196 | # Can be disabled with "" or false 197 | embed_color = "#ff00ff" 198 | [discord.chat.death.webhook] 199 | url = "death_webhook_url_test" 200 | username = "death_webhook_username_test" 201 | avatar_url = "death_webhook_avatar_url_test" 202 | 203 | [discord.chat.advancement] 204 | # Placeholders available: {username}, {advancement_title}, {advancement_description} 205 | # Can be disabled with "" or false 206 | format = "format_test" 207 | type = "embed" 208 | # Can be disabled with "" or false 209 | embed_color = "#ff00ff" 210 | [discord.chat.advancement.webhook] 211 | url = "advancement_webhook_url_test" 212 | username = "advancement_webhook_username_test" 213 | avatar_url = "advancement_webhook_avatar_url_test" 214 | 215 | # Not server overridable 216 | [discord.chat.proxy_start] 217 | # Can be disabled with "" or false 218 | format = "format_test" 219 | type = "embed" 220 | # Can be disabled with "" or false 221 | embed_color = "#ff00ff" 222 | 223 | # Not server overridable 224 | [discord.chat.proxy_stop] 225 | # Can be disabled with "" or false 226 | format = "format_test" 227 | type = "embed" 228 | # Can be disabled with "" or false 229 | embed_color = "#ff00ff" 230 | 231 | [discord.chat.server_start] 232 | # Placeholders available: {server} 233 | # Can be disabled with "" or false 234 | format = "format_test" 235 | type = "embed" 236 | # Can be disabled with "" or false 237 | embed_color = "#ff00ff" 238 | 239 | [discord.chat.server_stop] 240 | # Placeholders available: {server} 241 | # Can be disabled with "" or false 242 | format = "format_test" 243 | type = "embed" 244 | # Can be disabled with "" or false 245 | embed_color = "#ff00ff" 246 | 247 | [discord.commands.list] 248 | # Not server overridable 249 | enabled = false 250 | 251 | # Ephemeral messages are only visible to the user who sent the command 252 | # Not server overridable 253 | ephemeral = false 254 | 255 | # Placeholders available: {server_name}, {online_players}, {max_players} 256 | server_format = "server_format_test" 257 | 258 | # Placeholders available: {username} 259 | player_format = "player_format_test" 260 | 261 | # Can be disabled with "" or false 262 | no_players = "no_players_test" 263 | 264 | # Can be disabled with "" or false 265 | server_offline = "server_offline_test" 266 | # Not server overridable 267 | codeblock_lang = "codeblock_lang_test" 268 | 269 | # Discord > Minecraft message formats 270 | # Uses XML-like formatting with https://docs.advntr.dev/minimessage/format.html 271 | [minecraft] 272 | # Ingame command for plugin 273 | # Not server overridable 274 | # e.g., /discord, /discord reload, /discord topic preview 275 | plugin_command = "discord" 276 | 277 | # Placeholders available: {discord} 278 | discord_chunk = "discord_chunk_test" 279 | 280 | # Placeholders available: {role_color}, {display_name}, {username}, {nickname} 281 | # tag allows you to shift right-click the username to insert @{username} in the chat 282 | username_chunk = "username_chunk_test" 283 | 284 | # Placeholders available: {discord_chunk}, {username_chunk}, {attachments}, {message} 285 | message = "message_test" 286 | 287 | # Placeholders available: {url}, {attachment_color} 288 | attachments = "attachments_test" 289 | 290 | # Placeholders available: {url}, {link_color} 291 | # Can be disabled with "" or false 292 | links = "links_test" 293 | 294 | # Colors for the <{discord_color}>, <{attachment_color}> and <{link_color}> tags 295 | discord_color = "#ff00ff" 296 | attachment_color = "#ff00ff" 297 | link_color = "#ff00ff" 298 | 299 | # Role prefix configuration 300 | # Format: "role_id" = "prefix format using MiniMessage" 301 | [minecraft.role_prefixes] 302 | "123456789" = "role_prefix_test_1" 303 | "987654321" = "role_prefix_test_2" 304 | 305 | # Override config for specific servers 306 | # Any config option under [discord] or [minecraft] can be overridden (other than options labelled not server overridable) 307 | # Format: [override.(velocity.toml server name).discord] or [override.(velocity.toml server name).minecraft] 308 | # Example: 309 | # [override.lobby.discord] 310 | # channel = "000000000000000000" 311 | -------------------------------------------------------------------------------- /src/test/resources/real_test_config.toml: -------------------------------------------------------------------------------- 1 | #:schema https://raw.githubusercontent.com/fooooooooooooooo/VelocityDiscord/refs/heads/master/schema.json 2 | 3 | # Don't change this 4 | config_version = "2.0" 5 | 6 | # Comma separated list of server names to exclude from the bridge (defined under [servers] inside your velocity.toml) 7 | # e.g., exclude_servers = ["lobby", "survival"] 8 | exclude_servers = [] 9 | excluded_servers_receive_messages = false 10 | 11 | # How often to ping all servers to check for online status (seconds) 12 | # Set to 0 to disable 13 | # Excluded servers will not be pinged 14 | ping_interval = 30 15 | 16 | # Server display names 17 | # If a server is not found in this list, the server name will be used instead 18 | [server_names] 19 | server_a = "Server A" 20 | server_b = "Server B" 21 | 22 | [discord] 23 | # Bot token from https://discordapp.com/developers/applications/ 24 | token = "test_token" 25 | # Channel ID to send Minecraft chat messages to 26 | channel = "0000000000000000000" 27 | 28 | # Show messages from bots in Minecraft chat 29 | show_bot_messages = false 30 | # Show clickable links for attachments in Minecraft chat 31 | show_attachments_ingame = true 32 | 33 | # Show a text as playing activity of the bot 34 | show_activity = true 35 | # Activity text of the bot to show in Discord 36 | # Placeholders available: {amount} 37 | activity_text = "with {amount} players online" 38 | 39 | # Enable mentioning Discord users from Minecraft chat 40 | enable_mentions = true 41 | # Enable @everyone and @here pings from Minecraft chat 42 | enable_everyone_and_here = false 43 | 44 | # OPTIONAL - Configuration for updating the Discord channel topic 45 | # Set the interval (in minutes) for updating the channel topic. 46 | # Use a value less than 10 to disable this feature. 47 | update_channel_topic_interval = 0 48 | 49 | [discord.channel_topic] 50 | # Template for the channel topic. 51 | # Placeholders available: 52 | # {players} - Total number of players online 53 | # {player_list} - List of players (format is defined below) 54 | # {servers} - Number of servers 55 | # {server_list} - List of server names 56 | # {hostname} - Server hostname 57 | # {port} - Server port 58 | # {motd} - Message of the Day (MOTD) 59 | # {query_port} - Query port 60 | # {max_players} - Maximum number of players 61 | # {plugins} - Number of plugins 62 | # {plugin_list} - List of plugin names 63 | # {version} - Server version 64 | # {software} - Software name 65 | # {average_ping} - Average ping of all players 66 | # {uptime} - Server uptime in hours and minutes 67 | # {server[SERVERNAME]} - Dynamic placeholder for each server's name and status (e.g., {server[MyServer]}, {server[AnotherServer]}, {server[Lobby]}, etc.) 68 | format = """{players}/{max_players} 69 | {player_list} 70 | {hostname}:{port} 71 | Uptime: {uptime}""" 72 | 73 | # Template for server[SERVERNAME] placeholder in the channel topic. 74 | # Placeholders available: {name}, {players}, {max_players}, {motd}, {version}, {protocol} 75 | server = "{name}: {players}/{max_players}" 76 | 77 | # Template for server[SERVERNAME] placeholder in the channel topic when the server is offline. 78 | # Placeholders available: {name} 79 | server_offline = "{name}: Offline" 80 | 81 | # Can be disabled to hide the list completely when no players are online 82 | player_list_no_players_header = "No players online" 83 | 84 | # Can be disabled to hide the header and only show the player list 85 | player_list_header = "Players: " 86 | 87 | # Placeholders available: {username}, {ping} 88 | player_list_player = "{username}" 89 | 90 | # Separator between players in the list, \n can be used for new line 91 | player_list_separator = ", " 92 | 93 | # Maximum number of players to show in the topic 94 | # Set to < 1 to show all players 95 | player_list_max_count = 10 96 | 97 | [discord.webhook] 98 | # Full webhook URL to send more fancy Minecraft chat messages to 99 | url = "https://discord.com/api/webhooks/0000000000000000000/test" 100 | # Full URL of an avatar service to get the player's avatar from 101 | # Placeholders available: {uuid}, {username} 102 | avatar_url = "https://visage.surgeplay.com/face/96/{uuid}" 103 | 104 | # The format of the webhook's username 105 | # Placeholders available: {username}, {server} 106 | username = "{username}" 107 | 108 | # Minecraft > Discord message formats 109 | # Uses the same formatting as the Discord client (a subset of markdown) 110 | # Messages can be disabled with empty string ("") or false 111 | # 112 | # type can be one of the following: 113 | # "text" - Normal text only message with the associated x_message format 114 | # "embed" - Discord embed with the associated x_message format as the description field 115 | # Default for all is "text" 116 | # 117 | # embed_color is the color of the embed, in #RRGGBB format 118 | [discord.chat.message] 119 | # Placeholders available: {username}, {prefix}, {server}, {message} 120 | # Can be disabled 121 | format = "{username}: {message}" 122 | 123 | # for user messages, the following types can be used 124 | # "text" - Normal text only message with the above 125 | # 126 | # "webhook" - Use a Discord webhook to have the bot use the player's username and avatar when sending messages 127 | # Requires a webhook URL to be set below 128 | # Ignores the above message format, and just sends the message as the content of the webhook 129 | # 130 | # "embed" - Discord embed with the above format as the description field 131 | type = "webhook" 132 | # Can be disabled 133 | embed_color = "" 134 | # Channel override for this message type, set to "" or false or remove to use the default channel 135 | # Can be applied to all message types 136 | channel = "0000000000000000000" 137 | 138 | # [discord.chat.message.webhook] 139 | # url = "https://discord.com/api/webhooks/0000000000000000000/test" 140 | # username = "{username}" 141 | # avatar_url = "https://visage.surgeplay.com/face/96/{uuid}" 142 | 143 | [discord.chat.join] 144 | # Placeholders available: {username}, {prefix}, {server} 145 | # Can be disabled 146 | format = "**{username} joined the game**" 147 | type = "embed" 148 | # Can be disabled 149 | embed_color = "#40bf4f" 150 | channel = "0000000000000000000" 151 | 152 | [discord.chat.leave] 153 | # Placeholders available: {username}, {prefix}, {server} 154 | # Can be disabled 155 | format = "**{username} left the game**" 156 | type = "text" 157 | # Can be disabled 158 | embed_color = "#bf4040" 159 | channel = "0000000000000000000" 160 | 161 | [discord.chat.disconnect] 162 | # Possible different format for timeouts or other terminating connections 163 | # Placeholders available: {username}, {prefix} 164 | # Can be disabled 165 | format = "**{username} disconnected**" 166 | type = "webhook" 167 | # Can be disabled 168 | embed_color = "#bf4040" 169 | channel = "0000000000000000000" 170 | 171 | [discord.chat.server_switch] 172 | # Placeholders available: {username}, {prefix}, {current}, {previous} 173 | # Can be disabled 174 | format = "**{username} moved to {current} from {previous}**" 175 | type = "webhook" 176 | # Can be disabled 177 | embed_color = "#40bf4f" 178 | channel = "0000000000000000000" 179 | 180 | [discord.chat.death] 181 | # Placeholders available: {username}, {death_message} 182 | # death_message includes the username just as it is shown ingame 183 | # Can be disabled 184 | format = "**{death_message}**" 185 | type = "webhook" 186 | # Can be disabled 187 | embed_color = "#bf4040" 188 | channel = "0000000000000000000" 189 | 190 | [discord.chat.advancement] 191 | # Placeholders available: {username}, {advancement_title}, {advancement_description} 192 | # Can be disabled 193 | format = "**{username} has made the advancement __{advancement_title}__**\n_{advancement_description}_" 194 | type = "webhook" 195 | # Can be disabled 196 | embed_color = "#40bf4f" 197 | channel = "0000000000000000000" 198 | 199 | [discord.chat.server_start] 200 | # Placeholders available: {server} 201 | # Can be disabled 202 | format = "**{server} has started**" 203 | type = "text" 204 | # Can be disabled 205 | embed_color = "#40bf4f" 206 | channel = "0000000000000000000" 207 | 208 | [discord.chat.server_stop] 209 | # Placeholders available: {server} 210 | # Can be disabled 211 | format = "**{server} has stopped**" 212 | type = "text" 213 | # Can be disabled 214 | embed_color = "#bf4040" 215 | channel = "0000000000000000000" 216 | 217 | [discord.chat.proxy_start] 218 | # Can be disabled 219 | format = "**Proxy started**" 220 | type = "text" 221 | # Can be disabled 222 | embed_color = "#40bf4f" 223 | channel = "0000000000000000000" 224 | 225 | [discord.chat.proxy_stop] 226 | # Can be disabled 227 | format = "**Proxy stopped**" 228 | type = "text" 229 | # Can be disabled 230 | embed_color = "#bf4040" 231 | channel = "0000000000000000000" 232 | 233 | [discord.commands.list] 234 | enabled = true 235 | 236 | # Ephemeral messages are only visible to the user who sent the command 237 | ephemeral = true 238 | 239 | # Placeholders available: {server_name}, {online_players}, {max_players} 240 | server_format = "[{server_name} {online_players}/{max_players}]" 241 | 242 | # Placeholders available: {username} 243 | player_format = "- {username}" 244 | 245 | # Can be disabled 246 | no_players = "No players online" 247 | 248 | # Can be disabled 249 | server_offline = "Server offline" 250 | codeblock_lang = "asciidoc" 251 | 252 | # Discord > Minecraft message formats 253 | # Uses XML-like formatting with https://docs.advntr.dev/minimessage/format.html 254 | [minecraft] 255 | # Ingame command for plugin 256 | # Not server overridable 257 | # e.g., /discord, /discord reload, /discord topic preview 258 | plugin_command = "discord" 259 | 260 | # Placeholders available: {discord} 261 | discord_chunk = "[<{discord_color}>Discord]" 262 | 263 | # Placeholders available: {role_color}, {display_name}, {username}, {nickname} 264 | # tag allows you to shift right-click the username to insert @{username} in the chat 265 | username_chunk = "<{role_color}>{nickname}" 266 | 267 | # Placeholders available: {discord_chunk}, {username_chunk}, {attachments}, {message} 268 | message = "{discord_chunk} {role_prefix} {username_chunk}: {message} {attachments}" 269 | 270 | # Placeholders available: {url}, {attachment_color} 271 | attachments = "[<{attachment_color}>Attachment]" 272 | 273 | # Placeholders available: {url}, {link_color} 274 | # Can be disabled 275 | links = "[<{link_color}>Link]" 276 | 277 | # Colors for the <{discord_color}>, <{attachment_color}> and <{link_color}> tags 278 | discord_color = "#7289da" 279 | attachment_color = "#4abdff" 280 | link_color = "#55FF55" 281 | 282 | # Role prefix configuration 283 | # Format: "role_id" = "prefix format using MiniMessage" 284 | [minecraft.role_prefixes] 285 | # "123456789" = "[OWNER]" 286 | # "987654321" = "[ADMIN]" 287 | # "456789123" = "[MOD]" 288 | # "789123456" = "[HELPER]" 289 | 290 | # Override config for specific servers 291 | # Any config option under [discord] or [minecraft] can be overridden (other than discord.token) 292 | # Format: [override.(velocity.toml server name).discord] or [override.(velocity.toml server name).minecraft] 293 | [override.server_b.discord] 294 | channel = "0000000000000000000" 295 | 296 | [override.server_b.discord.chat.message] 297 | channel = "0000000000000000000" 298 | 299 | [override.server_b.discord.chat.message.webhook] 300 | url = "https://discord.com/api/webhooks/0000000000000000000/test" 301 | username = "{username}" 302 | avatar_url = "https://visage.surgeplay.com/face/96/{uuid}" 303 | 304 | [override.server_b.discord.chat.join] 305 | channel = "0000000000000000000" 306 | 307 | [override.server_b.discord.chat.leave] 308 | channel = "0000000000000000000" 309 | 310 | [override.server_b.discord.chat.disconnect] 311 | channel = "0000000000000000000" 312 | 313 | [override.server_b.discord.chat.server_switch] 314 | channel = "0000000000000000000" 315 | 316 | [override.server_b.discord.chat.death] 317 | # channel = "0000000000000000000" 318 | -------------------------------------------------------------------------------- /test.nu: -------------------------------------------------------------------------------- 1 | open .env | from toml | load-env 2 | 3 | if $env.TEST_SERVER_DIR == '' { 4 | print 'TEST_SERVER_DIR is not set' 5 | exit 1 6 | } 7 | 8 | # ./gradlew.bat build 9 | 10 | let dest_dir = ($env.TEST_SERVER_DIR | path join 'plugins') 11 | 12 | let old_jars_paths = (ls $dest_dir | get name | where $it =~ '(?i)velocitydiscord-.*\.jar') 13 | 14 | $old_jars_paths | each { rm $in } 15 | 16 | let new_jar_path = (ls 'build/libs' | sort-by modified | last | get name) 17 | let new_jar_name = ($new_jar_path | path split | last) 18 | 19 | let dest_jar_path = $'($dest_dir)/($new_jar_name)' 20 | 21 | print $'($new_jar_path) -> ($dest_jar_path)' 22 | 23 | cp $new_jar_path $dest_jar_path 24 | --------------------------------------------------------------------------------