-
Notifications
You must be signed in to change notification settings - Fork 0
Add Discord webhook notifications for player join/quit events #77
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 4 commits
d06aed3
9a60956
877151a
fa9f28b
f6f897b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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,23 @@ 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 playerName = player.getName(); | ||||||||||||||||||
| plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() { | ||||||||||||||||||
| @Override | ||||||||||||||||||
| public void run() { | ||||||||||||||||||
| discordWebhookService.sendQuitNotification(playerName); | ||||||||||||||||||
| } | ||||||||||||||||||
| }); | ||||||||||||||||||
|
Comment on lines
+83
to
+88
|
||||||||||||||||||
| plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() { | |
| @Override | |
| public void run() { | |
| discordWebhookService.sendQuitNotification(playerName); | |
| } | |
| }); | |
| // Call on the main thread to avoid accessing Bukkit APIs from an async task | |
| discordWebhookService.sendQuitNotification(playerName); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| 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. | ||
| * @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. | ||
| * @return true if staff-only mode is active. | ||
| */ | ||
| public boolean isStaffOnly() { | ||
| return configService.getBoolean("discordWebhookStaffOnly"); | ||
| } | ||
|
|
||
| /** | ||
| * Sends a player join notification to the configured Discord webhook. | ||
| * @param playerName The name of the player who joined. | ||
| */ | ||
| public void sendJoinNotification(String playerName) { | ||
| if (!isEnabled()) { | ||
| return; | ||
| } | ||
| String template = configService.getString("discordWebhookJoinMessage"); | ||
| if (template == null || template.isEmpty()) { | ||
| return; | ||
| } | ||
| String message = template.replace("{player}", playerName); | ||
| sendWebhookMessage(message); | ||
| } | ||
|
|
||
| /** | ||
| * Sends a player quit notification to the configured Discord webhook. | ||
| * @param playerName The name of the player who quit. | ||
| */ | ||
| public void sendQuitNotification(String playerName) { | ||
| if (!isEnabled()) { | ||
| return; | ||
| } | ||
| String template = configService.getString("discordWebhookQuitMessage"); | ||
| if (template == null || template.isEmpty()) { | ||
| return; | ||
| } | ||
| String message = template.replace("{player}", playerName); | ||
| sendWebhookMessage(message); | ||
| } | ||
|
|
||
| /** | ||
| * Sends a message to the configured Discord webhook URL. | ||
| * @param content The message content to send. | ||
| */ | ||
| private void sendWebhookMessage(String content) { | ||
| String webhookUrl = configService.getString("discordWebhookUrl"); | ||
| if (webhookUrl == null || webhookUrl.trim().isEmpty()) { | ||
| return; | ||
| } | ||
|
|
||
| HttpURLConnection connection = null; | ||
| try { | ||
| URL url = new URL(webhookUrl.trim()); | ||
| 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(); | ||
| } | ||
| } | ||
|
Comment on lines
+125
to
+148
|
||
| } | ||
|
|
||
| /** | ||
| * 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(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,4 +21,6 @@ permissions: | |
| at.config: | ||
| default: op | ||
| at.list: | ||
| default: op | ||
|
Comment on lines
23
to
+24
|
||
| at.staff: | ||
| default: op | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The async task calls
discordWebhookService.sendJoinNotification(...), which reads plugin config viaConfigService(ultimatelyJavaPlugin#getConfig()) and may also callLogger.log()(which checksactivityTracker.isDebugEnabled()and reads config again). Bukkit/Spigot API access is generally not thread-safe off the main thread, so this can cause async-access warnings or subtle race issues. Consider snapshotting all needed config values (enabled, staffOnly, url, template, debug flag) on the main thread before scheduling, and have the async runnable only perform the HTTP call using those plain values (no Bukkit API calls).