Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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;
Expand Down Expand Up @@ -48,6 +49,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;

/**
Expand Down Expand Up @@ -146,8 +148,8 @@ private void performCompatibilityChecks() {
private void registerEventHandlers() {
EventHandlerRegistry eventHandlerRegistry = new EventHandlerRegistry();
ArrayList<Listener> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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()
Expand All @@ -41,5 +49,23 @@ 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 playerName = player.getName();
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() {
@Override
public void run() {
discordWebhookService.sendJoinNotification(playerName);
}
});
Comment on lines +68 to +73
Copy link

Copilot AI Mar 8, 2026

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 via ConfigService (ultimately JavaPlugin#getConfig()) and may also call Logger.log() (which checks activityTracker.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).

Suggested change
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() {
@Override
public void run() {
discordWebhookService.sendJoinNotification(playerName);
}
});
// Call sendJoinNotification on the main thread to avoid async Bukkit API access
discordWebhookService.sendJoinNotification(playerName);

Copilot uses AI. Check for mistakes.
}
}
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;
Expand All @@ -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()
Expand Down Expand Up @@ -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
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same async-safety concern as in JoinHandler: the runnable calls discordWebhookService.sendQuitNotification(...), which reads plugin config (and may invoke Logger.log() which also reads config). This introduces Bukkit API calls from an asynchronous thread. Snapshot the required config/debug values on the main thread before scheduling, and keep the async work limited to the HTTP request.

Suggested change
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);

Copilot uses AI. Check for mistakes.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -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
Expand All @@ -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 + "└─────────────────────────");
}

Expand Down
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
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HttpURLConnection isn't disconnected and the response stream is never consumed/closed. On some JVMs this can leak sockets/file descriptors and degrade reliability over time. Ensure the response InputStream/ErrorStream is closed (even if you only need the status code) and call connection.disconnect() in a finally block (try-with-resources for the output stream also helps).

Copilot uses AI. Check for mistakes.
}

/**
* 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();
}
}
2 changes: 2 additions & 0 deletions src/main/resources/plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,6 @@ permissions:
at.config:
default: op
at.list:
default: op
Comment on lines 23 to +24
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR also changes the at.list permission to default to op. That permission behavior change isn't mentioned in the PR description and may be a breaking change for existing servers if /at list was previously available to non-ops. If this is intentional, please document it in the PR description/release notes; otherwise, consider reverting this permission default change.

Copilot uses AI. Check for mistakes.
at.staff:
default: op
Loading