diff --git a/src/main/java/dansplugins/activitytracker/ActivityTracker.java b/src/main/java/dansplugins/activitytracker/ActivityTracker.java index 91541a9..54ff950 100644 --- a/src/main/java/dansplugins/activitytracker/ActivityTracker.java +++ b/src/main/java/dansplugins/activitytracker/ActivityTracker.java @@ -1,6 +1,5 @@ package dansplugins.activitytracker; -import java.io.File; import java.util.ArrayList; import java.util.Arrays; @@ -9,6 +8,7 @@ import dansplugins.activitytracker.factories.SessionFactory; import dansplugins.activitytracker.services.ActivityRecordService; import dansplugins.activitytracker.services.ConfigService; +import dansplugins.activitytracker.services.DiscordWebhookService; import dansplugins.activitytracker.services.StorageService; import dansplugins.activitytracker.api.RestApiService; import dansplugins.activitytracker.utils.Logger; @@ -48,6 +48,7 @@ public final class ActivityTracker extends PonderBukkitPlugin { private final SessionFactory sessionFactory = new SessionFactory(logger, persistentData); private final ActivityRecordFactory activityRecordFactory = new ActivityRecordFactory(logger, sessionFactory); private final ActivityRecordService activityRecordService = new ActivityRecordService(persistentData, activityRecordFactory, logger); + private final DiscordWebhookService discordWebhookService = new DiscordWebhookService(configService, logger); private RestApiService restApiService; /** @@ -122,22 +123,7 @@ public boolean isDebugEnabled() { } private void initializeConfig() { - if (configFileExists()) { - performCompatibilityChecks(); - } - else { - configService.saveMissingConfigDefaultsIfNotPresent(); - } - } - - private boolean configFileExists() { - return new File("./plugins/" + getName() + "/config.yml").exists(); - } - - private void performCompatibilityChecks() { - if (isVersionMismatched()) { - configService.saveMissingConfigDefaultsIfNotPresent(); - } + configService.saveMissingConfigDefaultsIfNotPresent(); } /** @@ -146,8 +132,8 @@ private void performCompatibilityChecks() { private void registerEventHandlers() { EventHandlerRegistry eventHandlerRegistry = new EventHandlerRegistry(); ArrayList listeners = new ArrayList<>(Arrays.asList( - new JoinHandler(activityRecordService, persistentData, sessionFactory), - new QuitHandler(persistentData, logger) + new JoinHandler(activityRecordService, persistentData, sessionFactory, discordWebhookService, this), + new QuitHandler(persistentData, logger, discordWebhookService, this) )); eventHandlerRegistry.registerEventHandlers(listeners, this); } diff --git a/src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java b/src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java index 10defd8..64dfb1d 100644 --- a/src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java +++ b/src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java @@ -2,10 +2,12 @@ import dansplugins.activitytracker.data.PersistentData; import dansplugins.activitytracker.factories.SessionFactory; +import dansplugins.activitytracker.services.DiscordWebhookService; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.plugin.java.JavaPlugin; import dansplugins.activitytracker.objects.ActivityRecord; import dansplugins.activitytracker.objects.Session; @@ -18,11 +20,17 @@ public class JoinHandler implements Listener { private final ActivityRecordService activityRecordService; private final PersistentData persistentData; private final SessionFactory sessionFactory; + private final DiscordWebhookService discordWebhookService; + private final JavaPlugin plugin; - public JoinHandler(ActivityRecordService activityRecordService, PersistentData persistentData, SessionFactory sessionFactory) { + private static final String STAFF_PERMISSION = "at.staff"; + + public JoinHandler(ActivityRecordService activityRecordService, PersistentData persistentData, SessionFactory sessionFactory, DiscordWebhookService discordWebhookService, JavaPlugin plugin) { this.activityRecordService = activityRecordService; this.persistentData = persistentData; this.sessionFactory = sessionFactory; + this.discordWebhookService = discordWebhookService; + this.plugin = plugin; } @EventHandler() @@ -41,5 +49,27 @@ public void handle(PlayerJoinEvent event) { record.addSession(newSession); record.setMostRecentSession(newSession); } + + sendDiscordJoinNotification(player); + } + + private void sendDiscordJoinNotification(Player player) { + if (!discordWebhookService.isEnabled()) { + return; + } + if (discordWebhookService.isStaffOnly() && !player.hasPermission(STAFF_PERMISSION)) { + return; + } + final String webhookUrl = discordWebhookService.getWebhookUrl(); + final String message = discordWebhookService.prepareJoinMessage(player.getName()); + if (webhookUrl == null || message == null) { + return; + } + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() { + @Override + public void run() { + discordWebhookService.sendWebhookMessage(webhookUrl, message); + } + }); } } \ No newline at end of file diff --git a/src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java b/src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java index 9e58177..d64244e 100644 --- a/src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java +++ b/src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java @@ -1,11 +1,13 @@ package dansplugins.activitytracker.eventhandlers; import dansplugins.activitytracker.exceptions.NoSessionException; +import dansplugins.activitytracker.services.DiscordWebhookService; import dansplugins.activitytracker.utils.Logger; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.plugin.java.JavaPlugin; import dansplugins.activitytracker.data.PersistentData; import dansplugins.activitytracker.objects.ActivityRecord; @@ -17,10 +19,16 @@ public class QuitHandler implements Listener { private final PersistentData persistentData; private final Logger logger; + private final DiscordWebhookService discordWebhookService; + private final JavaPlugin plugin; - public QuitHandler(PersistentData persistentData, Logger logger) { + private static final String STAFF_PERMISSION = "at.staff"; + + public QuitHandler(PersistentData persistentData, Logger logger, DiscordWebhookService discordWebhookService, JavaPlugin plugin) { this.persistentData = persistentData; this.logger = logger; + this.discordWebhookService = discordWebhookService; + this.plugin = plugin; } @EventHandler() @@ -56,5 +64,27 @@ public void handle(PlayerQuitEvent event) { } catch (Exception e) { logger.log("ERROR: Failed to properly end session for " + player.getName() + ": " + e.getMessage()); } + + sendDiscordQuitNotification(player); + } + + private void sendDiscordQuitNotification(Player player) { + if (!discordWebhookService.isEnabled()) { + return; + } + if (discordWebhookService.isStaffOnly() && !player.hasPermission(STAFF_PERMISSION)) { + return; + } + final String webhookUrl = discordWebhookService.getWebhookUrl(); + final String message = discordWebhookService.prepareQuitMessage(player.getName()); + if (webhookUrl == null || message == null) { + return; + } + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() { + @Override + public void run() { + discordWebhookService.sendWebhookMessage(webhookUrl, message); + } + }); } } \ No newline at end of file diff --git a/src/main/java/dansplugins/activitytracker/services/ConfigService.java b/src/main/java/dansplugins/activitytracker/services/ConfigService.java index a697742..367511c 100644 --- a/src/main/java/dansplugins/activitytracker/services/ConfigService.java +++ b/src/main/java/dansplugins/activitytracker/services/ConfigService.java @@ -44,6 +44,21 @@ public void saveMissingConfigDefaultsIfNotPresent() { if (!getConfig().isSet("restApiPort")) { getConfig().set("restApiPort", 8080); } + if (!getConfig().isSet("discordWebhookEnabled")) { + getConfig().set("discordWebhookEnabled", false); + } + if (!getConfig().isSet("discordWebhookUrl")) { + getConfig().set("discordWebhookUrl", ""); + } + if (!getConfig().isSet("discordWebhookStaffOnly")) { + getConfig().set("discordWebhookStaffOnly", false); + } + if (!getConfig().isSet("discordWebhookJoinMessage")) { + getConfig().set("discordWebhookJoinMessage", "\u2694\uFE0F **{player}** has joined the server!"); + } + if (!getConfig().isSet("discordWebhookQuitMessage")) { + getConfig().set("discordWebhookQuitMessage", "\uD83D\uDC4B **{player}** has left the server."); + } getConfig().options().copyDefaults(true); activityTracker.saveConfig(); } @@ -58,7 +73,8 @@ public void setConfigOption(String option, String value, CommandSender sender) { } else if (option.equalsIgnoreCase("restApiPort")) { getConfig().set(option, Integer.parseInt(value)); sender.sendMessage(ChatColor.GREEN + "Integer set."); - } else if (option.equalsIgnoreCase("debugMode") || option.equalsIgnoreCase("restApiEnabled")) { + } else if (option.equalsIgnoreCase("debugMode") || option.equalsIgnoreCase("restApiEnabled") + || option.equalsIgnoreCase("discordWebhookEnabled") || option.equalsIgnoreCase("discordWebhookStaffOnly")) { getConfig().set(option, Boolean.parseBoolean(value)); sender.sendMessage(ChatColor.GREEN + "Boolean set."); } else if (option.equalsIgnoreCase("")) { // no doubles yet @@ -81,14 +97,24 @@ public void sendConfigList(CommandSender sender) { sender.sendMessage(""); sender.sendMessage(ChatColor.GOLD + "┌─ " + ChatColor.YELLOW + "" + ChatColor.BOLD + "Activity Tracker" + ChatColor.RESET + ChatColor.GOLD + " ─ Config"); - sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "version: " + + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "version: " + ChatColor.WHITE + getConfig().getString("version")); - sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "debugMode: " + + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "debugMode: " + ChatColor.WHITE + getString("debugMode")); - sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "restApiEnabled: " + + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "restApiEnabled: " + ChatColor.WHITE + getString("restApiEnabled")); - sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "restApiPort: " + + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "restApiPort: " + ChatColor.WHITE + getString("restApiPort")); + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "discordWebhookEnabled: " + + ChatColor.WHITE + getString("discordWebhookEnabled")); + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "discordWebhookUrl: " + + ChatColor.WHITE + getString("discordWebhookUrl")); + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "discordWebhookStaffOnly: " + + ChatColor.WHITE + getString("discordWebhookStaffOnly")); + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "discordWebhookJoinMessage:" + + ChatColor.WHITE + " " + getString("discordWebhookJoinMessage")); + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "discordWebhookQuitMessage:" + + ChatColor.WHITE + " " + getString("discordWebhookQuitMessage")); sender.sendMessage(ChatColor.GOLD + "└─────────────────────────"); } diff --git a/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java b/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java new file mode 100644 index 0000000..fb173b5 --- /dev/null +++ b/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java @@ -0,0 +1,190 @@ +package dansplugins.activitytracker.services; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import dansplugins.activitytracker.utils.Logger; + +/** + * Service for sending Discord webhook notifications when players join or leave the server. + * @author Daniel McCoy Stephenson + */ +public class DiscordWebhookService { + private final ConfigService configService; + private final Logger logger; + + public DiscordWebhookService(ConfigService configService, Logger logger) { + this.configService = configService; + this.logger = logger; + } + + /** + * Checks if the Discord webhook feature is enabled and configured. + * Must be called from the main server thread. + * @return true if enabled and a webhook URL is set. + */ + public boolean isEnabled() { + if (!configService.getBoolean("discordWebhookEnabled")) { + return false; + } + String url = configService.getString("discordWebhookUrl"); + if (url == null) { + return false; + } + String trimmed = url.trim(); + return !trimmed.isEmpty(); + } + + /** + * Checks if webhooks should only fire for staff members. + * Must be called from the main server thread. + * @return true if staff-only mode is active. + */ + public boolean isStaffOnly() { + return configService.getBoolean("discordWebhookStaffOnly"); + } + + /** + * Returns the configured webhook URL, trimmed. + * Must be called from the main server thread. + * @return the trimmed webhook URL, or null if not configured. + */ + public String getWebhookUrl() { + String url = configService.getString("discordWebhookUrl"); + if (url == null) { + return null; + } + String trimmed = url.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** + * Prepares a join notification message by applying the player name to the configured template. + * Must be called from the main server thread. + * @param playerName The name of the player who joined. + * @return The formatted message, or null if the template is empty/null. + */ + public String prepareJoinMessage(String playerName) { + String template = configService.getString("discordWebhookJoinMessage"); + if (template == null || template.isEmpty()) { + return null; + } + return template.replace("{player}", playerName); + } + + /** + * Prepares a quit notification message by applying the player name to the configured template. + * Must be called from the main server thread. + * @param playerName The name of the player who quit. + * @return The formatted message, or null if the template is empty/null. + */ + public String prepareQuitMessage(String playerName) { + String template = configService.getString("discordWebhookQuitMessage"); + if (template == null || template.isEmpty()) { + return null; + } + return template.replace("{player}", playerName); + } + + /** + * Sends a message to the specified Discord webhook URL. + * This method performs HTTP I/O and should be called from an async task. + * Does not access Bukkit APIs. + * @param webhookUrl The Discord webhook URL to post to. + * @param content The message content to send. + */ + public void sendWebhookMessage(String webhookUrl, String content) { + if (webhookUrl == null || webhookUrl.isEmpty()) { + return; + } + + HttpURLConnection connection = null; + try { + URL url = new URL(webhookUrl); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setConnectTimeout(5000); + connection.setReadTimeout(10000); + connection.setDoOutput(true); + + String jsonPayload = "{\"content\": \"" + escapeJson(content) + "\"}"; + byte[] input = jsonPayload.getBytes(StandardCharsets.UTF_8); + + OutputStream os = connection.getOutputStream(); + try { + os.write(input, 0, input.length); + } finally { + os.close(); + } + + int responseCode = connection.getResponseCode(); + if (responseCode < 200 || responseCode >= 300) { + logger.log("Discord webhook returned error code: " + responseCode); + } + + // Consume response stream to free up the connection + InputStream is = (responseCode >= 200 && responseCode < 300) + ? connection.getInputStream() + : connection.getErrorStream(); + if (is != null) { + try { + byte[] buffer = new byte[1024]; + while (is.read(buffer) != -1) { } + } finally { + is.close(); + } + } + } catch (IOException e) { + logger.log("Failed to send Discord webhook message: " + e.getMessage()); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + /** + * Escapes special characters for JSON string values. + * @param text The text to escape. + * @return The escaped text safe for JSON inclusion. + */ + private String escapeJson(String text) { + if (text == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + default: + if (c < 0x20) { + sb.append(String.format("\\u%04x", c)); + } else { + sb.append(c); + } + break; + } + } + return sb.toString(); + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index b7099e7..7d51850 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -21,4 +21,6 @@ permissions: at.config: default: op at.list: + default: op + at.staff: default: op \ No newline at end of file diff --git a/src/test/java/dansplugins/activitytracker/services/DiscordWebhookServiceTest.java b/src/test/java/dansplugins/activitytracker/services/DiscordWebhookServiceTest.java new file mode 100644 index 0000000..27b0816 --- /dev/null +++ b/src/test/java/dansplugins/activitytracker/services/DiscordWebhookServiceTest.java @@ -0,0 +1,179 @@ +package dansplugins.activitytracker.services; + +import dansplugins.activitytracker.utils.Logger; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for DiscordWebhookService + */ +public class DiscordWebhookServiceTest { + + @Mock + private ConfigService configService; + + @Mock + private Logger logger; + + private DiscordWebhookService discordWebhookService; + private AutoCloseable mocks; + + @Before + public void setUp() { + mocks = MockitoAnnotations.openMocks(this); + discordWebhookService = new DiscordWebhookService(configService, logger); + } + + @After + public void tearDown() throws Exception { + mocks.close(); + } + + @Test + public void testIsEnabledReturnsFalseWhenDisabled() { + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(false); + + assertFalse(discordWebhookService.isEnabled()); + } + + @Test + public void testIsEnabledReturnsFalseWhenUrlIsEmpty() { + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); + when(configService.getString("discordWebhookUrl")).thenReturn(""); + + assertFalse(discordWebhookService.isEnabled()); + } + + @Test + public void testIsEnabledReturnsFalseWhenUrlIsNull() { + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); + when(configService.getString("discordWebhookUrl")).thenReturn(null); + + assertFalse(discordWebhookService.isEnabled()); + } + + @Test + public void testIsEnabledReturnsFalseWhenUrlIsWhitespaceOnly() { + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); + when(configService.getString("discordWebhookUrl")).thenReturn(" "); + + assertFalse(discordWebhookService.isEnabled()); + } + + @Test + public void testIsEnabledReturnsTrueWhenConfigured() { + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); + when(configService.getString("discordWebhookUrl")).thenReturn("https://discord.com/api/webhooks/test"); + + assertTrue(discordWebhookService.isEnabled()); + } + + @Test + public void testIsStaffOnlyReturnsConfigValue() { + when(configService.getBoolean("discordWebhookStaffOnly")).thenReturn(true); + assertTrue(discordWebhookService.isStaffOnly()); + + when(configService.getBoolean("discordWebhookStaffOnly")).thenReturn(false); + assertFalse(discordWebhookService.isStaffOnly()); + } + + @Test + public void testGetWebhookUrlReturnsTrimmedUrl() { + when(configService.getString("discordWebhookUrl")).thenReturn(" https://discord.com/api/webhooks/test "); + + assertEquals("https://discord.com/api/webhooks/test", discordWebhookService.getWebhookUrl()); + } + + @Test + public void testGetWebhookUrlReturnsNullWhenEmpty() { + when(configService.getString("discordWebhookUrl")).thenReturn(""); + + assertNull(discordWebhookService.getWebhookUrl()); + } + + @Test + public void testGetWebhookUrlReturnsNullWhenNull() { + when(configService.getString("discordWebhookUrl")).thenReturn(null); + + assertNull(discordWebhookService.getWebhookUrl()); + } + + @Test + public void testGetWebhookUrlReturnsNullWhenWhitespaceOnly() { + when(configService.getString("discordWebhookUrl")).thenReturn(" "); + + assertNull(discordWebhookService.getWebhookUrl()); + } + + @Test + public void testPrepareJoinMessageReplacesPlayerName() { + when(configService.getString("discordWebhookJoinMessage")).thenReturn("**{player}** joined!"); + + assertEquals("**TestPlayer** joined!", discordWebhookService.prepareJoinMessage("TestPlayer")); + } + + @Test + public void testPrepareJoinMessageReturnsNullWhenTemplateIsEmpty() { + when(configService.getString("discordWebhookJoinMessage")).thenReturn(""); + + assertNull(discordWebhookService.prepareJoinMessage("TestPlayer")); + } + + @Test + public void testPrepareJoinMessageReturnsNullWhenTemplateIsNull() { + when(configService.getString("discordWebhookJoinMessage")).thenReturn(null); + + assertNull(discordWebhookService.prepareJoinMessage("TestPlayer")); + } + + @Test + public void testPrepareQuitMessageReplacesPlayerName() { + when(configService.getString("discordWebhookQuitMessage")).thenReturn("**{player}** left."); + + assertEquals("**TestPlayer** left.", discordWebhookService.prepareQuitMessage("TestPlayer")); + } + + @Test + public void testPrepareQuitMessageReturnsNullWhenTemplateIsEmpty() { + when(configService.getString("discordWebhookQuitMessage")).thenReturn(""); + + assertNull(discordWebhookService.prepareQuitMessage("TestPlayer")); + } + + @Test + public void testPrepareQuitMessageReturnsNullWhenTemplateIsNull() { + when(configService.getString("discordWebhookQuitMessage")).thenReturn(null); + + assertNull(discordWebhookService.prepareQuitMessage("TestPlayer")); + } + + @Test + public void testSendWebhookMessageSkipsWhenUrlIsNull() { + discordWebhookService.sendWebhookMessage(null, "test message"); + + // No HTTP call should be attempted, no errors logged + verifyNoInteractions(logger); + } + + @Test + public void testSendWebhookMessageSkipsWhenUrlIsEmpty() { + discordWebhookService.sendWebhookMessage("", "test message"); + + // No HTTP call should be attempted, no errors logged + verifyNoInteractions(logger); + } + + @Test + public void testSendWebhookMessageHandlesInvalidUrl() { + discordWebhookService.sendWebhookMessage("not-a-valid-url", "**TestPlayer** joined!"); + + // Error should be logged gracefully + verify(logger).log(contains("Failed to send Discord webhook message")); + } +}