diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7f503c9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,26 @@ +name: Run Tests + +on: + push: + branches: ['main', 'master', 'dev'] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run tests with Gradle + run: ./gradlew test --no-daemon diff --git a/.gitignore b/.gitignore index 21f8da1..74764a6 100644 --- a/.gitignore +++ b/.gitignore @@ -112,4 +112,9 @@ buildNumber.properties # Common working directory run/ -.env \ No newline at end of file +.env + +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar \ No newline at end of file diff --git a/DISCORD_TESTING.md b/DISCORD_TESTING.md new file mode 100644 index 0000000..4020198 --- /dev/null +++ b/DISCORD_TESTING.md @@ -0,0 +1,145 @@ +# Discord Webhook Testing Scripts + +This directory contains scripts to test Discord webhook notifications without running a Minecraft server. + +## Prerequisites + +### Bash Script (`test-discord-webhook.sh`) +- Bash shell +- Java installed (any version with `javac` and `java` commands) + +### Python Script (`test-discord-webhook.py`) +- Python 3.x installed + +## Getting a Discord Webhook URL + +1. Go to your Discord server settings +2. Navigate to **Integrations** → **Webhooks** +3. Click **New Webhook** +4. Configure the webhook: + - Set a name (e.g., "Herald Bot") + - Choose the channel where notifications should be sent + - Copy the webhook URL +5. Keep this URL secure - don't share it publicly! + +## Usage + +### Bash Script + +```bash +# Basic usage (uses default player and server names) +./test-discord-webhook.sh https://discord.com/api/webhooks/123456/abcdef + +# With custom player name +./test-discord-webhook.sh https://discord.com/api/webhooks/123456/abcdef Steve + +# With custom player and server names +./test-discord-webhook.sh https://discord.com/api/webhooks/123456/abcdef Steve "My Awesome Server" +``` + +### Python Script + +```bash +# Basic usage (uses default player and server names) +python3 test-discord-webhook.py https://discord.com/api/webhooks/123456/abcdef + +# With custom player name +python3 test-discord-webhook.py https://discord.com/api/webhooks/123456/abcdef Steve + +# With custom player and server names +python3 test-discord-webhook.py https://discord.com/api/webhooks/123456/abcdef Steve "My Awesome Server" +``` + +## What the Scripts Do + +These scripts simulate the Herald plugin's Discord notification by: + +1. Taking a Discord webhook URL as input +2. Formatting a message in the same way the plugin does: `**PlayerName** joined the **ServerName** server` +3. Sending the message to Discord via HTTP POST request +4. Reporting success or failure + +The message will appear in your Discord channel exactly as it would when a player joins your Minecraft server. + +## Example Output + +### Successful Test +``` +======================================== +Discord Webhook Test Script +======================================== + +Testing Discord webhook... +Webhook URL: https://discord.com/api/webhooks/123456/**** +Player Name: Steve +Server Name: My Awesome Server + +Sending test message... + +Sending payload: {"content": "**Steve** joined the **My Awesome Server** server"} +Response code: 204 +✓ SUCCESS: Message sent to Discord! +Check your Discord channel for the notification. + +======================================== +Test completed successfully! +======================================== +``` + +### Failed Test +``` +======================================== +Discord Webhook Test Script +======================================== + +Testing Discord webhook... +Webhook URL: https://discord.com/api/webhooks/invalid/**** +Player Name: Steve +Server Name: Minecraft + +Sending test message... + +✗ ERROR: Failed to send message to Discord +Error: Discord webhook returned error code: 404 + +======================================== +Test failed! +======================================== +``` + +## Troubleshooting + +### "Command not found" Error +- **Bash script**: Make sure the script is executable: `chmod +x test-discord-webhook.sh` +- **Python script**: Make sure the script is executable: `chmod +x test-discord-webhook.py` +- **Java not found**: Install Java Development Kit (JDK) +- **Python not found**: Install Python 3 + +### "Error code: 404" or "Error code: 401" +- Your webhook URL is invalid or has been deleted +- Create a new webhook and try again + +### "Error code: 429" +- You're being rate-limited by Discord +- Wait a few seconds and try again + +### No Message Appears in Discord +- Check that you're looking at the correct channel +- Verify the webhook is configured for the right channel +- Make sure the webhook hasn't been deleted + +## Security Note + +**Never commit your actual webhook URL to version control!** These scripts mask the webhook token in output for security, but you should still keep your webhook URLs private. + +## Integration with Herald Plugin + +Once you've verified your webhook URL works with these scripts, you can configure it in your Herald plugin's `config.yml`: + +```yaml +discord: + enabled: true + webhook-url: "https://discord.com/api/webhooks/123456/abcdef" +``` + +The plugin will send notifications in exactly the same format as these test scripts. diff --git a/README.md b/README.md index a698e54..afaf1f5 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,19 @@ # Herald -Herald is a Minecraft server plugin that sends email notifications when players join the server. +Herald is a Minecraft server plugin that sends Discord notifications when players join the server. Email notifications are also supported as a secondary option. ![screenshot of emails](./screenshots/mailhog-7-1-2025.PNG) ## Features -- Email notification system for player logins +- **Discord webhook notifications** for player logins (flagship feature) +- Email notification system for player logins (optional, secondary) +- Configurable notification methods (Discord, email, or both) - Easy to configure and use -- Built-in mail server setup with Docker +- Built-in mail server setup with Docker (for email testing) ## Requirements - Minecraft server with version 1.16 or higher - Java runtime environment -- Docker and Docker Compose (for mail server setup) +- Docker and Docker Compose (optional, for email server setup only) ## Installation 1. Download the Herald plugin JAR file @@ -19,8 +21,65 @@ Herald is a Minecraft server plugin that sends email notifications when players 3. Restart your server or use a plugin manager to load the plugin 4. Configure the plugin settings as needed -## Email Server Setup -Herald requires an email server to send notifications. We've provided a Docker Compose setup that makes this easy. +## Configuration +After first run, a configuration file will be created that you can modify to set up your notification preferences. + +### Herald Plugin Configuration +Update your Herald `config.yml`: + +```yaml +# Herald Configuration + +# Discord Configuration (flagship feature) +discord: + enabled: false # Set to true to enable Discord notifications + webhook-url: "" # Your Discord webhook URL + +# Email Configuration (optional) +email-recipients: [] +smtp: + server: "" + port: 587 + username: "" + password: "" + use-tls: true +email: + sender: "" +``` + +### Setting up Discord Notifications +Discord is the recommended way to receive player join notifications. + +1. In your Discord server, go to **Server Settings → Integrations → Webhooks** +2. Click **New Webhook** +3. Configure the webhook: + - Set a name (e.g., "Herald Bot") + - Choose the channel where notifications should be sent + - Copy the webhook URL +4. In your Herald `config.yml`, set: + - `discord.enabled: true` + - `discord.webhook-url: ""` +5. Restart your Minecraft server or reload the plugin + +Players will then see messages like `**Steve** joined the **My Server** server` in your Discord channel. + +You can use Discord notifications alone, email alone, or both together. + +#### Testing Discord Without a Minecraft Server +Use the included test scripts to verify your webhook before deploying: + +```bash +# Bash (requires Java) +./test-discord-webhook.sh https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN + +# Python +python3 test-discord-webhook.py https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN +``` + +See [DISCORD_TESTING.md](./DISCORD_TESTING.md) for full documentation. + +## Email Server Setup (Optional) +Email is a secondary notification method. If you want to use it, Herald requires an SMTP server. A Docker Compose setup is included for easy local testing. ### Quick Start 1. Make sure Docker and Docker Compose are installed on your system @@ -30,7 +89,7 @@ Herald requires an email server to send notifications. We've provided a Docker C docker compose up -d ``` -3. Configure your Herald plugin to use the mail server (see configuration section below) +3. Configure your Herald plugin to use the mail server (see configuration section above) 4. Access the MailHog web interface at http://localhost:8025 to view all sent emails ### Mail Server Architecture @@ -41,35 +100,6 @@ This setup creates two mail-related services: All emails sent to the mail server are relayed to MailHog, where you can view them in a convenient web interface. -## Configuration -After first run, a configuration file will be created that you can modify to set up your email notification preferences. - -### Herald Plugin Configuration -Update your Herald `config.yml` to use the mail server: - -```yaml -# Herald Configuration - -# Email Recipients -# List of email addresses to notify when a player joins -email-recipients: - - "admin@example.com" - - "moderator@example.com" - -# SMTP Configuration -smtp: - server: "mailserver" # Use "localhost" if Herald is on the same machine - port: 25 # Standard SMTP port - username: "" # No authentication needed - password: "" - use-tls: false - -email: - sender: "minecraft@minecraft-mail.local" -``` - -Make sure to replace "mailserver" with your server's IP address if your Minecraft server is not running in the same Docker network. - ## Testing The Docker Compose setup includes a test Minecraft server that can be used to test the Herald plugin: @@ -105,6 +135,7 @@ All emails sent by the Herald plugin will be captured by MailHog. To view them: 3. View and inspect all sent emails in the MailHog interface ## Troubleshooting +- **Discord messages not appearing**: Verify your webhook URL is correct and the channel exists. Run `./test-discord-webhook.sh` to test without a server. - **Emails not showing up in MailHog**: Make sure your Herald plugin is configured with the correct server address and port. - **Connection refused errors**: Verify that the Docker containers are running with `docker ps` and that you're using the correct address. - **Authentication failures**: This setup doesn't require authentication by default; make sure username and password fields are empty in your Herald config. @@ -118,13 +149,26 @@ This setup is primarily intended for development and testing. For production use 4. Consider adding spam protection measures ## Building from Source -The project uses Gradle for building: +The project uses the Gradle wrapper for building. This ensures all contributors use the same Gradle version without needing a system install. - ```shell script - ./gradlew build - ``` +```shell +# Build the plugin JAR +./gradlew build + +# Run unit tests +./gradlew test + +# Build without running tests +./gradlew build -x test +``` + +On Windows, use `gradlew.bat` instead: +```cmd +gradlew.bat build +gradlew.bat test +``` -This will create a fat JAR with all dependencies included. +The built JAR is located at `build/libs/`. The wrapper will automatically download the correct Gradle version on first use. ## Authors - Daniel McCoy Stephenson diff --git a/build.gradle b/build.gradle index 1d96c9f..611c8fd 100644 --- a/build.gradle +++ b/build.gradle @@ -21,12 +21,20 @@ repositories { dependencies { compileOnly("org.spigotmc:spigot-api:1.21.4-R0.1-SNAPSHOT") implementation 'com.sun.mail:jakarta.mail:2.0.1' + + // Test dependencies + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks { runServer { minecraftVersion("1.21") } + + test { + useJUnitPlatform() + } } def targetJavaVersion = 21 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..61285a6 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0d8ab51..a441313 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/java/com/dansplugins/herald/DiscordNotifier.java b/src/main/java/com/dansplugins/herald/DiscordNotifier.java new file mode 100644 index 0000000..0c0ef97 --- /dev/null +++ b/src/main/java/com/dansplugins/herald/DiscordNotifier.java @@ -0,0 +1,117 @@ +package com.dansplugins.herald; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +public class DiscordNotifier implements Notifier { + + private final String webhookUrl; + + public DiscordNotifier(String webhookUrl) { + this.webhookUrl = webhookUrl; + } + + /** + * Send a player-join notification to Discord. + * Formats the message using Discord Markdown bold syntax and sends it via webhook. + * + * @param playerName the name of the player who joined + * @param serverName the name of the server they joined + * @throws IOException if there's an error sending the message + */ + @Override + public void notifyPlayerJoin(String playerName, String serverName) throws IOException { + String content = "**" + playerName + "** joined the **" + serverName + "** server"; + sendMessage(content); + } + + /** + * Send a message to Discord via webhook + * @param content The message content to send + * @throws IOException if there's an error sending the message + */ + public void sendMessage(String content) throws IOException { + if (webhookUrl == null || webhookUrl.isEmpty()) { + throw new IllegalArgumentException("Discord webhook URL is not configured"); + } + + URL url = new URL(webhookUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setConnectTimeout(5000); // 5 seconds connect timeout + connection.setReadTimeout(10000); // 10 seconds read timeout + connection.setDoOutput(true); + + // Create JSON payload with the message content + String jsonPayload = String.format("{\"content\": \"%s\"}", escapeJson(content)); + + try { + try (OutputStream os = connection.getOutputStream()) { + byte[] input = jsonPayload.getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } + + int responseCode = connection.getResponseCode(); + if (responseCode < 200 || responseCode >= 300) { + throw new IOException("Discord webhook returned error code: " + responseCode); + } + } finally { + connection.disconnect(); + } + } + + /** + * Escape special characters in JSON strings + * @param text The text to escape + * @return The escaped text + */ + String escapeJson(String text) { + if (text == null) { + return ""; + } + StringBuilder sb = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + default: + if (c < 0x20) { + String hex = Integer.toHexString(c); + sb.append("\\u00"); + if (hex.length() == 1) { + sb.append('0'); + } + sb.append(hex); + } else { + sb.append(c); + } + break; + } + } + return sb.toString(); + } +} diff --git a/src/main/java/com/dansplugins/herald/EmailNotifier.java b/src/main/java/com/dansplugins/herald/EmailNotifier.java new file mode 100644 index 0000000..2ca6993 --- /dev/null +++ b/src/main/java/com/dansplugins/herald/EmailNotifier.java @@ -0,0 +1,106 @@ +package com.dansplugins.herald; + +import jakarta.mail.*; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; + +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +public class EmailNotifier implements Notifier { + + private final String smtpServer; + private final int smtpPort; + private final String smtpUsername; + private final String smtpPassword; + private final String emailSender; + private final boolean useTLS; + private final List recipients; + + public EmailNotifier(String smtpServer, int smtpPort, String smtpUsername, + String smtpPassword, String emailSender, boolean useTLS, + List recipients) { + this.smtpServer = smtpServer; + this.smtpPort = smtpPort; + this.smtpUsername = smtpUsername; + this.smtpPassword = smtpPassword; + this.emailSender = emailSender; + this.useTLS = useTLS; + this.recipients = recipients != null ? new ArrayList<>(recipients) : new ArrayList<>(); + } + + /** + * Send a player-join notification via email. + * Formats a plain-text subject and body and sends via SMTP. + * + * @param playerName the name of the player who joined + * @param serverName the name of the server they joined + * @throws IllegalStateException if recipients or SMTP server are not configured + * @throws MessagingException if there is an error sending the email + */ + @Override + public void notifyPlayerJoin(String playerName, String serverName) throws MessagingException { + String subject = playerName + " joined " + serverName + " server"; + String body = playerName + " has joined the server at " + new java.util.Date(); + sendNotification(subject, body); + } + + /** + * Send an email notification with the given subject and body. + * + * @param subject The email subject line + * @param body The email body text + * @throws IllegalStateException if recipients or SMTP server are not configured + * @throws MessagingException if there is an error sending the email + */ + public void sendNotification(String subject, String body) throws MessagingException { + if (recipients.isEmpty()) { + throw new IllegalStateException("No email recipients configured"); + } + + if (smtpServer == null || smtpServer.isEmpty()) { + throw new IllegalStateException("SMTP server not configured"); + } + + if (emailSender == null || emailSender.isEmpty()) { + throw new IllegalStateException("Email sender address not configured"); + } + + boolean useAuth = smtpUsername != null && !smtpUsername.isEmpty(); + + Properties props = new Properties(); + props.put("mail.smtp.host", smtpServer); + props.put("mail.smtp.port", String.valueOf(smtpPort)); + props.put("mail.smtp.auth", useAuth ? "true" : "false"); + + if (useTLS) { + props.put("mail.smtp.starttls.enable", "true"); + } + + Session session; + if (useAuth) { + Authenticator authenticator = new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(smtpUsername, smtpPassword); + } + }; + session = Session.getInstance(props, authenticator); + } else { + session = Session.getInstance(props); + } + + Message message = new MimeMessage(session); + message.setFrom(new InternetAddress(emailSender)); + + for (String recipient : recipients) { + message.addRecipient(Message.RecipientType.TO, new InternetAddress(recipient)); + } + + message.setSubject(subject); + message.setText(body); + + Transport.send(message); + } +} diff --git a/src/main/java/com/dansplugins/herald/Herald.java b/src/main/java/com/dansplugins/herald/Herald.java index d6231bb..7633900 100644 --- a/src/main/java/com/dansplugins/herald/Herald.java +++ b/src/main/java/com/dansplugins/herald/Herald.java @@ -5,22 +5,12 @@ import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.plugin.java.JavaPlugin; -import jakarta.mail.*; -import jakarta.mail.internet.InternetAddress; -import jakarta.mail.internet.MimeMessage; import java.util.ArrayList; import java.util.List; -import java.util.Properties; public final class Herald extends JavaPlugin implements Listener { - private List emailRecipients = new ArrayList<>(); - private String smtpServer; - private int smtpPort; - private String smtpUsername; - private String smtpPassword; - private String emailSender; - private boolean useTLS; + private final List notifiers = new ArrayList<>(); @Override public void onEnable() { @@ -42,16 +32,37 @@ public void onDisable() { } private void loadConfiguration() { - // Load email recipients - emailRecipients = getConfig().getStringList("email-recipients"); - - // Load SMTP settings - smtpServer = getConfig().getString("smtp.server"); - smtpPort = getConfig().getInt("smtp.port"); - smtpUsername = getConfig().getString("smtp.username"); - smtpPassword = getConfig().getString("smtp.password"); - emailSender = getConfig().getString("email.sender"); - useTLS = getConfig().getBoolean("smtp.use-tls", true); + notifiers.clear(); + + // Load Discord settings (primary notification method) + boolean discordEnabled = getConfig().getBoolean("discord.enabled", false); + String discordWebhookUrl = getConfig().getString("discord.webhook-url"); + + if (discordEnabled && discordWebhookUrl != null && !discordWebhookUrl.isEmpty()) { + notifiers.add(new DiscordNotifier(discordWebhookUrl)); + getLogger().info("Discord notifications enabled"); + } else if (discordEnabled) { + getLogger().warning("Discord notifications are enabled in config, but 'discord.webhook-url' is missing or empty. Discord notifications will be skipped."); + } + + // Load email settings (secondary notification method) + List emailRecipients = getConfig().getStringList("email-recipients"); + String smtpServer = getConfig().getString("smtp.server"); + int smtpPort = getConfig().getInt("smtp.port"); + String smtpUsername = getConfig().getString("smtp.username"); + String smtpPassword = getConfig().getString("smtp.password"); + String emailSender = getConfig().getString("email.sender"); + boolean useTLS = getConfig().getBoolean("smtp.use-tls", true); + + boolean hasRecipients = !emailRecipients.isEmpty(); + boolean hasSmtp = smtpServer != null && !smtpServer.isEmpty(); + + if (hasRecipients && hasSmtp) { + notifiers.add(new EmailNotifier(smtpServer, smtpPort, smtpUsername, smtpPassword, emailSender, useTLS, emailRecipients)); + getLogger().info("Email notifications enabled"); + } else if (hasRecipients || hasSmtp) { + getLogger().warning("Email configuration is incomplete. Email notifications will be skipped."); + } } @EventHandler @@ -59,67 +70,18 @@ public void onPlayerJoin(PlayerJoinEvent event) { String playerName = event.getPlayer().getName(); String serverName = getServer().getName().isEmpty() ? "Minecraft" : getServer().getName(); - // Send email notification - String subject = playerName + " joined " + serverName + " server"; - String body = playerName + " has joined the server at " + new java.util.Date(); - - // Send email asynchronously to not block the main server thread getServer().getScheduler().runTaskAsynchronously(this, () -> { - try { - sendEmail(subject, body); - getLogger().info("Email notification sent successfully for player: " + playerName); - } catch (Exception e) { - getLogger().severe("Failed to send email notification: " + e.getMessage()); - e.printStackTrace(); + for (Notifier notifier : notifiers) { + try { + notifier.notifyPlayerJoin(playerName, serverName); + getLogger().info("Notification sent successfully for player: " + playerName + + " via " + notifier.getClass().getSimpleName()); + } catch (Exception e) { + getLogger().severe("Failed to send notification via " + + notifier.getClass().getSimpleName() + ": " + e.getMessage()); + e.printStackTrace(); + } } }); } - - private void sendEmail(String subject, String body) throws MessagingException { - if (emailRecipients.isEmpty()) { - getLogger().warning("No email recipients configured. Skipping email notification."); - return; - } - - if (smtpServer == null || smtpServer.isEmpty()) { - getLogger().warning("SMTP server not configured. Skipping email notification."); - return; - } - - // Set up mail server properties - Properties props = new Properties(); - props.put("mail.smtp.host", smtpServer); - props.put("mail.smtp.port", String.valueOf(smtpPort)); - props.put("mail.smtp.auth", "true"); - - if (useTLS) { - props.put("mail.smtp.starttls.enable", "true"); - } - - // Create a mail session with authenticator - Authenticator authenticator = new Authenticator() { - @Override - protected PasswordAuthentication getPasswordAuthentication() { - return new PasswordAuthentication(smtpUsername, smtpPassword); - } - }; - - Session session = Session.getInstance(props, authenticator); - - // Create a message - Message message = new MimeMessage(session); - message.setFrom(new InternetAddress(emailSender)); - - // Add all recipients - for (String recipient : emailRecipients) { - message.addRecipient(Message.RecipientType.TO, new InternetAddress(recipient)); - } - - // Set subject and body - message.setSubject(subject); - message.setText(body); - - // Send the message - Transport.send(message); - } } \ No newline at end of file diff --git a/src/main/java/com/dansplugins/herald/Notifier.java b/src/main/java/com/dansplugins/herald/Notifier.java new file mode 100644 index 0000000..6713b4a --- /dev/null +++ b/src/main/java/com/dansplugins/herald/Notifier.java @@ -0,0 +1,17 @@ +package com.dansplugins.herald; + +/** + * Common interface for all player-join notification channels. + * Implementations handle their own message formatting and transport. + */ +public interface Notifier { + + /** + * Send a notification for a player joining the server. + * + * @param playerName the name of the player who joined + * @param serverName the name of the server they joined + * @throws Exception if the notification could not be delivered + */ + void notifyPlayerJoin(String playerName, String serverName) throws Exception; +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 918a5c1..930f197 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,18 +1,22 @@ # Herald Configuration +# Discord Configuration (flagship feature) +discord: + enabled: false # Set to true to enable Discord notifications + webhook-url: "" # Discord webhook URL for the channel you want to send notifications to + +# Email Configuration (optional) # Email Recipients # List of email addresses to notify when a player joins -email-recipients: - - "admin@example.com" - - "moderator@example.com" +email-recipients: [] # Herald config.yml smtp: - server: "mailserver" # Or your server's IP address if Herald isn't on the same machine - port: 25 # Or 587 if you want to use TLS + server: "" # Your SMTP server address + port: 587 # 587 for TLS, 25 for plain username: "" # Leave empty for no authentication password: "" # Leave empty for no authentication - use-tls: false # Set to true if using port 587 + use-tls: true # Set to true if using port 587 email: - sender: "minecraft@minecraft-mail.local" \ No newline at end of file + sender: "" # The email address to send notifications from \ No newline at end of file diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index d6ebb49..cbe6b04 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,4 +3,4 @@ version: '${version}' main: com.dansplugins.herald.Herald api-version: '1.16' authors: ["Daniel McCoy Stephenson"] -description: Sends email notifications when players join the server \ No newline at end of file +description: Sends Discord and email notifications when players join the server \ No newline at end of file diff --git a/src/test/java/com/dansplugins/herald/DiscordNotifierTest.java b/src/test/java/com/dansplugins/herald/DiscordNotifierTest.java new file mode 100644 index 0000000..7464c11 --- /dev/null +++ b/src/test/java/com/dansplugins/herald/DiscordNotifierTest.java @@ -0,0 +1,747 @@ +package com.dansplugins.herald; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.CsvSource; +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.net.MalformedURLException; + +/** + * Unit tests for DiscordNotifier class + */ +class DiscordNotifierTest { + + @Test + @DisplayName("Constructor should accept valid webhook URL") + void testConstructorWithValidUrl() { + String webhookUrl = "https://discord.com/api/webhooks/123456/abcdef"; + DiscordNotifier notifier = new DiscordNotifier(webhookUrl); + assertNotNull(notifier); + } + + @Test + @DisplayName("Constructor should accept null webhook URL") + void testConstructorWithNullUrl() { + DiscordNotifier notifier = new DiscordNotifier(null); + assertNotNull(notifier); + } + + @Test + @DisplayName("sendMessage should throw IllegalArgumentException for null webhook URL") + void testSendMessageWithNullUrl() { + DiscordNotifier notifier = new DiscordNotifier(null); + assertThrows(IllegalArgumentException.class, () -> { + notifier.sendMessage("test message"); + }); + } + + @Test + @DisplayName("sendMessage should throw IllegalArgumentException for empty webhook URL") + void testSendMessageWithEmptyUrl() { + DiscordNotifier notifier = new DiscordNotifier(""); + assertThrows(IllegalArgumentException.class, () -> { + notifier.sendMessage("test message"); + }); + } + + @Test + @DisplayName("escapeJson should handle null input") + void testEscapeJsonWithNull() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String result = notifier.escapeJson(null); + assertEquals("", result); + } + + @Test + @DisplayName("escapeJson should handle empty string") + void testEscapeJsonWithEmptyString() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String result = notifier.escapeJson(""); + assertEquals("", result); + } + + @Test + @DisplayName("escapeJson should not modify simple text") + void testEscapeJsonWithSimpleText() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String input = "Hello World"; + String result = notifier.escapeJson(input); + assertEquals("Hello World", result); + } + + @Test + @DisplayName("escapeJson should escape double quotes") + void testEscapeJsonWithDoubleQuotes() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String input = "He said \"Hello\""; + String result = notifier.escapeJson(input); + assertEquals("He said \\\"Hello\\\"", result); + } + + @Test + @DisplayName("escapeJson should escape backslashes") + void testEscapeJsonWithBackslashes() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String input = "Path: C:\\Users\\Test"; + String result = notifier.escapeJson(input); + assertEquals("Path: C:\\\\Users\\\\Test", result); + } + + @Test + @DisplayName("escapeJson should escape newlines") + void testEscapeJsonWithNewlines() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String input = "Line1\nLine2"; + String result = notifier.escapeJson(input); + assertEquals("Line1\\nLine2", result); + } + + @Test + @DisplayName("escapeJson should escape carriage returns") + void testEscapeJsonWithCarriageReturns() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String input = "Line1\rLine2"; + String result = notifier.escapeJson(input); + assertEquals("Line1\\rLine2", result); + } + + @Test + @DisplayName("escapeJson should escape tabs") + void testEscapeJsonWithTabs() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String input = "Col1\tCol2"; + String result = notifier.escapeJson(input); + assertEquals("Col1\\tCol2", result); + } + + @Test + @DisplayName("escapeJson should handle multiple special characters") + void testEscapeJsonWithMultipleSpecialChars() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String input = "Text with \"quotes\", \nnewlines, \ttabs, and \\backslashes"; + String result = notifier.escapeJson(input); + assertEquals("Text with \\\"quotes\\\", \\nnewlines, \\ttabs, and \\\\backslashes", result); + } + + @Test + @DisplayName("escapeJson should properly escape Discord message format") + void testEscapeJsonWithDiscordMessageFormat() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String playerName = "Steve"; + String serverName = "My Server"; + String message = "**" + playerName + "** joined the **" + serverName + "** server"; + String result = notifier.escapeJson(message); + assertEquals("**Steve** joined the **My Server** server", result); + } + + @Test + @DisplayName("escapeJson should handle backslash before quote correctly") + void testEscapeJsonWithBackslashBeforeQuote() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String input = "Test\\\"Quote"; + String result = notifier.escapeJson(input); + // Backslash is escaped first, then quote is escaped + assertEquals("Test\\\\\\\"Quote", result); + } + + @Test + @DisplayName("escapeJson should handle empty markdown formatting") + void testEscapeJsonWithMarkdown() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String input = "**bold** *italic* __underline__ ~~strikethrough~~"; + String result = notifier.escapeJson(input); + assertEquals("**bold** *italic* __underline__ ~~strikethrough~~", result); + } + + @Test + @DisplayName("escapeJson should handle Unicode characters") + void testEscapeJsonWithUnicodeCharacters() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String input = "Hello 世界 🌍"; + String result = notifier.escapeJson(input); + assertEquals("Hello 世界 🌍", result); + } + + @Test + @DisplayName("escapeJson should handle special Discord mentions") + void testEscapeJsonWithDiscordMentions() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String input = "<@123456789> <#987654321> @everyone @here"; + String result = notifier.escapeJson(input); + assertEquals("<@123456789> <#987654321> @everyone @here", result); + } + + // Nested test classes for better organization + @Nested + @DisplayName("Constructor Tests") + class ConstructorTests { + + @Test + @DisplayName("Constructor should store webhook URL") + void testConstructorStoresUrl() { + String webhookUrl = "https://discord.com/api/webhooks/123456/abcdef"; + DiscordNotifier notifier = new DiscordNotifier(webhookUrl); + assertNotNull(notifier); + } + + @Test + @DisplayName("Constructor should accept various URL formats") + void testConstructorWithVariousUrlFormats() { + assertDoesNotThrow(() -> new DiscordNotifier("https://discord.com/api/webhooks/123/abc")); + assertDoesNotThrow(() -> new DiscordNotifier("http://localhost:8080/webhook")); + assertDoesNotThrow(() -> new DiscordNotifier("https://example.com")); + } + } + + @Nested + @DisplayName("URL Validation Tests") + class UrlValidationTests { + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("sendMessage should reject null or empty webhook URLs") + void testSendMessageWithInvalidUrls(String invalidUrl) { + DiscordNotifier notifier = new DiscordNotifier(invalidUrl); + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + notifier.sendMessage("test message"); + }); + assertEquals("Discord webhook URL is not configured", exception.getMessage()); + } + + @ParameterizedTest + @ValueSource(strings = { + "not-a-url", + "ftp://invalid.com", + "://malformed", + "htp://typo.com" + }) + @DisplayName("sendMessage should handle malformed URLs") + void testSendMessageWithMalformedUrls(String malformedUrl) { + DiscordNotifier notifier = new DiscordNotifier(malformedUrl); + assertThrows(Exception.class, () -> { + notifier.sendMessage("test message"); + }); + } + } + + @Nested + @DisplayName("JSON Escaping Tests") + class JsonEscapingTests { + + private DiscordNotifier notifier; + + @BeforeEach + void setUp() { + notifier = new DiscordNotifier("https://example.com"); + } + + @ParameterizedTest + @CsvSource({ + "'Hello World', 'Hello World'", + "'', ''", + "'Test123', 'Test123'", + "'Simple-text_123', 'Simple-text_123'" + }) + @DisplayName("escapeJson should preserve text without special characters") + void testEscapeJsonPreservesSimpleText(String input, String expected) { + assertEquals(expected, notifier.escapeJson(input)); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "He said \"Hello\" | He said \\\"Hello\\\"", + "\"quoted\" | \\\"quoted\\\"", + "\" | \\\"", + "\"\"\" | \\\"\\\"\\\"" + }) + @DisplayName("escapeJson should escape quotes correctly") + void testEscapeJsonWithQuotes(String input, String expected) { + assertEquals(expected, notifier.escapeJson(input)); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "C:\\Path | C:\\\\Path", + "\\ | \\\\", + "\\\\ | \\\\\\\\" + }) + @DisplayName("escapeJson should escape backslashes correctly") + void testEscapeJsonWithBackslashes(String input, String expected) { + assertEquals(expected, notifier.escapeJson(input)); + } + + @Test + @DisplayName("escapeJson should escape newline after backslash correctly") + void testEscapeJsonBackslashThenNewline() { + String input = "\\\n"; + String expected = "\\\\\\n"; + assertEquals(expected, notifier.escapeJson(input)); + } + + @Test + @DisplayName("escapeJson should handle all control characters together") + void testEscapeJsonWithAllControlCharacters() { + String input = "Line1\nLine2\rLine3\tCol"; + String expected = "Line1\\nLine2\\rLine3\\tCol"; + assertEquals(expected, notifier.escapeJson(input)); + } + + @Test + @DisplayName("escapeJson should escape backspace and form feed") + void testEscapeJsonWithBackspaceAndFormFeed() { + assertEquals("\\b", notifier.escapeJson("\b")); + assertEquals("\\f", notifier.escapeJson("\f")); + } + + @Test + @DisplayName("escapeJson should escape control characters below 0x20 as unicode") + void testEscapeJsonControlCharactersBelow0x20() { + // ASCII 0x01 (SOH) should become \u0001 + String result = notifier.escapeJson("\u0001"); + assertEquals("\\u0001", result); + // ASCII 0x02 (STX) should become \u0002 + assertEquals("\\u0002", notifier.escapeJson("\u0002")); + // ASCII 0x1F (US) should become \u001f + assertEquals("\\u001f", notifier.escapeJson("\u001f")); + } + + @Test + @DisplayName("escapeJson should handle consecutive special characters") + void testEscapeJsonWithConsecutiveSpecialChars() { + String input = "\"\"\\\\\n\n\t\t"; + String expected = "\\\"\\\"\\\\\\\\\\n\\n\\t\\t"; + assertEquals(expected, notifier.escapeJson(input)); + } + + @Test + @DisplayName("escapeJson should handle very long strings") + void testEscapeJsonWithLongString() { + StringBuilder input = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + input.append("Test "); + } + String result = notifier.escapeJson(input.toString()); + assertTrue(result.length() > 0); + assertFalse(result.contains("\\\\Test")); // Should not have unnecessary escapes + } + } + + @Nested + @DisplayName("Message Content Tests") + class MessageContentTests { + + private DiscordNotifier notifier; + + @BeforeEach + void setUp() { + notifier = new DiscordNotifier("https://example.com"); + } + + @Test + @DisplayName("escapeJson should preserve Discord markdown formatting") + void testDiscordMarkdownPreservation() { + String input = "**bold** *italic* __underline__ ~~strikethrough~~ `code` ```block```"; + String result = notifier.escapeJson(input); + assertEquals(input, result); + } + + @Test + @DisplayName("escapeJson should handle Discord mentions and channels") + void testDiscordSpecialSyntax() { + String input = "<@123> <@!456> <#789> <@&012> <:emoji:345>"; + String result = notifier.escapeJson(input); + assertEquals(input, result); + } + + @Test + @DisplayName("escapeJson should handle player join message format") + void testPlayerJoinMessageFormat() { + String playerName = "Steve"; + String serverName = "My Server"; + String message = "**" + playerName + "** joined the **" + serverName + "** server"; + String result = notifier.escapeJson(message); + assertEquals("**Steve** joined the **My Server** server", result); + } + + @Test + @DisplayName("escapeJson should handle special player names") + void testSpecialPlayerNames() { + String[] playerNames = { + "Player_123", + "Player-Name", + "Player.Name", + "123Player", + "player" + }; + for (String name : playerNames) { + String message = "**" + name + "** joined"; + String result = notifier.escapeJson(message); + assertTrue(result.contains(name)); + } + } + + @Test + @DisplayName("escapeJson should handle Unicode in player names") + void testUnicodePlayerNames() { + String message = "**玩家123** joined the **服务器** server"; + String result = notifier.escapeJson(message); + assertEquals(message, result); + } + + @Test + @DisplayName("escapeJson should handle emoji in messages") + void testEmojiInMessages() { + String message = "🎮 **Player** joined! 🎉"; + String result = notifier.escapeJson(message); + assertEquals(message, result); + } + } + + @Nested + @DisplayName("Edge Case Tests") + class EdgeCaseTests { + + private DiscordNotifier notifier; + + @BeforeEach + void setUp() { + notifier = new DiscordNotifier("https://example.com"); + } + + @Test + @DisplayName("escapeJson should handle strings with only whitespace") + void testWhitespaceOnlyStrings() { + assertEquals(" ", notifier.escapeJson(" ")); + assertEquals("\\t\\t", notifier.escapeJson("\t\t")); + assertEquals("\\n\\n", notifier.escapeJson("\n\n")); + } + + @Test + @DisplayName("escapeJson should handle single character strings") + void testSingleCharacterStrings() { + assertEquals("a", notifier.escapeJson("a")); + assertEquals("\\\"", notifier.escapeJson("\"")); + assertEquals("\\\\", notifier.escapeJson("\\")); + assertEquals("\\n", notifier.escapeJson("\n")); + } + + @Test + @DisplayName("escapeJson should be idempotent for already escaped strings") + void testIdempotency() { + String input = "Test\\\"Quote"; + String firstEscape = notifier.escapeJson(input); + String secondEscape = notifier.escapeJson(firstEscape); + // Should escape again since it's treating the escaped string as new input + assertNotEquals(firstEscape, secondEscape); + } + + @Test + @DisplayName("escapeJson should handle maximum Discord message length") + void testMaxDiscordMessageLength() { + // Discord max message length is 2000 characters + StringBuilder message = new StringBuilder(); + for (int i = 0; i < 2000; i++) { + message.append("a"); + } + String result = notifier.escapeJson(message.toString()); + assertEquals(2000, result.length()); + } + + @Test + @DisplayName("escapeJson should handle strings with mixed line endings") + void testMixedLineEndings() { + String input = "Line1\nLine2\r\nLine3\rLine4"; + String result = notifier.escapeJson(input); + assertEquals("Line1\\nLine2\\r\\nLine3\\rLine4", result); + } + } + + @Nested + @DisplayName("Security Tests") + class SecurityTests { + + private DiscordNotifier notifier; + + @BeforeEach + void setUp() { + notifier = new DiscordNotifier("https://example.com"); + } + + @Test + @DisplayName("escapeJson should prevent JSON injection with quotes") + void testJsonInjectionPrevention() { + String maliciousInput = "\", \"injected\": \"value"; + String escaped = notifier.escapeJson(maliciousInput); + assertFalse(escaped.contains("\", \"injected")); + assertTrue(escaped.contains("\\\"")); + } + + @Test + @DisplayName("escapeJson should handle potential script injection") + void testScriptInjectionPrevention() { + String scriptInput = ""; + String result = notifier.escapeJson(scriptInput); + // Should preserve the content (Discord handles sanitization) + assertEquals(scriptInput, result); + } + + @Test + @DisplayName("escapeJson should handle null bytes") + void testNullByteHandling() { + String input = "Test\0Null"; + String result = notifier.escapeJson(input); + assertNotNull(result); + // Null byte (0x00) should be escaped as \u0000 + assertTrue(result.contains("\\u0000"), "Null byte should be escaped as \\u0000"); + } + + @Test + @DisplayName("escapeJson should handle control characters") + void testControlCharacterHandling() { + String input = "Test\u0001\u0002\u0003"; + String result = notifier.escapeJson(input); + assertNotNull(result); + assertTrue(result.contains("Test")); + // Control chars should be escaped as unicode sequences + assertTrue(result.contains("\\u0001")); + assertTrue(result.contains("\\u0002")); + assertTrue(result.contains("\\u0003")); + } + } + + @Nested + @DisplayName("Integration Tests") + class IntegrationTests { + + @Test + @DisplayName("Constructor and escapeJson should work together") + void testConstructorAndEscapeJsonIntegration() { + String webhookUrl = "https://discord.com/api/webhooks/123/abc"; + DiscordNotifier notifier = new DiscordNotifier(webhookUrl); + + String playerName = "TestPlayer"; + String serverName = "TestServer"; + String message = "**" + playerName + "** joined the **" + serverName + "** server"; + + String escaped = notifier.escapeJson(message); + assertNotNull(escaped); + assertTrue(escaped.contains(playerName)); + assertTrue(escaped.contains(serverName)); + } + + @Test + @DisplayName("Multiple messages should be escaped independently") + void testMultipleMessagesIndependently() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + + String message1 = "Player \"A\" joined"; + String message2 = "Player \"B\" joined"; + + String escaped1 = notifier.escapeJson(message1); + String escaped2 = notifier.escapeJson(message2); + + assertNotEquals(escaped1, escaped2); + assertTrue(escaped1.contains("A")); + assertTrue(escaped2.contains("B")); + } + } + + @Nested + @DisplayName("JSON Payload Tests") + class JsonPayloadTests { + + private DiscordNotifier notifier; + + @BeforeEach + void setUp() { + notifier = new DiscordNotifier("https://example.com"); + } + + @Test + @DisplayName("Escaped content should produce valid JSON when embedded in payload") + void testEscapedContentProducesValidJsonStructure() { + String content = "Steve joined"; + String escaped = notifier.escapeJson(content); + String payload = "{\"content\": \"" + escaped + "\"}"; + // Basic structural check: starts with { and ends with } + assertTrue(payload.startsWith("{")); + assertTrue(payload.endsWith("}")); + assertTrue(payload.contains("\"content\"")); + } + + @Test + @DisplayName("Escaped quotes in content should not break JSON structure") + void testEscapedQuotesSafeInPayload() { + String content = "Player \"Steve\" joined"; + String escaped = notifier.escapeJson(content); + String payload = "{\"content\": \"" + escaped + "\"}"; + // The raw unescaped quote should not appear outside the value + assertFalse(escaped.contains("\"Steve\"")); + assertTrue(escaped.contains("\\\"Steve\\\"")); + assertTrue(payload.contains("\\\"Steve\\\"")); + } + + @Test + @DisplayName("Escaped backslash in content should not break JSON structure") + void testEscapedBackslashSafeInPayload() { + String content = "C:\\Users\\Steve joined"; + String escaped = notifier.escapeJson(content); + // Raw single backslash should not appear (would break JSON) + assertFalse(escaped.contains("C:\\U")); + assertTrue(escaped.contains("C:\\\\U")); + } + + @Test + @DisplayName("Newlines in content should be escaped so payload stays single-line") + void testNewlinesEscapedInPayload() { + String content = "Line1\nLine2"; + String escaped = notifier.escapeJson(content); + assertFalse(escaped.contains("\n"), "Literal newline must not appear in escaped JSON string"); + assertTrue(escaped.contains("\\n")); + } + + @Test + @DisplayName("All named control escapes should not appear as literal characters") + void testAllNamedControlEscapesAreSafe() { + String content = "a\bb\fc\nd\re\tf"; + String escaped = notifier.escapeJson(content); + assertFalse(escaped.contains("\b"), "Literal backspace must not appear"); + assertFalse(escaped.contains("\f"), "Literal form-feed must not appear"); + assertFalse(escaped.contains("\n"), "Literal newline must not appear"); + assertFalse(escaped.contains("\r"), "Literal carriage-return must not appear"); + assertFalse(escaped.contains("\t"), "Literal tab must not appear"); + assertTrue(escaped.contains("\\b")); + assertTrue(escaped.contains("\\f")); + assertTrue(escaped.contains("\\n")); + assertTrue(escaped.contains("\\r")); + assertTrue(escaped.contains("\\t")); + } + + @ParameterizedTest + @ValueSource(chars = {'\u0001', '\u0002', '\u0003', '\u0004', '\u0010', '\u001A', '\u001F'}) + @DisplayName("Control characters below 0x20 should be escaped as \\uXXXX") + void testLowControlCharsEscapedAsUnicode(char controlChar) { + String input = "prefix" + controlChar + "suffix"; + String escaped = notifier.escapeJson(input); + assertFalse(escaped.contains(String.valueOf(controlChar)), + "Control char 0x" + Integer.toHexString(controlChar) + " must not appear literally"); + assertTrue(escaped.contains("\\u00"), + "Control char should be escaped as \\uXXXX"); + } + + @Test + @DisplayName("Normal printable ASCII should pass through unchanged") + void testPrintableAsciiPassesThrough() { + // All printable ASCII 0x20–0x7E except " and \ + String printable = " !#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_`abcdefghijklmnopqrstuvwxyz{|}~"; + String escaped = notifier.escapeJson(printable); + assertEquals(printable, escaped); + } + } + + @Nested + @DisplayName("Notifier Interface Tests") + class NotifyPlayerJoinTests { + + @Test + @DisplayName("DiscordNotifier should implement the Notifier interface") + void testImplementsNotifierInterface() { + DiscordNotifier notifier = new DiscordNotifier("https://discord.com/api/webhooks/123/abc"); + assertInstanceOf(Notifier.class, notifier); + } + + @Test + @DisplayName("notifyPlayerJoin should throw IllegalArgumentException when URL is null") + void testNotifyPlayerJoinWithNullUrlThrows() { + DiscordNotifier notifier = new DiscordNotifier(null); + assertThrows(IllegalArgumentException.class, () -> + notifier.notifyPlayerJoin("Steve", "SurvivalServer")); + } + + @Test + @DisplayName("notifyPlayerJoin should throw IllegalArgumentException when URL is empty") + void testNotifyPlayerJoinWithEmptyUrlThrows() { + DiscordNotifier notifier = new DiscordNotifier(""); + assertThrows(IllegalArgumentException.class, () -> + notifier.notifyPlayerJoin("Steve", "SurvivalServer")); + } + + @Test + @DisplayName("notifyPlayerJoin should format message with Discord bold Markdown") + void testNotifyPlayerJoinFormatsMessageCorrectly() throws Exception { + final String[] capturedMessage = {null}; + DiscordNotifier notifier = new DiscordNotifier("https://example.com") { + @Override + public void sendMessage(String content) { + capturedMessage[0] = content; + } + }; + + notifier.notifyPlayerJoin("Steve", "MySurvivalServer"); + + assertEquals("**Steve** joined the **MySurvivalServer** server", capturedMessage[0]); + } + + @Test + @DisplayName("notifyPlayerJoin should include both player name and server name") + void testNotifyPlayerJoinIncludesBothNames() throws Exception { + final String[] capturedMessage = {null}; + DiscordNotifier notifier = new DiscordNotifier("https://example.com") { + @Override + public void sendMessage(String content) { + capturedMessage[0] = content; + } + }; + + notifier.notifyPlayerJoin("Notch", "ClassicSMP"); + + assertNotNull(capturedMessage[0]); + assertTrue(capturedMessage[0].contains("Notch")); + assertTrue(capturedMessage[0].contains("ClassicSMP")); + } + + @Test + @DisplayName("notifyPlayerJoin should use Discord bold markdown for player and server names") + void testNotifyPlayerJoinUsesBoldMarkdown() throws Exception { + final String[] capturedMessage = {null}; + DiscordNotifier notifier = new DiscordNotifier("https://example.com") { + @Override + public void sendMessage(String content) { + capturedMessage[0] = content; + } + }; + + notifier.notifyPlayerJoin("Alex", "Server"); + + assertNotNull(capturedMessage[0]); + assertTrue(capturedMessage[0].startsWith("**"), "Message should start with bold marker"); + assertTrue(capturedMessage[0].contains("** joined the **"), "Both names should be bolded"); + assertTrue(capturedMessage[0].endsWith("** server"), "Message should end with bolded server name suffix"); + } + + @ParameterizedTest + @CsvSource({"Steve,SurvivalServer", "Alex,CreativeWorld", "Player123,MyCoolSMP"}) + @DisplayName("notifyPlayerJoin should correctly format various player/server name combinations") + void testNotifyPlayerJoinVariousNames(String playerName, String serverName) throws Exception { + final String[] capturedMessage = {null}; + DiscordNotifier notifier = new DiscordNotifier("https://example.com") { + @Override + public void sendMessage(String content) { + capturedMessage[0] = content; + } + }; + + notifier.notifyPlayerJoin(playerName, serverName); + + String expected = "**" + playerName + "** joined the **" + serverName + "** server"; + assertEquals(expected, capturedMessage[0]); + } + } +} diff --git a/src/test/java/com/dansplugins/herald/EmailNotifierTest.java b/src/test/java/com/dansplugins/herald/EmailNotifierTest.java new file mode 100644 index 0000000..93917bc --- /dev/null +++ b/src/test/java/com/dansplugins/herald/EmailNotifierTest.java @@ -0,0 +1,403 @@ +package com.dansplugins.herald; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Unit tests for EmailNotifier class + */ +class EmailNotifierTest { + + @Nested + @DisplayName("Constructor Tests") + class ConstructorTests { + + @Test + @DisplayName("Should create instance with complete valid configuration") + void testValidConfiguration() { + List recipients = Arrays.asList("user@example.com"); + EmailNotifier notifier = new EmailNotifier( + "smtp.example.com", 587, "user", "password", "sender@example.com", true, recipients); + assertNotNull(notifier); + } + + @Test + @DisplayName("Should accept null recipients list without throwing") + void testNullRecipients() { + EmailNotifier notifier = new EmailNotifier( + "smtp.example.com", 587, "user", "password", "sender@example.com", true, null); + assertNotNull(notifier); + } + + @Test + @DisplayName("Should accept empty recipients list without throwing in constructor") + void testEmptyRecipientsInConstructor() { + EmailNotifier notifier = new EmailNotifier( + "smtp.example.com", 587, "user", "password", "sender@example.com", true, + Collections.emptyList()); + assertNotNull(notifier); + } + + @Test + @DisplayName("Should accept null SMTP server without throwing in constructor") + void testNullSmtpServerInConstructor() { + List recipients = Arrays.asList("user@example.com"); + EmailNotifier notifier = new EmailNotifier( + null, 587, "user", "password", "sender@example.com", true, recipients); + assertNotNull(notifier); + } + + @Test + @DisplayName("Should accept empty SMTP server without throwing in constructor") + void testEmptySmtpServerInConstructor() { + List recipients = Arrays.asList("user@example.com"); + EmailNotifier notifier = new EmailNotifier( + "", 587, "user", "password", "sender@example.com", true, recipients); + assertNotNull(notifier); + } + + @Test + @DisplayName("Should accept port 465 (SMTPS)") + void testSmtpsPort() { + List recipients = Arrays.asList("user@example.com"); + EmailNotifier notifier = new EmailNotifier( + "smtp.example.com", 465, "user", "password", "sender@example.com", false, recipients); + assertNotNull(notifier); + } + + @Test + @DisplayName("Should make a defensive copy of the recipients list") + void testRecipientsDefensiveCopy() { + List recipients = new ArrayList<>(Arrays.asList("user@example.com")); + EmailNotifier notifier = new EmailNotifier( + null, 587, "user", "password", "sender@example.com", true, recipients); + assertNotNull(notifier); + + // Mutating the original list after construction should not affect the notifier. + // The notifier was built with 1 recipient. After we add to the original list, + // sendNotification should still throw "SMTP server not configured" (not + // "No email recipients configured"), proving the stored copy still has the original recipient. + recipients.add("extra@example.com"); + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + notifier.sendNotification("Subject", "Body")); + assertEquals("SMTP server not configured", ex.getMessage()); + } + + @Test + @DisplayName("Should support multiple recipients") + void testMultipleRecipients() { + List recipients = Arrays.asList( + "admin@example.com", "owner@example.com", "mod@example.com"); + EmailNotifier notifier = new EmailNotifier( + "smtp.example.com", 587, "user", "password", "sender@example.com", true, recipients); + assertNotNull(notifier); + } + + @Test + @DisplayName("Should work with TLS disabled") + void testTlsDisabled() { + List recipients = Arrays.asList("user@example.com"); + EmailNotifier notifier = new EmailNotifier( + "smtp.example.com", 25, "user", "password", "sender@example.com", false, recipients); + assertNotNull(notifier); + } + } + + @Nested + @DisplayName("Validation Tests") + class ValidationTests { + + @Test + @DisplayName("sendNotification should throw IllegalStateException when recipients list is empty") + void testEmptyRecipientsThrows() { + EmailNotifier notifier = new EmailNotifier( + "smtp.example.com", 587, "user", "password", "sender@example.com", true, + Collections.emptyList()); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + notifier.sendNotification("Subject", "Body")); + assertEquals("No email recipients configured", ex.getMessage()); + } + + @Test + @DisplayName("sendNotification should throw IllegalStateException when recipients list is null") + void testNullRecipientsThrows() { + EmailNotifier notifier = new EmailNotifier( + "smtp.example.com", 587, "user", "password", "sender@example.com", true, null); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + notifier.sendNotification("Subject", "Body")); + assertEquals("No email recipients configured", ex.getMessage()); + } + + @Test + @DisplayName("sendNotification should throw IllegalStateException when SMTP server is null") + void testNullSmtpServerThrows() { + List recipients = Arrays.asList("user@example.com"); + EmailNotifier notifier = new EmailNotifier( + null, 587, "user", "password", "sender@example.com", true, recipients); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + notifier.sendNotification("Subject", "Body")); + assertEquals("SMTP server not configured", ex.getMessage()); + } + + @Test + @DisplayName("sendNotification should throw IllegalStateException when SMTP server is empty") + void testEmptySmtpServerThrows() { + List recipients = Arrays.asList("user@example.com"); + EmailNotifier notifier = new EmailNotifier( + "", 587, "user", "password", "sender@example.com", true, recipients); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + notifier.sendNotification("Subject", "Body")); + assertEquals("SMTP server not configured", ex.getMessage()); + } + + @Test + @DisplayName("sendNotification should check recipients before SMTP server") + void testRecipientsCheckedBeforeSmtpServer() { + // Both missing: recipients error should surface first + EmailNotifier notifier = new EmailNotifier( + null, 587, "user", "password", "sender@example.com", true, null); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + notifier.sendNotification("Subject", "Body")); + assertEquals("No email recipients configured", ex.getMessage()); + } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("sendNotification should throw for null or empty SMTP server") + void testNullOrEmptySmtpServerThrows(String smtpServer) { + List recipients = Arrays.asList("user@example.com"); + EmailNotifier notifier = new EmailNotifier( + smtpServer, 587, "user", "password", "sender@example.com", true, recipients); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + notifier.sendNotification("Subject", "Body")); + assertEquals("SMTP server not configured", ex.getMessage()); + } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("sendNotification should throw for null or empty email sender") + void testNullOrEmptyEmailSenderThrows(String emailSender) { + List recipients = Arrays.asList("user@example.com"); + EmailNotifier notifier = new EmailNotifier( + "smtp.example.com", 587, "user", "password", emailSender, true, recipients); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + notifier.sendNotification("Subject", "Body")); + assertEquals("Email sender address not configured", ex.getMessage()); + } + + @Test + @DisplayName("sendNotification should check SMTP server before email sender") + void testSmtpCheckedBeforeEmailSender() { + // SMTP missing, sender also missing — SMTP error surfaces first + List recipients = Arrays.asList("user@example.com"); + EmailNotifier notifier = new EmailNotifier( + null, 587, "user", "password", null, true, recipients); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + notifier.sendNotification("Subject", "Body")); + assertEquals("SMTP server not configured", ex.getMessage()); + } + + @Test + @DisplayName("notifyPlayerJoin should throw IllegalStateException when email sender is missing") + void testNotifyPlayerJoinWithNoEmailSenderThrows() { + EmailNotifier notifier = new EmailNotifier( + "smtp.example.com", 587, "user", "pass", null, true, + Arrays.asList("recipient@example.com")); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + notifier.notifyPlayerJoin("Steve", "SurvivalServer")); + assertEquals("Email sender address not configured", ex.getMessage()); + } + } + + @Nested + @DisplayName("Email Format Tests") + class EmailFormatTests { + + @Test + @DisplayName("Email subject format should be plain text without Discord markdown") + void testEmailSubjectIsPlainText() { + String playerName = "Steve"; + String serverName = "SurvivalServer"; + String subject = playerName + " joined " + serverName + " server"; + + assertEquals("Steve joined SurvivalServer server", subject); + assertFalse(subject.contains("**"), "Email subject must not contain Discord bold markdown"); + assertFalse(subject.contains("*"), "Email subject must not contain any markdown"); + } + + @Test + @DisplayName("Email subject should differ from Discord message format") + void testEmailVsDiscordFormat() { + String playerName = "Player"; + String serverName = "Server"; + + String discordMsg = "**" + playerName + "** joined the **" + serverName + "** server"; + String emailSubject = playerName + " joined " + serverName + " server"; + + assertNotEquals(discordMsg, emailSubject); + assertTrue(discordMsg.contains("**")); + assertFalse(emailSubject.contains("**")); + } + + @Test + @DisplayName("Email body should include player name and indicate join event") + void testEmailBodyContainsPlayerName() { + String playerName = "Steve"; + String body = playerName + " has joined the server at " + new java.util.Date(); + + assertTrue(body.startsWith(playerName), "Body should start with the player name"); + assertTrue(body.contains("has joined the server at"), "Body should describe the join event"); + } + + @ParameterizedTest + @ValueSource(strings = {"Steve", "Alex", "Player123", "Player_X", "Cool-Player"}) + @DisplayName("Email subject should handle various standard player names") + void testEmailSubjectWithVariousPlayerNames(String playerName) { + String subject = playerName + " joined Server server"; + assertTrue(subject.startsWith(playerName)); + assertFalse(subject.contains("**"), "Email subject must not contain markdown"); + } + + @Test + @DisplayName("Email subject should include server name") + void testEmailSubjectIncludesServerName() { + String playerName = "Alice"; + String serverName = "MyCoolSMP"; + String subject = playerName + " joined " + serverName + " server"; + + assertTrue(subject.contains(serverName)); + assertTrue(subject.contains(playerName)); + assertTrue(subject.endsWith("server")); + } + + @Test + @DisplayName("Email subject with Unicode player name should be well-formed") + void testEmailSubjectWithUnicodePlayerName() { + String playerName = "玩家123"; + String subject = playerName + " joined MyServer server"; + + assertTrue(subject.contains(playerName)); + assertFalse(subject.contains("**")); + } + } + + @Nested + @DisplayName("Notifier Interface Tests") + class NotifyPlayerJoinTests { + + @Test + @DisplayName("EmailNotifier should implement the Notifier interface") + void testImplementsNotifierInterface() { + EmailNotifier notifier = new EmailNotifier( + "smtp.example.com", 587, "user", "pass", "sender@example.com", true, + Arrays.asList("recipient@example.com")); + assertInstanceOf(Notifier.class, notifier); + } + + @Test + @DisplayName("notifyPlayerJoin should throw IllegalStateException when no recipients are configured") + void testNotifyPlayerJoinWithNoRecipientsThrows() { + EmailNotifier notifier = new EmailNotifier( + "smtp.example.com", 587, "user", "pass", "sender@example.com", true, + Collections.emptyList()); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + notifier.notifyPlayerJoin("Steve", "SurvivalServer")); + assertEquals("No email recipients configured", ex.getMessage()); + } + + @Test + @DisplayName("notifyPlayerJoin should throw IllegalStateException when SMTP server is missing") + void testNotifyPlayerJoinWithNoSmtpThrows() { + EmailNotifier notifier = new EmailNotifier( + null, 587, "user", "pass", "sender@example.com", true, + Arrays.asList("recipient@example.com")); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + notifier.notifyPlayerJoin("Steve", "SurvivalServer")); + assertEquals("SMTP server not configured", ex.getMessage()); + } + + @Test + @DisplayName("notifyPlayerJoin should format a plain-text subject without Markdown") + void testNotifyPlayerJoinFormatsSubjectCorrectly() throws Exception { + final String[] capturedSubject = {null}; + final String[] capturedBody = {null}; + EmailNotifier notifier = new EmailNotifier( + "smtp.example.com", 587, "user", "pass", "sender@example.com", true, + Arrays.asList("recipient@example.com")) { + @Override + public void sendNotification(String subject, String body) { + capturedSubject[0] = subject; + capturedBody[0] = body; + } + }; + + notifier.notifyPlayerJoin("Steve", "MySurvivalServer"); + + assertEquals("Steve joined MySurvivalServer server", capturedSubject[0]); + assertFalse(capturedSubject[0].contains("**"), "Email subject must not contain Discord markdown"); + } + + @Test + @DisplayName("notifyPlayerJoin should include player name in email body") + void testNotifyPlayerJoinBodyContainsPlayerName() throws Exception { + final String[] capturedBody = {null}; + EmailNotifier notifier = new EmailNotifier( + "smtp.example.com", 587, "user", "pass", "sender@example.com", true, + Arrays.asList("recipient@example.com")) { + @Override + public void sendNotification(String subject, String body) { + capturedBody[0] = body; + } + }; + + notifier.notifyPlayerJoin("Alex", "CreativeWorld"); + + assertNotNull(capturedBody[0]); + assertTrue(capturedBody[0].startsWith("Alex"), "Body should start with player name"); + assertTrue(capturedBody[0].contains("has joined the server at"), + "Body should describe the join event"); + } + + @Test + @DisplayName("notifyPlayerJoin email subject should differ from Discord message format for same names") + void testNotifyPlayerJoinSubjectDiffersFromDiscordFormat() throws Exception { + final String[] capturedSubject = {null}; + EmailNotifier emailNotifier = new EmailNotifier( + "smtp.example.com", 587, "user", "pass", "sender@example.com", true, + Arrays.asList("recipient@example.com")) { + @Override + public void sendNotification(String subject, String body) { + capturedSubject[0] = subject; + } + }; + + emailNotifier.notifyPlayerJoin("Player", "Server"); + + String discordFormat = "**Player** joined the **Server** server"; + assertNotEquals(discordFormat, capturedSubject[0]); + assertTrue(capturedSubject[0].contains("Player")); + assertFalse(capturedSubject[0].contains("**")); + } + } +} diff --git a/src/test/java/com/dansplugins/herald/HeraldIntegrationTest.java b/src/test/java/com/dansplugins/herald/HeraldIntegrationTest.java new file mode 100644 index 0000000..bc22a6f --- /dev/null +++ b/src/test/java/com/dansplugins/herald/HeraldIntegrationTest.java @@ -0,0 +1,355 @@ +package com.dansplugins.herald; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for Herald plugin configuration and message formatting + */ +class HeraldIntegrationTest { + + @Nested + @DisplayName("Message Format Tests") + class MessageFormatTests { + + @Test + @DisplayName("Discord message format should match expected pattern") + void testDiscordMessageFormat() { + String playerName = "TestPlayer"; + String serverName = "TestServer"; + String expectedFormat = "**" + playerName + "** joined the **" + serverName + "** server"; + + assertTrue(expectedFormat.startsWith("**")); + assertTrue(expectedFormat.contains("** joined the **")); + assertTrue(expectedFormat.endsWith("** server")); + } + + @Test + @DisplayName("Discord message should be properly escaped") + void testDiscordMessageEscaping() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + + String playerName = "Player\"Test"; + String serverName = "Server\\Name"; + String message = "**" + playerName + "** joined the **" + serverName + "** server"; + + String escaped = notifier.escapeJson(message); + assertTrue(escaped.contains("\\\"")); + assertTrue(escaped.contains("\\\\")); + } + + @Test + @DisplayName("Message format should handle empty server name gracefully") + void testEmptyServerName() { + String playerName = "TestPlayer"; + String serverName = ""; + String message = "**" + playerName + "** joined the **" + (serverName.isEmpty() ? "Minecraft" : serverName) + "** server"; + + assertTrue(message.contains("Minecraft")); + } + } + + @Nested + @DisplayName("Configuration Tests") + class ConfigurationTests { + + @Test + @DisplayName("Discord notifier should be created with valid URL") + void testDiscordNotifierCreation() { + String webhookUrl = "https://discord.com/api/webhooks/123456/abcdef"; + DiscordNotifier notifier = new DiscordNotifier(webhookUrl); + assertNotNull(notifier); + } + + @Test + @DisplayName("Discord notifier should handle null URL in constructor") + void testDiscordNotifierWithNullUrl() { + DiscordNotifier notifier = new DiscordNotifier(null); + assertNotNull(notifier); + + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + notifier.sendMessage("test"); + }); + assertEquals("Discord webhook URL is not configured", exception.getMessage()); + } + + @Test + @DisplayName("Discord notifier should handle empty URL in constructor") + void testDiscordNotifierWithEmptyUrl() { + DiscordNotifier notifier = new DiscordNotifier(""); + assertNotNull(notifier); + + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + notifier.sendMessage("test"); + }); + assertEquals("Discord webhook URL is not configured", exception.getMessage()); + } + } + + @Nested + @DisplayName("Player Name Tests") + class PlayerNameTests { + + @Test + @DisplayName("Message should handle standard player names") + void testStandardPlayerNames() { + String[] standardNames = {"Steve", "Alex", "Player123", "Cool_Player", "Player-Name"}; + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + + for (String name : standardNames) { + String message = "**" + name + "** joined the **TestServer** server"; + String escaped = notifier.escapeJson(message); + assertTrue(escaped.contains(name)); + } + } + + @Test + @DisplayName("Message should handle player names with special characters") + void testPlayerNamesWithSpecialChars() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + + String[] specialNames = {"Player\"Quote", "Player\\Slash", "Player\nNewline"}; + for (String name : specialNames) { + String message = "**" + name + "** joined"; + String escaped = notifier.escapeJson(message); + assertNotNull(escaped); + // Should be properly escaped + assertFalse(escaped.contains("\"") && !escaped.contains("\\\"")); + } + } + + @Test + @DisplayName("Message should handle Unicode player names") + void testUnicodePlayerNames() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + + String[] unicodeNames = {"玩家", "プレイヤー", "игрок", "لاعب"}; + for (String name : unicodeNames) { + String message = "**" + name + "** joined the **Server** server"; + String escaped = notifier.escapeJson(message); + assertTrue(escaped.contains(name)); + } + } + } + + @Nested + @DisplayName("Server Name Tests") + class ServerNameTests { + + @Test + @DisplayName("Message should handle standard server names") + void testStandardServerNames() { + String[] serverNames = {"Minecraft", "My Server", "Server123", "Cool-Server"}; + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + + for (String serverName : serverNames) { + String message = "**Player** joined the **" + serverName + "** server"; + String escaped = notifier.escapeJson(message); + assertTrue(escaped.contains(serverName)); + } + } + + @Test + @DisplayName("Message should use 'Minecraft' as default server name") + void testDefaultServerName() { + String serverName = ""; + String actualServerName = serverName.isEmpty() ? "Minecraft" : serverName; + + String message = "**Player** joined the **" + actualServerName + "** server"; + assertTrue(message.contains("Minecraft")); + } + + @Test + @DisplayName("Message should handle server names with special characters") + void testServerNamesWithSpecialChars() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + + String serverName = "Server \"Best\" \\Cool\\"; + String message = "**Player** joined the **" + serverName + "** server"; + String escaped = notifier.escapeJson(message); + assertTrue(escaped.contains("\\\"")); + assertTrue(escaped.contains("\\\\")); + } + } + + @Nested + @DisplayName("Concurrent Message Tests") + class ConcurrentTests { + + @Test + @DisplayName("Multiple messages should not interfere with each other") + void testMultipleSimultaneousMessages() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + + String message1 = "**Player1** joined the **Server** server"; + String message2 = "**Player2** joined the **Server** server"; + String message3 = "**Player3** joined the **Server** server"; + + String escaped1 = notifier.escapeJson(message1); + String escaped2 = notifier.escapeJson(message2); + String escaped3 = notifier.escapeJson(message3); + + assertTrue(escaped1.contains("Player1")); + assertTrue(escaped2.contains("Player2")); + assertTrue(escaped3.contains("Player3")); + + // Ensure they're independent + assertFalse(escaped1.contains("Player2")); + assertFalse(escaped2.contains("Player3")); + assertFalse(escaped3.contains("Player1")); + } + + @Test + @DisplayName("Same player joining multiple times should create distinct messages") + void testRepeatedPlayerJoins() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String playerName = "TestPlayer"; + + String message1 = "**" + playerName + "** joined the **Server1** server"; + String message2 = "**" + playerName + "** joined the **Server2** server"; + + String escaped1 = notifier.escapeJson(message1); + String escaped2 = notifier.escapeJson(message2); + + assertTrue(escaped1.contains("Server1")); + assertTrue(escaped2.contains("Server2")); + assertNotEquals(escaped1, escaped2); + } + } + + @Nested + @DisplayName("Performance Tests") + class PerformanceTests { + + @Test + @DisplayName("escapeJson should handle rapid successive calls") + void testRapidEscapeJsonCalls() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + + for (int i = 0; i < 10; i++) { + String playerName = "Player" + i; + String message = "**" + playerName + "** joined the **Server** server"; + String result = notifier.escapeJson(message); + assertNotNull(result); + assertTrue(result.contains(playerName)); + } + } + + @Test + @DisplayName("escapeJson should handle large messages efficiently") + void testLargeMessageEscaping() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + + // Create a large message (near Discord's 2000 char limit) + StringBuilder largeMessage = new StringBuilder("**Player** joined the **"); + for (int i = 0; i < 180; i++) { + largeMessage.append("VeryLong"); + } + largeMessage.append("** server"); + + String escaped = notifier.escapeJson(largeMessage.toString()); + assertNotNull(escaped); + assertTrue(escaped.length() > 0); + } + } + + @Nested + @DisplayName("Discord Message Format Tests") + class DiscordMessageFormatTests { + + @Test + @DisplayName("Standard player join message should match exact Discord format") + void testExactDiscordMessageFormat() { + String playerName = "Steve"; + String serverName = "MySurvivalServer"; + String message = "**" + playerName + "** joined the **" + serverName + "** server"; + assertEquals("**Steve** joined the **MySurvivalServer** server", message); + } + + @Test + @DisplayName("Message format should be preserved after escaping safe content") + void testMessageFormatPreservedForSafeContent() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String[] safePlayers = {"Alice", "Bob123", "Player_X", "User-1"}; + for (String player : safePlayers) { + String msg = "**" + player + "** joined the **Server** server"; + assertEquals(msg, notifier.escapeJson(msg), + "Message for " + player + " should be unchanged after escaping"); + } + } + + @Test + @DisplayName("Message escaping should sanitize injected quotes in player name") + void testPlayerNameWithQuoteInjection() { + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String playerName = "Steve\", \"troll\": \"true"; + String message = "**" + playerName + "** joined the **Server** server"; + String escaped = notifier.escapeJson(message); + // After escaping the full message string, there should be no unescaped quotes + // that could break the outer JSON structure + assertFalse(escaped.contains("\", \"troll"), "JSON injection attempt must be neutralised"); + } + + @Test + @DisplayName("Empty server name should fall back to 'Minecraft' in the message") + void testDefaultServerNameFallback() { + // When serverName is empty, Herald.java uses "Minecraft" as the fallback. + // Verify the message produced with that fallback contains "Minecraft". + String serverName = "Minecraft"; // this is the fallback value Herald uses + String msg = "**Player** joined the **" + serverName + "** server"; + + DiscordNotifier notifier = new DiscordNotifier("https://example.com"); + String escaped = notifier.escapeJson(msg); + assertTrue(escaped.contains("Minecraft")); + assertEquals(msg, escaped); // "Minecraft" has no special chars, should be unchanged + } + + @Test + @DisplayName("Message should contain both player and server names") + void testMessageContainsBothNames() { + String playerName = "Notch"; + String serverName = "ClassicSMP"; + String message = "**" + playerName + "** joined the **" + serverName + "** server"; + + assertTrue(message.contains(playerName)); + assertTrue(message.contains(serverName)); + assertTrue(message.contains("joined the")); + assertTrue(message.endsWith("server")); + } + } + + @Nested + @DisplayName("Notification Ordering Tests") + class NotificationOrderingTests { + + @Test + @DisplayName("Discord message should be independently formattable from email subject") + void testDiscordAndEmailMessagesAreDifferent() { + String playerName = "TestPlayer"; + String serverName = "TestServer"; + + String discordMessage = "**" + playerName + "** joined the **" + serverName + "** server"; + String emailSubject = playerName + " joined " + serverName + " server"; + + // Discord uses bold markdown; email subject is plain text + assertNotEquals(discordMessage, emailSubject); + assertTrue(discordMessage.contains("**")); + assertFalse(emailSubject.contains("**")); + } + + @Test + @DisplayName("Discord message format should use bold markdown") + void testDiscordMessageUsesBoldMarkdown() { + String playerName = "Player"; + String serverName = "Server"; + String discordMessage = "**" + playerName + "** joined the **" + serverName + "** server"; + + assertTrue(discordMessage.startsWith("**"), + "Discord message should start with bold markdown"); + assertTrue(discordMessage.contains("** joined the **"), + "Discord message should bold both player and server names"); + } + } +} diff --git a/test-discord-webhook.py b/test-discord-webhook.py new file mode 100755 index 0000000..42fa896 --- /dev/null +++ b/test-discord-webhook.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 + +""" +Discord Webhook Test Script +This script allows you to test Discord webhook notifications without running a Minecraft server +""" + +import sys +import json +import urllib.request +import urllib.error + +def send_discord_message(webhook_url, content): + """Send a message to Discord via webhook""" + if not webhook_url: + raise ValueError("Discord webhook URL is not configured") + + # Create JSON payload + payload = { + "content": content + } + + data = json.dumps(payload).encode('utf-8') + + print(f"Sending payload: {json.dumps(payload)}") + + # Create request + req = urllib.request.Request( + webhook_url, + data=data, + headers={'Content-Type': 'application/json'} + ) + + # Send request + try: + with urllib.request.urlopen(req, timeout=10) as response: + status_code = response.getcode() + print(f"Response code: {status_code}") + + if status_code < 200 or status_code >= 300: + raise Exception(f"Discord webhook returned error code: {status_code}") + + return True + except urllib.error.HTTPError as e: + print(f"Response code: {e.code}") + raise Exception(f"Discord webhook returned error code: {e.code}") + +def mask_webhook_url(url): + """Mask the webhook token for security""" + if "/webhooks/" in url: + parts = url.split("/webhooks/") + if len(parts) > 1: + token_parts = parts[1].split("/") + if len(token_parts) > 1: + return f"{parts[0]}/webhooks/{token_parts[0]}/****" + return url + +def main(): + # Colors for terminal output + RED = '\033[0;31m' + GREEN = '\033[0;32m' + YELLOW = '\033[1;33m' + BLUE = '\033[0;34m' + NC = '\033[0m' # No Color + + print(f"{BLUE}========================================{NC}") + print(f"{BLUE}Discord Webhook Test Script (Python){NC}") + print(f"{BLUE}========================================{NC}") + print() + + # Check arguments + if len(sys.argv) < 2: + print(f"{RED}Error: Discord webhook URL is required{NC}") + print() + print("Usage: python3 test-discord-webhook.py [player-name] [server-name]") + print() + print("Example:") + print(" python3 test-discord-webhook.py https://discord.com/api/webhooks/123456/abcdef") + print(" python3 test-discord-webhook.py https://discord.com/api/webhooks/123456/abcdef Steve") + print(' python3 test-discord-webhook.py https://discord.com/api/webhooks/123456/abcdef Steve "My Awesome Server"') + print() + print(f"{YELLOW}To get a Discord webhook URL:{NC}") + print(" 1. Go to your Discord server settings") + print(" 2. Navigate to Integrations → Webhooks") + print(" 3. Click 'New Webhook'") + print(" 4. Configure the webhook and copy the URL") + print() + sys.exit(1) + + webhook_url = sys.argv[1] + player_name = sys.argv[2] if len(sys.argv) > 2 else "TestPlayer" + server_name = sys.argv[3] if len(sys.argv) > 3 else "Minecraft" + + print("Testing Discord webhook...") + print(f"Webhook URL: {mask_webhook_url(webhook_url)}") + print(f"Player Name: {player_name}") + print(f"Server Name: {server_name}") + print() + + # Format message like the plugin does + message = f"**{player_name}** joined the **{server_name}** server" + + print(f"{YELLOW}Sending test message...{NC}") + print() + + try: + send_discord_message(webhook_url, message) + print() + print(f"{GREEN}✓ SUCCESS: Message sent to Discord!{NC}") + print("Check your Discord channel for the notification.") + print() + print(f"{GREEN}========================================{NC}") + print(f"{GREEN}Test completed successfully!{NC}") + print(f"{GREEN}========================================{NC}") + sys.exit(0) + except Exception as e: + print() + print(f"{RED}✗ ERROR: Failed to send message to Discord{NC}") + print(f"Error: {str(e)}") + print() + print(f"{RED}========================================{NC}") + print(f"{RED}Test failed!{NC}") + print(f"{RED}========================================{NC}") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/test-discord-webhook.sh b/test-discord-webhook.sh new file mode 100755 index 0000000..5e6333e --- /dev/null +++ b/test-discord-webhook.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash + +# Discord Webhook Test Script +# This script allows you to test Discord webhook notifications without running a Minecraft server + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}========================================${NC}" +echo -e "${BLUE}Discord Webhook Test Script${NC}" +echo -e "${BLUE}========================================${NC}" +echo "" + +# Check if webhook URL is provided +if [ -z "$1" ]; then + echo -e "${RED}Error: Discord webhook URL is required${NC}" + echo "" + echo "Usage: $0 [player-name] [server-name]" + echo "" + echo "Example:" + echo " $0 https://discord.com/api/webhooks/123456/abcdef" + echo " $0 https://discord.com/api/webhooks/123456/abcdef Steve" + echo " $0 https://discord.com/api/webhooks/123456/abcdef Steve \"My Awesome Server\"" + echo "" + echo -e "${YELLOW}To get a Discord webhook URL:${NC}" + echo " 1. Go to your Discord server settings" + echo " 2. Navigate to Integrations → Webhooks" + echo " 3. Click 'New Webhook'" + echo " 4. Configure the webhook and copy the URL" + echo "" + exit 1 +fi + +WEBHOOK_URL="$1" +PLAYER_NAME="${2:-TestPlayer}" +SERVER_NAME="${3:-Minecraft}" + +# Create temporary Java file +TEMP_DIR=$(mktemp -d) +JAVA_FILE="$TEMP_DIR/DiscordWebhookTest.java" + +cat > "$JAVA_FILE" << 'EOF' +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +public class DiscordWebhookTest { + + public static void main(String[] args) { + if (args.length < 1) { + System.err.println("Usage: java DiscordWebhookTest [player-name] [server-name]"); + System.exit(1); + } + + String webhookUrl = args[0]; + String playerName = args.length > 1 ? args[1] : "TestPlayer"; + String serverName = args.length > 2 ? args[2] : "Minecraft"; + + System.out.println("Testing Discord webhook..."); + System.out.println("Webhook URL: " + maskWebhookUrl(webhookUrl)); + System.out.println("Player Name: " + playerName); + System.out.println("Server Name: " + serverName); + System.out.println(); + + String message = "**" + playerName + "** joined the **" + serverName + "** server"; + + try { + sendDiscordMessage(webhookUrl, message); + System.out.println("✓ SUCCESS: Message sent to Discord!"); + System.out.println("Check your Discord channel for the notification."); + System.exit(0); + } catch (Exception e) { + System.err.println("✗ ERROR: Failed to send message to Discord"); + System.err.println("Error: " + e.getMessage()); + e.printStackTrace(); + System.exit(1); + } + } + + private static void sendDiscordMessage(String webhookUrl, String content) throws IOException { + if (webhookUrl == null || webhookUrl.isEmpty()) { + throw new IllegalArgumentException("Discord webhook URL is not configured"); + } + + URL url = new URL(webhookUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setConnectTimeout(10000); // 10 seconds + connection.setReadTimeout(15000); // 15 seconds + connection.setDoOutput(true); + + // Create JSON payload with the message content + String jsonPayload = String.format("{\"content\": \"%s\"}", escapeJson(content)); + + System.out.println("Sending payload: " + jsonPayload); + + try { + try (OutputStream os = connection.getOutputStream()) { + byte[] input = jsonPayload.getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } + + int responseCode = connection.getResponseCode(); + System.out.println("Response code: " + responseCode); + + if (responseCode < 200 || responseCode >= 300) { + throw new IOException("Discord webhook returned error code: " + responseCode); + } + } finally { + connection.disconnect(); + } + } + + private static String escapeJson(String text) { + if (text == null) { + return ""; + } + StringBuilder sb = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + switch (c) { + case '"': sb.append("\\\""); break; + case '\\': sb.append("\\\\"); break; + case '\b': sb.append("\\b"); break; + case '\f': sb.append("\\f"); break; + case '\n': sb.append("\\n"); break; + case '\r': sb.append("\\r"); break; + case '\t': sb.append("\\t"); break; + default: + if (c < 0x20) { + String hex = Integer.toHexString(c); + sb.append("\\u00"); + if (hex.length() == 1) sb.append('0'); + sb.append(hex); + } else { + sb.append(c); + } + break; + } + } + return sb.toString(); + } + + private static String maskWebhookUrl(String url) { + // Mask the webhook token for security + if (url.contains("/webhooks/")) { + String[] parts = url.split("/webhooks/"); + if (parts.length > 1) { + String[] tokenParts = parts[1].split("/"); + if (tokenParts.length > 1) { + return parts[0] + "/webhooks/" + tokenParts[0] + "/****"; + } + } + } + return url; + } +} +EOF + +echo -e "${YELLOW}Compiling test script...${NC}" +if ! javac "$JAVA_FILE"; then + echo -e "${RED}Compilation failed${NC}" + rm -rf "$TEMP_DIR" + exit 1 +fi + +echo -e "${GREEN}Compilation successful${NC}" +echo "" +echo -e "${YELLOW}Sending test message...${NC}" +echo "" + +# Run the test, capturing exit code without 'set -e' interference +cd "$TEMP_DIR" +set +e +java DiscordWebhookTest "$WEBHOOK_URL" "$PLAYER_NAME" "$SERVER_NAME" +TEST_RESULT=$? +set -e + +# Cleanup +cd - > /dev/null +rm -rf "$TEMP_DIR" + +echo "" +if [ $TEST_RESULT -eq 0 ]; then + echo -e "${GREEN}========================================${NC}" + echo -e "${GREEN}Test completed successfully!${NC}" + echo -e "${GREEN}========================================${NC}" +else + echo -e "${RED}========================================${NC}" + echo -e "${RED}Test failed!${NC}" + echo -e "${RED}========================================${NC}" +fi + +exit $TEST_RESULT