Skip to content

Commit 9a60956

Browse files
Add Discord webhook notifications for player join/quit events
- 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>
1 parent d06aed3 commit 9a60956

File tree

7 files changed

+371
-9
lines changed

7 files changed

+371
-9
lines changed

src/main/java/dansplugins/activitytracker/ActivityTracker.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import dansplugins.activitytracker.factories.SessionFactory;
1010
import dansplugins.activitytracker.services.ActivityRecordService;
1111
import dansplugins.activitytracker.services.ConfigService;
12+
import dansplugins.activitytracker.services.DiscordWebhookService;
1213
import dansplugins.activitytracker.services.StorageService;
1314
import dansplugins.activitytracker.api.RestApiService;
1415
import dansplugins.activitytracker.utils.Logger;
@@ -48,6 +49,7 @@ public final class ActivityTracker extends PonderBukkitPlugin {
4849
private final SessionFactory sessionFactory = new SessionFactory(logger, persistentData);
4950
private final ActivityRecordFactory activityRecordFactory = new ActivityRecordFactory(logger, sessionFactory);
5051
private final ActivityRecordService activityRecordService = new ActivityRecordService(persistentData, activityRecordFactory, logger);
52+
private final DiscordWebhookService discordWebhookService = new DiscordWebhookService(configService, logger);
5153
private RestApiService restApiService;
5254

5355
/**
@@ -146,8 +148,8 @@ private void performCompatibilityChecks() {
146148
private void registerEventHandlers() {
147149
EventHandlerRegistry eventHandlerRegistry = new EventHandlerRegistry();
148150
ArrayList<Listener> listeners = new ArrayList<>(Arrays.asList(
149-
new JoinHandler(activityRecordService, persistentData, sessionFactory),
150-
new QuitHandler(persistentData, logger)
151+
new JoinHandler(activityRecordService, persistentData, sessionFactory, discordWebhookService, this),
152+
new QuitHandler(persistentData, logger, discordWebhookService, this)
151153
));
152154
eventHandlerRegistry.registerEventHandlers(listeners, this);
153155
}

src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import dansplugins.activitytracker.data.PersistentData;
44
import dansplugins.activitytracker.factories.SessionFactory;
5+
import dansplugins.activitytracker.services.DiscordWebhookService;
56
import org.bukkit.entity.Player;
67
import org.bukkit.event.EventHandler;
78
import org.bukkit.event.Listener;
89
import org.bukkit.event.player.PlayerJoinEvent;
10+
import org.bukkit.plugin.java.JavaPlugin;
911

1012
import dansplugins.activitytracker.objects.ActivityRecord;
1113
import dansplugins.activitytracker.objects.Session;
@@ -18,11 +20,17 @@ public class JoinHandler implements Listener {
1820
private final ActivityRecordService activityRecordService;
1921
private final PersistentData persistentData;
2022
private final SessionFactory sessionFactory;
23+
private final DiscordWebhookService discordWebhookService;
24+
private final JavaPlugin plugin;
2125

22-
public JoinHandler(ActivityRecordService activityRecordService, PersistentData persistentData, SessionFactory sessionFactory) {
26+
private static final String STAFF_PERMISSION = "at.staff";
27+
28+
public JoinHandler(ActivityRecordService activityRecordService, PersistentData persistentData, SessionFactory sessionFactory, DiscordWebhookService discordWebhookService, JavaPlugin plugin) {
2329
this.activityRecordService = activityRecordService;
2430
this.persistentData = persistentData;
2531
this.sessionFactory = sessionFactory;
32+
this.discordWebhookService = discordWebhookService;
33+
this.plugin = plugin;
2634
}
2735

2836
@EventHandler()
@@ -41,5 +49,22 @@ public void handle(PlayerJoinEvent event) {
4149
record.addSession(newSession);
4250
record.setMostRecentSession(newSession);
4351
}
52+
53+
sendDiscordJoinNotification(player);
54+
}
55+
56+
private void sendDiscordJoinNotification(Player player) {
57+
if (!discordWebhookService.isEnabled()) {
58+
return;
59+
}
60+
if (discordWebhookService.isStaffOnly() && !player.hasPermission(STAFF_PERMISSION)) {
61+
return;
62+
}
63+
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() {
64+
@Override
65+
public void run() {
66+
discordWebhookService.sendJoinNotification(player.getName());
67+
}
68+
});
4469
}
4570
}

src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package dansplugins.activitytracker.eventhandlers;
22

33
import dansplugins.activitytracker.exceptions.NoSessionException;
4+
import dansplugins.activitytracker.services.DiscordWebhookService;
45
import dansplugins.activitytracker.utils.Logger;
56
import org.bukkit.entity.Player;
67
import org.bukkit.event.EventHandler;
78
import org.bukkit.event.Listener;
89
import org.bukkit.event.player.PlayerQuitEvent;
10+
import org.bukkit.plugin.java.JavaPlugin;
911

1012
import dansplugins.activitytracker.data.PersistentData;
1113
import dansplugins.activitytracker.objects.ActivityRecord;
@@ -17,10 +19,16 @@
1719
public class QuitHandler implements Listener {
1820
private final PersistentData persistentData;
1921
private final Logger logger;
22+
private final DiscordWebhookService discordWebhookService;
23+
private final JavaPlugin plugin;
2024

21-
public QuitHandler(PersistentData persistentData, Logger logger) {
25+
private static final String STAFF_PERMISSION = "at.staff";
26+
27+
public QuitHandler(PersistentData persistentData, Logger logger, DiscordWebhookService discordWebhookService, JavaPlugin plugin) {
2228
this.persistentData = persistentData;
2329
this.logger = logger;
30+
this.discordWebhookService = discordWebhookService;
31+
this.plugin = plugin;
2432
}
2533

2634
@EventHandler()
@@ -56,5 +64,22 @@ public void handle(PlayerQuitEvent event) {
5664
} catch (Exception e) {
5765
logger.log("ERROR: Failed to properly end session for " + player.getName() + ": " + e.getMessage());
5866
}
67+
68+
sendDiscordQuitNotification(player);
69+
}
70+
71+
private void sendDiscordQuitNotification(Player player) {
72+
if (!discordWebhookService.isEnabled()) {
73+
return;
74+
}
75+
if (discordWebhookService.isStaffOnly() && !player.hasPermission(STAFF_PERMISSION)) {
76+
return;
77+
}
78+
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() {
79+
@Override
80+
public void run() {
81+
discordWebhookService.sendQuitNotification(player.getName());
82+
}
83+
});
5984
}
6085
}

src/main/java/dansplugins/activitytracker/services/ConfigService.java

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,21 @@ public void saveMissingConfigDefaultsIfNotPresent() {
4444
if (!getConfig().isSet("restApiPort")) {
4545
getConfig().set("restApiPort", 8080);
4646
}
47+
if (!getConfig().isSet("discordWebhookEnabled")) {
48+
getConfig().set("discordWebhookEnabled", false);
49+
}
50+
if (!getConfig().isSet("discordWebhookUrl")) {
51+
getConfig().set("discordWebhookUrl", "");
52+
}
53+
if (!getConfig().isSet("discordWebhookStaffOnly")) {
54+
getConfig().set("discordWebhookStaffOnly", false);
55+
}
56+
if (!getConfig().isSet("discordWebhookJoinMessage")) {
57+
getConfig().set("discordWebhookJoinMessage", "\u2694\uFE0F **{player}** has joined the server!");
58+
}
59+
if (!getConfig().isSet("discordWebhookQuitMessage")) {
60+
getConfig().set("discordWebhookQuitMessage", "\uD83D\uDC4B **{player}** has left the server.");
61+
}
4762
getConfig().options().copyDefaults(true);
4863
activityTracker.saveConfig();
4964
}
@@ -58,7 +73,8 @@ public void setConfigOption(String option, String value, CommandSender sender) {
5873
} else if (option.equalsIgnoreCase("restApiPort")) {
5974
getConfig().set(option, Integer.parseInt(value));
6075
sender.sendMessage(ChatColor.GREEN + "Integer set.");
61-
} else if (option.equalsIgnoreCase("debugMode") || option.equalsIgnoreCase("restApiEnabled")) {
76+
} else if (option.equalsIgnoreCase("debugMode") || option.equalsIgnoreCase("restApiEnabled")
77+
|| option.equalsIgnoreCase("discordWebhookEnabled") || option.equalsIgnoreCase("discordWebhookStaffOnly")) {
6278
getConfig().set(option, Boolean.parseBoolean(value));
6379
sender.sendMessage(ChatColor.GREEN + "Boolean set.");
6480
} else if (option.equalsIgnoreCase("")) { // no doubles yet
@@ -81,14 +97,24 @@ public void sendConfigList(CommandSender sender) {
8197
sender.sendMessage("");
8298
sender.sendMessage(ChatColor.GOLD + "┌─ " + ChatColor.YELLOW + "" + ChatColor.BOLD + "Activity Tracker" +
8399
ChatColor.RESET + ChatColor.GOLD + " ─ Config");
84-
sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "version: " +
100+
sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "version: " +
85101
ChatColor.WHITE + getConfig().getString("version"));
86-
sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "debugMode: " +
102+
sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "debugMode: " +
87103
ChatColor.WHITE + getString("debugMode"));
88-
sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "restApiEnabled: " +
104+
sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "restApiEnabled: " +
89105
ChatColor.WHITE + getString("restApiEnabled"));
90-
sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "restApiPort: " +
106+
sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "restApiPort: " +
91107
ChatColor.WHITE + getString("restApiPort"));
108+
sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "discordWebhookEnabled: " +
109+
ChatColor.WHITE + getString("discordWebhookEnabled"));
110+
sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "discordWebhookUrl: " +
111+
ChatColor.WHITE + getString("discordWebhookUrl"));
112+
sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "discordWebhookStaffOnly: " +
113+
ChatColor.WHITE + getString("discordWebhookStaffOnly"));
114+
sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "discordWebhookJoinMessage:" +
115+
ChatColor.WHITE + " " + getString("discordWebhookJoinMessage"));
116+
sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "discordWebhookQuitMessage:" +
117+
ChatColor.WHITE + " " + getString("discordWebhookQuitMessage"));
92118
sender.sendMessage(ChatColor.GOLD + "└─────────────────────────");
93119
}
94120

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package dansplugins.activitytracker.services;
2+
3+
import java.io.IOException;
4+
import java.io.OutputStream;
5+
import java.net.HttpURLConnection;
6+
import java.net.URL;
7+
import java.nio.charset.StandardCharsets;
8+
9+
import dansplugins.activitytracker.utils.Logger;
10+
11+
/**
12+
* Service for sending Discord webhook notifications when players join or leave the server.
13+
* @author Daniel McCoy Stephenson
14+
*/
15+
public class DiscordWebhookService {
16+
private final ConfigService configService;
17+
private final Logger logger;
18+
19+
public DiscordWebhookService(ConfigService configService, Logger logger) {
20+
this.configService = configService;
21+
this.logger = logger;
22+
}
23+
24+
/**
25+
* Checks if the Discord webhook feature is enabled and configured.
26+
* @return true if enabled and a webhook URL is set.
27+
*/
28+
public boolean isEnabled() {
29+
if (!configService.getBoolean("discordWebhookEnabled")) {
30+
return false;
31+
}
32+
String url = configService.getString("discordWebhookUrl");
33+
return url != null && !url.isEmpty();
34+
}
35+
36+
/**
37+
* Checks if webhooks should only fire for staff members.
38+
* @return true if staff-only mode is active.
39+
*/
40+
public boolean isStaffOnly() {
41+
return configService.getBoolean("discordWebhookStaffOnly");
42+
}
43+
44+
/**
45+
* Sends a player join notification to the configured Discord webhook.
46+
* @param playerName The name of the player who joined.
47+
*/
48+
public void sendJoinNotification(String playerName) {
49+
String template = configService.getString("discordWebhookJoinMessage");
50+
if (template == null || template.isEmpty()) {
51+
return;
52+
}
53+
String message = template.replace("{player}", playerName);
54+
sendWebhookMessage(message);
55+
}
56+
57+
/**
58+
* Sends a player quit notification to the configured Discord webhook.
59+
* @param playerName The name of the player who quit.
60+
*/
61+
public void sendQuitNotification(String playerName) {
62+
String template = configService.getString("discordWebhookQuitMessage");
63+
if (template == null || template.isEmpty()) {
64+
return;
65+
}
66+
String message = template.replace("{player}", playerName);
67+
sendWebhookMessage(message);
68+
}
69+
70+
/**
71+
* Sends a message to the configured Discord webhook URL.
72+
* @param content The message content to send.
73+
*/
74+
private void sendWebhookMessage(String content) {
75+
String webhookUrl = configService.getString("discordWebhookUrl");
76+
if (webhookUrl == null || webhookUrl.isEmpty()) {
77+
return;
78+
}
79+
80+
try {
81+
URL url = new URL(webhookUrl);
82+
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
83+
connection.setRequestMethod("POST");
84+
connection.setRequestProperty("Content-Type", "application/json");
85+
connection.setConnectTimeout(5000);
86+
connection.setReadTimeout(10000);
87+
connection.setDoOutput(true);
88+
89+
String jsonPayload = "{\"content\": \"" + escapeJson(content) + "\"}";
90+
91+
OutputStream os = connection.getOutputStream();
92+
byte[] input = jsonPayload.getBytes(StandardCharsets.UTF_8);
93+
os.write(input, 0, input.length);
94+
os.close();
95+
96+
int responseCode = connection.getResponseCode();
97+
if (responseCode < 200 || responseCode >= 300) {
98+
logger.log("Discord webhook returned error code: " + responseCode);
99+
}
100+
} catch (IOException e) {
101+
logger.log("Failed to send Discord webhook message: " + e.getMessage());
102+
}
103+
}
104+
105+
/**
106+
* Escapes special characters for JSON string values.
107+
* @param text The text to escape.
108+
* @return The escaped text safe for JSON inclusion.
109+
*/
110+
private String escapeJson(String text) {
111+
if (text == null) {
112+
return "";
113+
}
114+
StringBuilder sb = new StringBuilder();
115+
for (int i = 0; i < text.length(); i++) {
116+
char c = text.charAt(i);
117+
switch (c) {
118+
case '"':
119+
sb.append("\\\"");
120+
break;
121+
case '\\':
122+
sb.append("\\\\");
123+
break;
124+
case '\n':
125+
sb.append("\\n");
126+
break;
127+
case '\r':
128+
sb.append("\\r");
129+
break;
130+
case '\t':
131+
sb.append("\\t");
132+
break;
133+
default:
134+
if (c < 0x20) {
135+
sb.append(String.format("\\u%04x", (int) c));
136+
} else {
137+
sb.append(c);
138+
}
139+
break;
140+
}
141+
}
142+
return sb.toString();
143+
}
144+
}

src/main/resources/plugin.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,6 @@ permissions:
2121
at.config:
2222
default: op
2323
at.list:
24+
default: op
25+
at.staff:
2426
default: op

0 commit comments

Comments
 (0)