Skip to content

Add Discord webhook notifications for player join/quit events#77

Draft
Copilot wants to merge 5 commits intomainfrom
copilot/add-discord-webhook-messages
Draft

Add Discord webhook notifications for player join/quit events#77
Copilot wants to merge 5 commits intomainfrom
copilot/add-discord-webhook-messages

Conversation

Copy link
Contributor

Copilot AI commented Mar 8, 2026

Adds Discord webhook integration for player join/quit notifications, modeled after Herald. Includes a staff-only mode that filters notifications to players with at.staff permission.

New: DiscordWebhookService

  • HTTP POST to Discord webhook URL with JSON {"content": "..."} payload
  • Configurable message templates with {player} placeholder
  • 5s connect / 10s read timeouts; errors logged gracefully
  • Config reading (isEnabled(), isStaffOnly(), getWebhookUrl(), prepareJoinMessage(), prepareQuitMessage()) is separated from HTTP sending (sendWebhookMessage(url, content)) for thread safety — config is read on the main server thread, only the HTTP call runs asynchronously

Config options

Key Type Default
discordWebhookEnabled boolean false
discordWebhookUrl string ""
discordWebhookStaffOnly boolean false
discordWebhookJoinMessage string ⚔️ **{player}** has joined the server!
discordWebhookQuitMessage string 👋 **{player}** has left the server.

Config initialization fix

  • initializeConfig() now always calls saveMissingConfigDefaultsIfNotPresent() on startup, so new config keys are written to existing config.yml files regardless of version match. Previously, existing servers with a matching version would never receive new default keys.

Event handlers

  • JoinHandler and QuitHandler snapshot all config values and player name on the main thread, then fire only the HTTP POST via runTaskAsynchronously — no Bukkit API access from async tasks
  • When discordWebhookStaffOnly is true, only players with at.staff (default: op) trigger notifications

Permission

  • at.staff added to plugin.yml (default: op) — used solely as a marker for staff-only filtering

Tests

  • Unit tests for DiscordWebhookService covering isEnabled()/isStaffOnly() states, getWebhookUrl() trimming (including whitespace-only), prepareJoinMessage()/prepareQuitMessage() template handling (null/empty/valid), sendWebhookMessage() null/empty URL guards, and invalid URL error logging
  • Uses MockitoAnnotations.openMocks() with @After cleanup
Original prompt

This section details on the original issue you should resolve

<issue_title>Suggestion: Discord webhook messaes.</issue_title>
<issue_description>Add disocrd webhook messages and make it so u can make it staff only.</issue_description>

<agent_instructions>See https://github.com/Dans-Plugins/Herald for reference</agent_instructions>

Comments on the Issue (you are @copilot in this section)


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

Copilot AI and others added 2 commits March 8, 2026 09:46
- Add DiscordWebhookService for sending messages to Discord webhooks
- Add config options: discordWebhookEnabled, discordWebhookUrl,
  discordWebhookStaffOnly, discordWebhookJoinMessage, discordWebhookQuitMessage
- Update JoinHandler and QuitHandler to send async Discord notifications
- Add at.staff permission for staff-only webhook filtering
- Add unit tests for DiscordWebhookService

Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
…dundant cast

Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Copilot AI changed the title [WIP] Add Discord webhook messages with staff-only option Add Discord webhook notifications for player join/quit events Mar 8, 2026
@dmccoystephenson dmccoystephenson requested a review from Copilot March 8, 2026 09:50
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a Discord webhook integration to ActivityTracker to notify a Discord channel when players join/quit, with configuration-driven templates and an optional staff-only filter.

Changes:

  • Introduces DiscordWebhookService to POST join/quit messages to a configured Discord webhook URL.
  • Hooks join/quit event handlers to fire webhook notifications asynchronously, with optional at.staff filtering.
  • Extends config defaults and /at config output to include Discord webhook settings, plus adds unit tests for the new service.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java New service that formats and sends webhook POST requests to Discord.
src/main/java/dansplugins/activitytracker/services/ConfigService.java Adds default config values and displays new webhook settings in the config list output.
src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java Schedules async webhook notification on player join with optional staff-only filtering.
src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java Schedules async webhook notification on player quit with optional staff-only filtering.
src/main/java/dansplugins/activitytracker/ActivityTracker.java Wires the new service into handler registration.
src/main/resources/plugin.yml Adds at.staff permission and changes at.list permission default.
src/test/java/dansplugins/activitytracker/services/DiscordWebhookServiceTest.java Adds unit tests for basic enable/config edge cases and invalid URL handling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +78 to +81
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() {
@Override
public void run() {
discordWebhookService.sendQuitNotification(player.getName());
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.

player.getName() is called from within runTaskAsynchronously. Accessing Player off the main server thread is not thread-safe, and during quit events the backing player object may already be in teardown. Capture player.getName() into a local String before scheduling the async task.

Suggested change
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() {
@Override
public void run() {
discordWebhookService.sendQuitNotification(player.getName());
final String playerName = player.getName();
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() {
@Override
public void run() {
discordWebhookService.sendQuitNotification(playerName);

Copilot uses AI. Check for mistakes.
Comment on lines 23 to +24
at.list:
default: op
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.
Comment on lines +71 to +77
@Test
public void testSendJoinNotificationSkipsWhenTemplateIsEmpty() {
when(configService.getString("discordWebhookJoinMessage")).thenReturn("");

// Should not throw and should not attempt to send
discordWebhookService.sendJoinNotification("TestPlayer");
}
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.

Several tests (e.g., the ones for empty/null templates) don't assert or verify any behavior—they will pass even if the method unexpectedly tries to post a webhook. Add Mockito verifications (e.g., verify that configService.getString("discordWebhookUrl")/logger.log(...) are not called, or refactor to inject a transport so posting can be asserted) so regressions are actually caught.

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +55
public void sendJoinNotification(String playerName) {
String template = configService.getString("discordWebhookJoinMessage");
if (template == null || template.isEmpty()) {
return;
}
String message = template.replace("{player}", playerName);
sendWebhookMessage(message);
}
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.

sendJoinNotification builds and sends a webhook payload regardless of the discordWebhookEnabled flag. Since the service already exposes isEnabled(), these public send methods should short-circuit when disabled (or have sendWebhookMessage enforce the enabled flag) to ensure the config toggle is always respected, even if other call sites are added later.

Copilot uses AI. Check for mistakes.
return false;
}
String url = configService.getString("discordWebhookUrl");
return url != null && !url.isEmpty();
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.

isEnabled() treats a whitespace-only discordWebhookUrl as configured, which will then fail later with a malformed URL. Consider trimming the URL and checking !trimmed.isEmpty() so staff-only checks don't schedule failing async tasks for blank-but-nonempty values.

Suggested change
return url != null && !url.isEmpty();
if (url == null) {
return false;
}
String trimmed = url.trim();
return !trimmed.isEmpty();

Copilot uses AI. Check for mistakes.
Comment on lines +99 to +105
int responseCode = connection.getResponseCode();
if (responseCode < 200 || responseCode >= 300) {
logger.log("Discord webhook returned error code: " + responseCode);
}
} catch (IOException e) {
logger.log("Failed to send Discord webhook message: " + e.getMessage());
}
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.
Comment on lines +63 to +66
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() {
@Override
public void run() {
discordWebhookService.sendJoinNotification(player.getName());
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.

player.getName() is called inside the async task. Bukkit/Spigot API objects (including Player) are generally not thread-safe, and on quit the player object can become invalid. Capture the player name (and any other Bukkit-derived values) on the main thread before scheduling, and only pass the plain String into the async runnable.

Suggested change
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() {
@Override
public void run() {
discordWebhookService.sendJoinNotification(player.getName());
final String playerName = player.getName();
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() {
@Override
public void run() {
discordWebhookService.sendJoinNotification(playerName);

Copilot uses AI. Check for mistakes.
@dmccoystephenson
Copy link
Member

@copilot address comments

…, connection cleanup, test verifications

- Capture player.getName() before async task in JoinHandler and QuitHandler
- Trim whitespace-only URLs in isEnabled() check
- Add isEnabled() guard at start of sendJoinNotification/sendQuitNotification
- Properly disconnect HttpURLConnection and consume response streams
- Add Mockito verify() assertions to tests and whitespace URL test case
- Use buffered read for response stream consumption

Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 8, 2026

@copilot address comments

All review comments addressed in fa9f28b:

  • Thread safety: player.getName() captured into local String before runTaskAsynchronously in both JoinHandler and QuitHandler
  • URL trimming: isEnabled() now trims the URL, so whitespace-only values are treated as unconfigured
  • isEnabled() guard: sendJoinNotification()/sendQuitNotification() short-circuit when disabled
  • Connection cleanup: HttpURLConnection.disconnect() in finally, response/error streams consumed and closed with buffered read
  • Tests: Added Mockito verify() assertions, whitespace-only URL test, and invalid URL error logging verification
  • at.list permission: Confirmed it was already default: op in the base branch — no change was introduced

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • hub.spigotmc.org
    • Triggering command: /usr/lib/jvm/temurin-17-jdk-amd64/bin/java /usr/lib/jvm/temurin-17-jdk-amd64/bin/java --enable-native-access=ALL-UNNAMED -classpath /usr/share/apache-maven-3.9.12/boot/plexus-classworlds-2.9.0.jar -Dclassworlds.conf=/usr/share/apache-maven-3.9.12/bin/m2.conf -Dmaven.home=/usr/share/apache-maven-3.9.12 -Dlibrary.jansi.path=/usr/share/apache-maven-3.9.12/lib/jansi-native -Dmaven.multiModuleProjectDirectory=/home/REDACTED/work/Activity-Tracker/Activity-Tracker org.codehaus.plexus.classworlds.launcher.Launcher -f pom.xml -B -V -e -Dfindbugs.skip -Dcheckstyle.skip -Dpmd.skip=true -Dspotbugs.skip -Denforcer.skip -Dmaven.javadoc.skip (dns block)
  • jitpack.io
    • Triggering command: /usr/lib/jvm/temurin-17-jdk-amd64/bin/java /usr/lib/jvm/temurin-17-jdk-amd64/bin/java --enable-native-access=ALL-UNNAMED -classpath /usr/share/apache-maven-3.9.12/boot/plexus-classworlds-2.9.0.jar -Dclassworlds.conf=/usr/share/apache-maven-3.9.12/bin/m2.conf -Dmaven.home=/usr/share/apache-maven-3.9.12 -Dlibrary.jansi.path=/usr/share/apache-maven-3.9.12/lib/jansi-native -Dmaven.multiModuleProjectDirectory=/home/REDACTED/work/Activity-Tracker/Activity-Tracker org.codehaus.plexus.classworlds.launcher.Launcher -f pom.xml -B -V -e -Dfindbugs.skip -Dcheckstyle.skip -Dpmd.skip=true -Dspotbugs.skip -Denforcer.skip -Dmaven.javadoc.skip (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +79 to +84
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() {
@Override
public void run() {
discordWebhookService.sendQuitNotification(playerName);
}
});
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.
Comment on lines +25 to +29
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
discordWebhookService = new DiscordWebhookService(configService, logger);
}
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.

MockitoAnnotations.initMocks(this) is deprecated in Mockito 3.x and doesn't give you an AutoCloseable to clean up. Prefer MockitoAnnotations.openMocks(this) (and close it in an @After), to match the newer pattern used elsewhere in the tests and avoid potential resource leaks.

Copilot uses AI. Check for mistakes.
Comment on lines +64 to +69
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() {
@Override
public void run() {
discordWebhookService.sendJoinNotification(playerName);
}
});
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.
@dmccoystephenson
Copy link
Member

@copilot address comments; also new config options weren't appearing when I tested this

… update tests

- Always call saveMissingConfigDefaultsIfNotPresent() so new config keys
  (Discord webhook settings) are written to existing config.yml files
- Refactor DiscordWebhookService: separate config reading (main thread)
  from HTTP sending (async task) via getWebhookUrl(), prepareJoinMessage(),
  prepareQuitMessage(), sendWebhookMessage(url, content)
- Update JoinHandler/QuitHandler to snapshot all config on main thread
- Use MockitoAnnotations.openMocks() instead of deprecated initMocks()
- Tests updated for new decomposed API

Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 8, 2026

@copilot address comments; also new config options weren't appearing when I tested this

Fixed in f6f897b:

Config not appearing: The root cause was initializeConfig() only called saveMissingConfigDefaultsIfNotPresent() when the config file didn't exist or the version was mismatched. For existing servers with a matching version, the new Discord webhook keys were never written. Fixed by always calling saveMissingConfigDefaultsIfNotPresent() on startup — the method is idempotent (checks isSet() before writing).

Async-safety: Refactored DiscordWebhookService to separate config reading from HTTP sending. Added getWebhookUrl(), prepareJoinMessage(), prepareQuitMessage() (config reads, main thread) and sendWebhookMessage(url, content) (HTTP only, async task). Handlers now snapshot all config values on the main thread and pass only plain strings to the async runnable.

Tests: Switched from deprecated initMocks() to openMocks() with @After cleanup.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • hub.spigotmc.org
    • Triggering command: /usr/lib/jvm/temurin-17-jdk-amd64/bin/java /usr/lib/jvm/temurin-17-jdk-amd64/bin/java --enable-native-access=ALL-UNNAMED -classpath /usr/share/apache-maven-3.9.12/boot/plexus-classworlds-2.9.0.jar -Dclassworlds.conf=/usr/share/apache-maven-3.9.12/bin/m2.conf -Dmaven.home=/usr/share/apache-maven-3.9.12 -Dlibrary.jansi.path=/usr/share/apache-maven-3.9.12/lib/jansi-native -Dmaven.multiModuleProjectDirectory=/home/REDACTED/work/Activity-Tracker/Activity-Tracker org.codehaus.plexus.classworlds.launcher.Launcher test -pl . -Dtest=DiscordWebhookServiceTest (dns block)
    • Triggering command: /usr/lib/jvm/temurin-17-jdk-amd64/bin/java /usr/lib/jvm/temurin-17-jdk-amd64/bin/java --enable-native-access=ALL-UNNAMED -classpath /usr/share/apache-maven-3.9.12/boot/plexus-classworlds-2.9.0.jar -Dclassworlds.conf=/usr/share/apache-maven-3.9.12/bin/m2.conf -Dmaven.home=/usr/share/apache-maven-3.9.12 -Dlibrary.jansi.path=/usr/share/apache-maven-3.9.12/lib/jansi-native -Dmaven.multiModuleProjectDirectory=/home/REDACTED/work/Activity-Tracker/Activity-Tracker org.codehaus.plexus.classworlds.launcher.Launcher -f pom.xml -B -V -e -Dfindbugs.skip -Dcheckstyle.skip -Dpmd.skip=true -Dspotbugs.skip -Denforcer.skip -Dmaven.javadoc.skip (dns block)
  • jitpack.io
    • Triggering command: /usr/lib/jvm/temurin-17-jdk-amd64/bin/java /usr/lib/jvm/temurin-17-jdk-amd64/bin/java --enable-native-access=ALL-UNNAMED -classpath /usr/share/apache-maven-3.9.12/boot/plexus-classworlds-2.9.0.jar -Dclassworlds.conf=/usr/share/apache-maven-3.9.12/bin/m2.conf -Dmaven.home=/usr/share/apache-maven-3.9.12 -Dlibrary.jansi.path=/usr/share/apache-maven-3.9.12/lib/jansi-native -Dmaven.multiModuleProjectDirectory=/home/REDACTED/work/Activity-Tracker/Activity-Tracker org.codehaus.plexus.classworlds.launcher.Launcher test -pl . -Dtest=DiscordWebhookServiceTest (dns block)
    • Triggering command: /usr/lib/jvm/temurin-17-jdk-amd64/bin/java /usr/lib/jvm/temurin-17-jdk-amd64/bin/java --enable-native-access=ALL-UNNAMED -classpath /usr/share/apache-maven-3.9.12/boot/plexus-classworlds-2.9.0.jar -Dclassworlds.conf=/usr/share/apache-maven-3.9.12/bin/m2.conf -Dmaven.home=/usr/share/apache-maven-3.9.12 -Dlibrary.jansi.path=/usr/share/apache-maven-3.9.12/lib/jansi-native -Dmaven.multiModuleProjectDirectory=/home/REDACTED/work/Activity-Tracker/Activity-Tracker org.codehaus.plexus.classworlds.launcher.Launcher -f pom.xml -B -V -e -Dfindbugs.skip -Dcheckstyle.skip -Dpmd.skip=true -Dspotbugs.skip -Denforcer.skip -Dmaven.javadoc.skip (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Suggestion: Discord webhook messaes.

3 participants