From 08afa94b0628e53a2a8001735ff9ba594bd9081f Mon Sep 17 00:00:00 2001 From: J4im3x0 Date: Sun, 17 Aug 2025 22:31:25 -0400 Subject: [PATCH] implemented discord webhook for alerts --- pom.xml | 17 +- .../inventoryrollback/config/ConfigData.java | 176 ++++++- .../inventoryrollback/config/MessageData.java | 310 +++++++++++- .../discord/DiscordWebhook.java | 469 ++++++++++++++++++ .../inventoryrollback/listeners/ClickGUI.java | 45 +- .../listeners/EventLogs.java | 47 ++ src/main/resources/config.yml | 29 ++ src/main/resources/messages.yml | 41 ++ 8 files changed, 1104 insertions(+), 30 deletions(-) create mode 100644 src/main/java/me/danjono/inventoryrollback/discord/DiscordWebhook.java diff --git a/pom.xml b/pom.xml index e38fc71..ccd2e20 100644 --- a/pom.xml +++ b/pom.xml @@ -55,22 +55,6 @@ 1.20.5-R0.1-SNAPSHOT provided - - - org.spigotmc - spigot - 1.20.5-R0.1-SNAPSHOT - true - provided - org.bstats @@ -194,6 +178,7 @@ org.apache.maven.plugins maven-jar-plugin + 3.4.1 diff --git a/src/main/java/me/danjono/inventoryrollback/config/ConfigData.java b/src/main/java/me/danjono/inventoryrollback/config/ConfigData.java index d8ac15c..fd0442d 100644 --- a/src/main/java/me/danjono/inventoryrollback/config/ConfigData.java +++ b/src/main/java/me/danjono/inventoryrollback/config/ConfigData.java @@ -101,7 +101,26 @@ public String getName() { private static boolean bStatsEnabled; private static boolean debugEnabled; - public void setVariables() { + // Discord webhook configuration + private static boolean discordEnabled; + private static String discordWebhookUrl; + private static boolean discordBackupCreated; + private static boolean discordInventoryRestored; + private static boolean discordEnderChestRestored; + private static boolean discordHealthRestored; + private static boolean discordHungerRestored; + private static boolean discordExperienceRestored; + private static boolean discordPlayerDeath; + private static boolean discordForceBackup; + private static boolean discordIncludeServerName; + private static String discordServerName; + private static boolean discordUseEmbeds; + private static String discordColorBackup; + private static String discordColorRestore; + private static String discordColorDeath; + private static String discordColorWarning; + + public void setVariables() { setEnabled((boolean) getDefaultValue("enabled", true)); String folder = (String) getDefaultValue("folder-location", "DEFAULT"); @@ -149,6 +168,25 @@ public void setVariables() { setbStatsEnabled((boolean) getDefaultValue("bStats", true)); setDebugEnabled((boolean) getDefaultValue("debug", false)); + // Discord webhook settings + setDiscordEnabled((boolean) getDefaultValue("discord.enabled", false)); + setDiscordWebhookUrl((String) getDefaultValue("discord.webhook-url", "")); + setDiscordBackupCreated((boolean) getDefaultValue("discord.events.backup-created", true)); + setDiscordInventoryRestored((boolean) getDefaultValue("discord.events.inventory-restored", true)); + setDiscordEnderChestRestored((boolean) getDefaultValue("discord.events.ender-chest-restored", true)); + setDiscordHealthRestored((boolean) getDefaultValue("discord.events.health-restored", true)); + setDiscordHungerRestored((boolean) getDefaultValue("discord.events.hunger-restored", true)); + setDiscordExperienceRestored((boolean) getDefaultValue("discord.events.experience-restored", true)); + setDiscordPlayerDeath((boolean) getDefaultValue("discord.events.player-death", true)); + setDiscordForceBackup((boolean) getDefaultValue("discord.events.force-backup", true)); + setDiscordIncludeServerName((boolean) getDefaultValue("discord.settings.include-server-name", true)); + setDiscordServerName((String) getDefaultValue("discord.settings.server-name", "My Server")); + setDiscordUseEmbeds((boolean) getDefaultValue("discord.settings.use-embeds", true)); + setDiscordColorBackup((String) getDefaultValue("discord.settings.colors.backup", "#00ff00")); + setDiscordColorRestore((String) getDefaultValue("discord.settings.colors.restore", "#0099ff")); + setDiscordColorDeath((String) getDefaultValue("discord.settings.colors.death", "#ff3300")); + setDiscordColorWarning((String) getDefaultValue("discord.settings.colors.warning", "#ffcc00")); + if (saveChanges()) saveConfig(); } @@ -281,6 +319,74 @@ public static void setDebugEnabled(boolean enabled) { debugEnabled = enabled; } + public static void setDiscordEnabled(boolean enabled) { + discordEnabled = enabled; + } + + public static void setDiscordWebhookUrl(String url) { + discordWebhookUrl = url; + } + + public static void setDiscordBackupCreated(boolean enabled) { + discordBackupCreated = enabled; + } + + public static void setDiscordInventoryRestored(boolean enabled) { + discordInventoryRestored = enabled; + } + + public static void setDiscordEnderChestRestored(boolean enabled) { + discordEnderChestRestored = enabled; + } + + public static void setDiscordHealthRestored(boolean enabled) { + discordHealthRestored = enabled; + } + + public static void setDiscordHungerRestored(boolean enabled) { + discordHungerRestored = enabled; + } + + public static void setDiscordExperienceRestored(boolean enabled) { + discordExperienceRestored = enabled; + } + + public static void setDiscordPlayerDeath(boolean enabled) { + discordPlayerDeath = enabled; + } + + public static void setDiscordForceBackup(boolean enabled) { + discordForceBackup = enabled; + } + + public static void setDiscordIncludeServerName(boolean enabled) { + discordIncludeServerName = enabled; + } + + public static void setDiscordServerName(String name) { + discordServerName = name; + } + + public static void setDiscordUseEmbeds(boolean enabled) { + discordUseEmbeds = enabled; + } + + public static void setDiscordColorBackup(String color) { + discordColorBackup = color; + } + + public static void setDiscordColorRestore(String color) { + discordColorRestore = color; + } + + public static void setDiscordColorDeath(String color) { + discordColorDeath = color; + } + + public static void setDiscordColorWarning(String color) { + discordColorWarning = color; + } + public static boolean isEnabled() { return pluginEnabled; } @@ -389,6 +495,74 @@ public static boolean isDebugEnabled() { return debugEnabled; } + public static boolean isDiscordEnabled() { + return discordEnabled; + } + + public static String getDiscordWebhookUrl() { + return discordWebhookUrl; + } + + public static boolean isDiscordBackupCreated() { + return discordBackupCreated; + } + + public static boolean isDiscordInventoryRestored() { + return discordInventoryRestored; + } + + public static boolean isDiscordEnderChestRestored() { + return discordEnderChestRestored; + } + + public static boolean isDiscordHealthRestored() { + return discordHealthRestored; + } + + public static boolean isDiscordHungerRestored() { + return discordHungerRestored; + } + + public static boolean isDiscordExperienceRestored() { + return discordExperienceRestored; + } + + public static boolean isDiscordPlayerDeath() { + return discordPlayerDeath; + } + + public static boolean isDiscordForceBackup() { + return discordForceBackup; + } + + public static boolean isDiscordIncludeServerName() { + return discordIncludeServerName; + } + + public static String getDiscordServerName() { + return discordServerName; + } + + public static boolean isDiscordUseEmbeds() { + return discordUseEmbeds; + } + + public static String getDiscordColorBackup() { + return discordColorBackup; + } + + public static String getDiscordColorRestore() { + return discordColorRestore; + } + + public static String getDiscordColorDeath() { + return discordColorDeath; + } + + public static String getDiscordColorWarning() { + return discordColorWarning; + } + private boolean saveChanges = false; public Object getDefaultValue(String path, Object defaultValue) { Object obj = configuration.get(path); diff --git a/src/main/java/me/danjono/inventoryrollback/config/MessageData.java b/src/main/java/me/danjono/inventoryrollback/config/MessageData.java index 07c44d2..47ab983 100644 --- a/src/main/java/me/danjono/inventoryrollback/config/MessageData.java +++ b/src/main/java/me/danjono/inventoryrollback/config/MessageData.java @@ -124,6 +124,38 @@ public boolean saveConfig() { private static String previousPageButton; private static String backButton; + // Discord webhook messages + private static String discordTitleBackupCreated; + private static String discordTitleInventoryRestored; + private static String discordTitleEnderChestRestored; + private static String discordTitleHealthRestored; + private static String discordTitleHungerRestored; + private static String discordTitleExperienceRestored; + private static String discordTitlePlayerDeath; + private static String discordTitleForceBackup; + + private static String discordDescBackupCreated; + private static String discordDescInventoryRestored; + private static String discordDescEnderChestRestored; + private static String discordDescHealthRestored; + private static String discordDescHungerRestored; + private static String discordDescExperienceRestored; + private static String discordDescPlayerDeath; + private static String discordDescForceBackup; + + private static String discordMsgBackupCreated; + private static String discordMsgInventoryRestored; + private static String discordMsgEnderChestRestored; + private static String discordMsgHealthRestored; + private static String discordMsgHungerRestored; + private static String discordMsgExperienceRestored; + private static String discordMsgPlayerDeath; + private static String discordMsgForceBackup; + + private static String discordErrorWebhookFailed; + private static String discordErrorInvalidWebhook; + private static String discordErrorConnectionFailed; + public void setMessages() { setPluginPrefix(convertColorCodes((String) getDefaultValue("general.prefix", "&f[&bInventoryRollbackPlus&f]&r "))); @@ -194,6 +226,38 @@ public void setMessages() { setPreviousPageButton(convertColorCodes((String) getDefaultValue("menu-buttons.previous-page", "&fPrevious Page"))); setBackButton(convertColorCodes((String) getDefaultValue("menu-buttons.back-page", "&fBack"))); + // Discord webhook messages + setDiscordTitleBackupCreated((String) getDefaultValue("discord.titles.backup-created", "📦 Backup Created")); + setDiscordTitleInventoryRestored((String) getDefaultValue("discord.titles.inventory-restored", "🎒 Inventory Restored")); + setDiscordTitleEnderChestRestored((String) getDefaultValue("discord.titles.ender-chest-restored", "📦 Ender Chest Restored")); + setDiscordTitleHealthRestored((String) getDefaultValue("discord.titles.health-restored", "❤️ Health Restored")); + setDiscordTitleHungerRestored((String) getDefaultValue("discord.titles.hunger-restored", "🍖 Hunger Restored")); + setDiscordTitleExperienceRestored((String) getDefaultValue("discord.titles.experience-restored", "✨ Experience Restored")); + setDiscordTitlePlayerDeath((String) getDefaultValue("discord.titles.player-death", "💀 Player Death")); + setDiscordTitleForceBackup((String) getDefaultValue("discord.titles.force-backup", "🔧 Force Backup")); + + setDiscordDescBackupCreated((String) getDefaultValue("discord.descriptions.backup-created", "Player **%PLAYER%** backup created\n**Type:** %TYPE%\n**Time:** %TIME%")); + setDiscordDescInventoryRestored((String) getDefaultValue("discord.descriptions.inventory-restored", "Player **%PLAYER%** inventory restored by **%ADMIN%**\n**From backup:** %TIME%")); + setDiscordDescEnderChestRestored((String) getDefaultValue("discord.descriptions.ender-chest-restored", "Player **%PLAYER%** ender chest restored by **%ADMIN%**\n**From backup:** %TIME%")); + setDiscordDescHealthRestored((String) getDefaultValue("discord.descriptions.health-restored", "Player **%PLAYER%** health restored by **%ADMIN%**\n**Health:** %HEALTH%\n**From backup:** %TIME%")); + setDiscordDescHungerRestored((String) getDefaultValue("discord.descriptions.hunger-restored", "Player **%PLAYER%** hunger restored by **%ADMIN%**\n**Hunger:** %HUNGER%\n**From backup:** %TIME%")); + setDiscordDescExperienceRestored((String) getDefaultValue("discord.descriptions.experience-restored", "Player **%PLAYER%** experience restored by **%ADMIN%**\n**Level:** %LEVEL%\n**From backup:** %TIME%")); + setDiscordDescPlayerDeath((String) getDefaultValue("discord.descriptions.player-death", "Player **%PLAYER%** died\n**Location:** %WORLD% (%X%, %Y%, %Z%)\n**Cause:** %CAUSE%\n**Time:** %TIME%")); + setDiscordDescForceBackup((String) getDefaultValue("discord.descriptions.force-backup", "Force backup created for **%PLAYER%** by **%ADMIN%**\n**Time:** %TIME%")); + + setDiscordMsgBackupCreated((String) getDefaultValue("discord.messages.backup-created", "📦 Backup created for %PLAYER% (%TYPE%) at %TIME%")); + setDiscordMsgInventoryRestored((String) getDefaultValue("discord.messages.inventory-restored", "🎒 %PLAYER% inventory restored by %ADMIN% from backup %TIME%")); + setDiscordMsgEnderChestRestored((String) getDefaultValue("discord.messages.ender-chest-restored", "📦 %PLAYER% ender chest restored by %ADMIN% from backup %TIME%")); + setDiscordMsgHealthRestored((String) getDefaultValue("discord.messages.health-restored", "❤️ %PLAYER% health restored by %ADMIN% (Health: %HEALTH%) from backup %TIME%")); + setDiscordMsgHungerRestored((String) getDefaultValue("discord.messages.hunger-restored", "🍖 %PLAYER% hunger restored by %ADMIN% (Hunger: %HUNGER%) from backup %TIME%")); + setDiscordMsgExperienceRestored((String) getDefaultValue("discord.messages.experience-restored", "✨ %PLAYER% experience restored by %ADMIN% (Level: %LEVEL%) from backup %TIME%")); + setDiscordMsgPlayerDeath((String) getDefaultValue("discord.messages.player-death", "💀 %PLAYER% died at %WORLD% (%X%, %Y%, %Z%) - %CAUSE% at %TIME%")); + setDiscordMsgForceBackup((String) getDefaultValue("discord.messages.force-backup", "🔧 Force backup created for %PLAYER% by %ADMIN% at %TIME%")); + + setDiscordErrorWebhookFailed(convertColorCodes((String) getDefaultValue("discord.errors.webhook-failed", "&cFailed to send Discord webhook message"))); + setDiscordErrorInvalidWebhook(convertColorCodes((String) getDefaultValue("discord.errors.invalid-webhook", "&cInvalid Discord webhook URL configured"))); + setDiscordErrorConnectionFailed(convertColorCodes((String) getDefaultValue("discord.errors.connection-failed", "&cFailed to connect to Discord webhook"))); + if (saveChanges()) saveConfig(); } @@ -398,6 +462,113 @@ public static void setBackButton(String message) { backButton = message; } + public static void setDiscordTitleBackupCreated(String message) { + discordTitleBackupCreated = message; + } + + public static void setDiscordTitleInventoryRestored(String message) { + discordTitleInventoryRestored = message; + } + + public static void setDiscordTitleEnderChestRestored(String message) { + discordTitleEnderChestRestored = message; + } + + public static void setDiscordTitleHealthRestored(String message) { + discordTitleHealthRestored = message; + } + + public static void setDiscordTitleHungerRestored(String message) { + discordTitleHungerRestored = message; + } + + public static void setDiscordTitleExperienceRestored(String message) { + discordTitleExperienceRestored = message; + } + + public static void setDiscordTitlePlayerDeath(String message) { + discordTitlePlayerDeath = message; + } + + public static void setDiscordTitleForceBackup(String message) { + discordTitleForceBackup = message; + } + + public static void setDiscordDescBackupCreated(String message) { + discordDescBackupCreated = message; + } + + public static void setDiscordDescInventoryRestored(String message) { + discordDescInventoryRestored = message; + } + + public static void setDiscordDescEnderChestRestored(String message) { + discordDescEnderChestRestored = message; + } + + public static void setDiscordDescHealthRestored(String message) { + discordDescHealthRestored = message; + } + + public static void setDiscordDescHungerRestored(String message) { + discordDescHungerRestored = message; + } + + public static void setDiscordDescExperienceRestored(String message) { + discordDescExperienceRestored = message; + } + + public static void setDiscordDescPlayerDeath(String message) { + discordDescPlayerDeath = message; + } + + public static void setDiscordDescForceBackup(String message) { + discordDescForceBackup = message; + } + + public static void setDiscordMsgBackupCreated(String message) { + discordMsgBackupCreated = message; + } + + public static void setDiscordMsgInventoryRestored(String message) { + discordMsgInventoryRestored = message; + } + + public static void setDiscordMsgEnderChestRestored(String message) { + discordMsgEnderChestRestored = message; + } + + public static void setDiscordMsgHealthRestored(String message) { + discordMsgHealthRestored = message; + } + + public static void setDiscordMsgHungerRestored(String message) { + discordMsgHungerRestored = message; + } + + public static void setDiscordMsgExperienceRestored(String message) { + discordMsgExperienceRestored = message; + } + + public static void setDiscordMsgPlayerDeath(String message) { + discordMsgPlayerDeath = message; + } + + public static void setDiscordMsgForceBackup(String message) { + discordMsgForceBackup = message; + } + + public static void setDiscordErrorWebhookFailed(String message) { + discordErrorWebhookFailed = message; + } + + public static void setDiscordErrorInvalidWebhook(String message) { + discordErrorInvalidWebhook = message; + } + + public static void setDiscordErrorConnectionFailed(String message) { + discordErrorConnectionFailed = message; + } // GETTERS @@ -452,7 +623,7 @@ public static String getNotOnlineError(String name) { public static String getForceBackupPlayer(String name) { return forceSavedPlayer.replaceAll(nameVariable, name); } - + public static String getForceBackupAll() { return forceSavedAll; } @@ -460,7 +631,7 @@ public static String getForceBackupAll() { public static String getForceBackupError(String name) { return notForcedSaved.replaceAll(nameVariable, name); } - + public static String getMainInventoryRestored(String name) { return mainInventoryRestored.replaceAll(nameVariable, name); } @@ -472,7 +643,7 @@ public static String getMainInventoryRestoredPlayer(String name) { public static String getMainInventoryNotOnline(String name) { return mainInventoryNotOnline.replaceAll(nameVariable, name); } - + public static String getMainInventoryRestoreButton() { return mainInventoryButton; } @@ -492,7 +663,7 @@ public static String getEnderChestRestoredPlayer(String name) { public static String getEnderChestNotOnline(String name) { return enderChestNotOnline.replaceAll(nameVariable, name); } - + public static String getEnderChestRestoreButton() { return enderChestButton; } @@ -508,7 +679,7 @@ public static String getHealthRestoredPlayer(String name) { public static String getHealthNotOnline(String name) { return healthNotOnline.replaceAll(nameVariable, name); } - + public static String getHealthRestoreButton() { return healthButton; } @@ -524,7 +695,7 @@ public static String getHungerRestoredPlayer(String name) { public static String getHungerNotOnline(String name) { return hungerNotOnline.replaceAll(nameVariable, name); } - + public static String getHungerRestoreButton() { return hungerButton; } @@ -540,11 +711,11 @@ public static String getExperienceRestoredPlayer(String name, int xp) { public static String getExperienceNotOnlinePlayer(String name) { return experienceNotOnline.replaceAll(nameVariable, name); } - + public static String getExperienceRestoreButton() { return experienceButton; } - + public static String getExperienceRestoreLevel(int xp) { return experienceButtonLore.replaceAll(xpVariable, xp + ""); } @@ -572,11 +743,11 @@ public static String getDeathReason(String reason) { public static String getDeathTime(String time) { return deathTime.replace("%TIME%", time); } - + public static String getDeathLocation() { return deathLocationTeleportTo; } - + public static String getDeathLocationTeleport(Location location) { return deathLocationTeleport.replace("%LOCATION%", "X:" + (int) location.getX() + " Y:" + (int) location.getY() + " Z:" + (int) location.getZ()); } @@ -600,7 +771,116 @@ public static String getPreviousPageButton() { public static String getBackButton() { return backButton; } - + + // Discord webhook message getters + public static String getDiscordTitleBackupCreated() { + return discordTitleBackupCreated; + } + + public static String getDiscordTitleInventoryRestored() { + return discordTitleInventoryRestored; + } + + public static String getDiscordTitleEnderChestRestored() { + return discordTitleEnderChestRestored; + } + + public static String getDiscordTitleHealthRestored() { + return discordTitleHealthRestored; + } + + public static String getDiscordTitleHungerRestored() { + return discordTitleHungerRestored; + } + + public static String getDiscordTitleExperienceRestored() { + return discordTitleExperienceRestored; + } + + public static String getDiscordTitlePlayerDeath() { + return discordTitlePlayerDeath; + } + + public static String getDiscordTitleForceBackup() { + return discordTitleForceBackup; + } + + public static String getDiscordDescBackupCreated() { + return discordDescBackupCreated; + } + + public static String getDiscordDescInventoryRestored() { + return discordDescInventoryRestored; + } + + public static String getDiscordDescEnderChestRestored() { + return discordDescEnderChestRestored; + } + + public static String getDiscordDescHealthRestored() { + return discordDescHealthRestored; + } + + public static String getDiscordDescHungerRestored() { + return discordDescHungerRestored; + } + + public static String getDiscordDescExperienceRestored() { + return discordDescExperienceRestored; + } + + public static String getDiscordDescPlayerDeath() { + return discordDescPlayerDeath; + } + + public static String getDiscordDescForceBackup() { + return discordDescForceBackup; + } + + public static String getDiscordMsgBackupCreated() { + return discordMsgBackupCreated; + } + + public static String getDiscordMsgInventoryRestored() { + return discordMsgInventoryRestored; + } + + public static String getDiscordMsgEnderChestRestored() { + return discordMsgEnderChestRestored; + } + + public static String getDiscordMsgHealthRestored() { + return discordMsgHealthRestored; + } + + public static String getDiscordMsgHungerRestored() { + return discordMsgHungerRestored; + } + + public static String getDiscordMsgExperienceRestored() { + return discordMsgExperienceRestored; + } + + public static String getDiscordMsgPlayerDeath() { + return discordMsgPlayerDeath; + } + + public static String getDiscordMsgForceBackup() { + return discordMsgForceBackup; + } + + public static String getDiscordErrorWebhookFailed() { + return discordErrorWebhookFailed; + } + + public static String getDiscordErrorInvalidWebhook() { + return discordErrorInvalidWebhook; + } + + public static String getDiscordErrorConnectionFailed() { + return discordErrorConnectionFailed; + } + private static String convertColorCodes(String text) { return ChatColor.translateAlternateColorCodes('&', text); } @@ -615,6 +895,14 @@ public Object getDefaultValue(String path, Object defaultValue) { saveChanges = true; } + // Process newline characters for Discord messages + if (obj instanceof String && path.startsWith("discord.")) { + String str = (String) obj; + // Convert literal \n sequences to actual newlines + str = str.replace("\\n", "\n"); + obj = str; + } + return obj; } diff --git a/src/main/java/me/danjono/inventoryrollback/discord/DiscordWebhook.java b/src/main/java/me/danjono/inventoryrollback/discord/DiscordWebhook.java new file mode 100644 index 0000000..9df6bed --- /dev/null +++ b/src/main/java/me/danjono/inventoryrollback/discord/DiscordWebhook.java @@ -0,0 +1,469 @@ +package me.danjono.inventoryrollback.discord; + +import me.danjono.inventoryrollback.InventoryRollback; +import me.danjono.inventoryrollback.config.ConfigData; +import me.danjono.inventoryrollback.config.MessageData; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.Player; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; + +public class DiscordWebhook { + + public enum EventType { + BACKUP_CREATED, + INVENTORY_RESTORED, + ENDER_CHEST_RESTORED, + HEALTH_RESTORED, + HUNGER_RESTORED, + EXPERIENCE_RESTORED, + PLAYER_DEATH, + FORCE_BACKUP + } + + /** + * Sends a Discord webhook message for a backup creation event + */ + public static void sendBackupCreated(String playerName, String backupType, String timestamp) { + if (!ConfigData.isDiscordEnabled() || !ConfigData.isDiscordBackupCreated()) { + return; + } + + String message, title, description; + if (ConfigData.isDiscordUseEmbeds()) { + title = MessageData.getDiscordTitleBackupCreated(); + description = MessageData.getDiscordDescBackupCreated() + .replace("%PLAYER%", playerName) + .replace("%TYPE%", backupType) + .replace("%TIME%", timestamp); + message = null; + } else { + message = MessageData.getDiscordMsgBackupCreated() + .replace("%PLAYER%", playerName) + .replace("%TYPE%", backupType) + .replace("%TIME%", timestamp); + title = null; + description = null; + } + + sendWebhookMessage(EventType.BACKUP_CREATED, message, title, description); + } + + /** + * Sends a Discord webhook message for an inventory restoration event + */ + public static void sendInventoryRestored(String playerName, String adminName, String timestamp) { + if (!ConfigData.isDiscordEnabled() || !ConfigData.isDiscordInventoryRestored()) { + return; + } + + String message, title, description; + if (ConfigData.isDiscordUseEmbeds()) { + title = MessageData.getDiscordTitleInventoryRestored(); + description = MessageData.getDiscordDescInventoryRestored() + .replace("%PLAYER%", playerName) + .replace("%ADMIN%", adminName) + .replace("%TIME%", timestamp); + message = null; + } else { + message = MessageData.getDiscordMsgInventoryRestored() + .replace("%PLAYER%", playerName) + .replace("%ADMIN%", adminName) + .replace("%TIME%", timestamp); + title = null; + description = null; + } + + sendWebhookMessage(EventType.INVENTORY_RESTORED, message, title, description); + } + + /** + * Sends a Discord webhook message for an ender chest restoration event + */ + public static void sendEnderChestRestored(String playerName, String adminName, String timestamp) { + if (!ConfigData.isDiscordEnabled() || !ConfigData.isDiscordEnderChestRestored()) { + return; + } + + String message, title, description; + if (ConfigData.isDiscordUseEmbeds()) { + title = MessageData.getDiscordTitleEnderChestRestored(); + description = MessageData.getDiscordDescEnderChestRestored() + .replace("%PLAYER%", playerName) + .replace("%ADMIN%", adminName) + .replace("%TIME%", timestamp); + message = null; + } else { + message = MessageData.getDiscordMsgEnderChestRestored() + .replace("%PLAYER%", playerName) + .replace("%ADMIN%", adminName) + .replace("%TIME%", timestamp); + title = null; + description = null; + } + + sendWebhookMessage(EventType.ENDER_CHEST_RESTORED, message, title, description); + } + + /** + * Sends a Discord webhook message for a health restoration event + */ + public static void sendHealthRestored(String playerName, String adminName, double health, String timestamp) { + if (!ConfigData.isDiscordEnabled() || !ConfigData.isDiscordHealthRestored()) { + return; + } + + String message, title, description; + if (ConfigData.isDiscordUseEmbeds()) { + title = MessageData.getDiscordTitleHealthRestored(); + description = MessageData.getDiscordDescHealthRestored() + .replace("%PLAYER%", playerName) + .replace("%ADMIN%", adminName) + .replace("%HEALTH%", String.format("%.1f", health)) + .replace("%TIME%", timestamp); + message = null; + } else { + message = MessageData.getDiscordMsgHealthRestored() + .replace("%PLAYER%", playerName) + .replace("%ADMIN%", adminName) + .replace("%HEALTH%", String.format("%.1f", health)) + .replace("%TIME%", timestamp); + title = null; + description = null; + } + + sendWebhookMessage(EventType.HEALTH_RESTORED, message, title, description); + } + + /** + * Sends a Discord webhook message for a hunger restoration event + */ + public static void sendHungerRestored(String playerName, String adminName, int hunger, String timestamp) { + if (!ConfigData.isDiscordEnabled() || !ConfigData.isDiscordHungerRestored()) { + return; + } + + String message, title, description; + if (ConfigData.isDiscordUseEmbeds()) { + title = MessageData.getDiscordTitleHungerRestored(); + description = MessageData.getDiscordDescHungerRestored() + .replace("%PLAYER%", playerName) + .replace("%ADMIN%", adminName) + .replace("%HUNGER%", String.valueOf(hunger)) + .replace("%TIME%", timestamp); + message = null; + } else { + message = MessageData.getDiscordMsgHungerRestored() + .replace("%PLAYER%", playerName) + .replace("%ADMIN%", adminName) + .replace("%HUNGER%", String.valueOf(hunger)) + .replace("%TIME%", timestamp); + title = null; + description = null; + } + + sendWebhookMessage(EventType.HUNGER_RESTORED, message, title, description); + } + + /** + * Sends a Discord webhook message for an experience restoration event + */ + public static void sendExperienceRestored(String playerName, String adminName, int level, String timestamp) { + if (!ConfigData.isDiscordEnabled() || !ConfigData.isDiscordExperienceRestored()) { + return; + } + + String message, title, description; + if (ConfigData.isDiscordUseEmbeds()) { + title = MessageData.getDiscordTitleExperienceRestored(); + description = MessageData.getDiscordDescExperienceRestored() + .replace("%PLAYER%", playerName) + .replace("%ADMIN%", adminName) + .replace("%LEVEL%", String.valueOf(level)) + .replace("%TIME%", timestamp); + message = null; + } else { + message = MessageData.getDiscordMsgExperienceRestored() + .replace("%PLAYER%", playerName) + .replace("%ADMIN%", adminName) + .replace("%LEVEL%", String.valueOf(level)) + .replace("%TIME%", timestamp); + title = null; + description = null; + } + + sendWebhookMessage(EventType.EXPERIENCE_RESTORED, message, title, description); + } + + /** + * Sends a Discord webhook message for a player death event + */ + public static void sendPlayerDeath(String playerName, Location location, String deathCause, String timestamp) { + if (!ConfigData.isDiscordEnabled() || !ConfigData.isDiscordPlayerDeath()) { + return; + } + + String worldName = location.getWorld() != null ? location.getWorld().getName() : "unknown"; + String x = String.valueOf((int) location.getX()); + String y = String.valueOf((int) location.getY()); + String z = String.valueOf((int) location.getZ()); + + String message, title, description; + if (ConfigData.isDiscordUseEmbeds()) { + title = MessageData.getDiscordTitlePlayerDeath(); + description = MessageData.getDiscordDescPlayerDeath() + .replace("%PLAYER%", playerName) + .replace("%WORLD%", worldName) + .replace("%X%", x) + .replace("%Y%", y) + .replace("%Z%", z) + .replace("%CAUSE%", deathCause) + .replace("%TIME%", timestamp); + message = null; + } else { + message = MessageData.getDiscordMsgPlayerDeath() + .replace("%PLAYER%", playerName) + .replace("%WORLD%", worldName) + .replace("%X%", x) + .replace("%Y%", y) + .replace("%Z%", z) + .replace("%CAUSE%", deathCause) + .replace("%TIME%", timestamp); + title = null; + description = null; + } + + sendWebhookMessage(EventType.PLAYER_DEATH, message, title, description); + } + + /** + * Sends a Discord webhook message for a force backup event + */ + public static void sendForceBackup(String playerName, String adminName, String timestamp) { + if (!ConfigData.isDiscordEnabled() || !ConfigData.isDiscordForceBackup()) { + return; + } + + String message, title, description; + if (ConfigData.isDiscordUseEmbeds()) { + title = MessageData.getDiscordTitleForceBackup(); + description = MessageData.getDiscordDescForceBackup() + .replace("%PLAYER%", playerName) + .replace("%ADMIN%", adminName) + .replace("%TIME%", timestamp); + message = null; + } else { + message = MessageData.getDiscordMsgForceBackup() + .replace("%PLAYER%", playerName) + .replace("%ADMIN%", adminName) + .replace("%TIME%", timestamp); + title = null; + description = null; + } + + sendWebhookMessage(EventType.FORCE_BACKUP, message, title, description); + } + + /** + * Sends the actual webhook message to Discord + */ + private static void sendWebhookMessage(EventType eventType, String message, String title, String description) { + String webhookUrl = ConfigData.getDiscordWebhookUrl(); + + if (webhookUrl == null || webhookUrl.isEmpty()) { + if (ConfigData.isDebugEnabled()) { + InventoryRollback.getInstance().getLogger().warning("Discord webhook URL is not configured"); + } + return; + } + + // Validate webhook URL + if (!isValidWebhookUrl(webhookUrl)) { + InventoryRollback.getInstance().getLogger().warning(MessageData.getDiscordErrorInvalidWebhook()); + return; + } + + // Send webhook asynchronously to avoid blocking the main thread + CompletableFuture.runAsync(() -> { + try { + String jsonPayload = buildJsonPayload(eventType, message, title, description); + sendHttpRequest(webhookUrl, jsonPayload); + + if (ConfigData.isDebugEnabled()) { + InventoryRollback.getInstance().getLogger().info("Discord webhook sent successfully for event: " + eventType); + } + } catch (Exception e) { + InventoryRollback.getInstance().getLogger().log(Level.WARNING, MessageData.getDiscordErrorWebhookFailed() + ": " + e.getMessage(), e); + } + }); + } + + /** + * Builds the JSON payload for the Discord webhook + */ + private static String buildJsonPayload(EventType eventType, String message, String title, String description) { + StringBuilder json = new StringBuilder(); + json.append("{"); + + // Add server name if enabled + if (ConfigData.isDiscordIncludeServerName()) { + json.append("\"username\":\"").append(escapeJson(ConfigData.getDiscordServerName())).append("\","); + } + + if (ConfigData.isDiscordUseEmbeds() && title != null && description != null) { + // Use embeds - Discord embeds support newlines differently + json.append("\"embeds\":[{"); + json.append("\"title\":\"").append(escapeJson(title)).append("\","); + json.append("\"description\":\"").append(escapeJsonForEmbed(description)).append("\","); + json.append("\"color\":").append(getColorForEventType(eventType)).append(","); + json.append("\"timestamp\":\"").append(java.time.Instant.now().toString()).append("\""); + json.append("}]"); + } else { + // Use simple content message - Discord content supports newlines natively + json.append("\"content\":\"").append(escapeJsonForContent(message != null ? message : "Unknown event")).append("\""); + } + + json.append("}"); + return json.toString(); + } + + /** + * Gets the color code for the event type + */ + private static int getColorForEventType(EventType eventType) { + String colorHex; + switch (eventType) { + case BACKUP_CREATED: + case FORCE_BACKUP: + colorHex = ConfigData.getDiscordColorBackup(); + break; + case INVENTORY_RESTORED: + case ENDER_CHEST_RESTORED: + case HEALTH_RESTORED: + case HUNGER_RESTORED: + case EXPERIENCE_RESTORED: + colorHex = ConfigData.getDiscordColorRestore(); + break; + case PLAYER_DEATH: + colorHex = ConfigData.getDiscordColorDeath(); + break; + default: + colorHex = ConfigData.getDiscordColorWarning(); + break; + } + + // Convert hex color to decimal + try { + if (colorHex.startsWith("#")) { + colorHex = colorHex.substring(1); + } + return Integer.parseInt(colorHex, 16); + } catch (NumberFormatException e) { + return 0x0099ff; // Default blue color + } + } + + /** + * Sends the HTTP request to Discord + */ + private static void sendHttpRequest(String webhookUrl, String jsonPayload) throws IOException { + // Log the JSON payload for debugging when debug is enabled + if (ConfigData.isDebugEnabled()) { + InventoryRollback.getInstance().getLogger().info("Sending Discord webhook payload: " + jsonPayload); + } + + URL url = new URL(webhookUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); + connection.setRequestProperty("User-Agent", "InventoryRollbackPlus/1.7.6"); + connection.setDoOutput(true); + + try (OutputStream outputStream = connection.getOutputStream()) { + byte[] input = jsonPayload.getBytes(StandardCharsets.UTF_8); + outputStream.write(input, 0, input.length); + } + + int responseCode = connection.getResponseCode(); + if (responseCode < 200 || responseCode >= 300) { + // Try to read error response for debugging + String errorResponse = ""; + try { + if (connection.getErrorStream() != null) { + java.util.Scanner scanner = new java.util.Scanner(connection.getErrorStream()).useDelimiter("\\A"); + errorResponse = scanner.hasNext() ? scanner.next() : ""; + } + } catch (Exception e) { + // Ignore error reading response + } + + String errorMessage = "Discord webhook returned HTTP " + responseCode; + if (!errorResponse.isEmpty() && ConfigData.isDebugEnabled()) { + errorMessage += " - Response: " + errorResponse; + } + throw new IOException(errorMessage); + } + } + + /** + * Validates if the webhook URL is a valid Discord webhook URL + */ + private static boolean isValidWebhookUrl(String url) { + try { + new URL(url); + return url.contains("discord.com/api/webhooks/") || url.contains("discordapp.com/api/webhooks/"); + } catch (MalformedURLException e) { + return false; + } + } + + /** + * Escapes JSON special characters + */ + private static String escapeJson(String text) { + if (text == null) return ""; + return text.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\r", "") // Remove carriage returns + .replace("\t", " "); // Replace tabs with spaces, keep newlines as-is + } + + /** + * Escapes JSON special characters for content messages + * Content messages support newlines natively but need proper JSON escaping + */ + private static String escapeJsonForContent(String text) { + if (text == null) return ""; + return text.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\b", "\\b") + .replace("\f", "\\f") + .replace("\n", "\\n") // Properly escape newlines for JSON + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + /** + * Escapes JSON special characters for embed descriptions + * Embed descriptions support newlines but need proper JSON escaping + */ + private static String escapeJsonForEmbed(String text) { + if (text == null) return ""; + return text.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\b", "\\b") + .replace("\f", "\\f") + .replace("\n", "\\n") // Properly escape newlines for JSON + .replace("\r", "\\r") + .replace("\t", "\\t"); + } +} diff --git a/src/main/java/me/danjono/inventoryrollback/listeners/ClickGUI.java b/src/main/java/me/danjono/inventoryrollback/listeners/ClickGUI.java index 2480efc..3366ac0 100644 --- a/src/main/java/me/danjono/inventoryrollback/listeners/ClickGUI.java +++ b/src/main/java/me/danjono/inventoryrollback/listeners/ClickGUI.java @@ -10,6 +10,7 @@ import me.danjono.inventoryrollback.config.SoundData; import me.danjono.inventoryrollback.data.LogType; import me.danjono.inventoryrollback.data.PlayerData; +import me.danjono.inventoryrollback.discord.DiscordWebhook; import me.danjono.inventoryrollback.gui.Buttons; import me.danjono.inventoryrollback.gui.InventoryName; import me.danjono.inventoryrollback.gui.menu.*; @@ -338,6 +339,16 @@ public void run() { player.sendMessage(MessageData.getPluginPrefix() + MessageData.getMainInventoryRestoredPlayer(staff.getName())); if (!staff.getUniqueId().equals(player.getUniqueId())) staff.sendMessage(MessageData.getPluginPrefix() + MessageData.getMainInventoryRestored(offlinePlayer.getName())); + + // Send Discord webhook for inventory restoration + try { + String timestamp = ConfigData.getTimeFormat().format(System.currentTimeMillis()); + DiscordWebhook.sendInventoryRestored(offlinePlayer.getName(), staff.getName(), timestamp); + } catch (Exception ex) { + if (ConfigData.isDebugEnabled()) { + InventoryRollback.getInstance().getLogger().warning("Failed to send Discord webhook for inventory restore: " + ex.getMessage()); + } + } } }.runTaskAsynchronously(main); @@ -440,6 +451,16 @@ else if (icon.getType().equals(Buttons.getHealthIcon())) { player.sendMessage(MessageData.getPluginPrefix() + MessageData.getHealthRestoredPlayer(staff.getName())); if (!staff.getUniqueId().equals(player.getUniqueId())) staff.sendMessage(MessageData.getPluginPrefix() + MessageData.getHealthRestored(player.getName())); + + // Send Discord webhook for health restoration + try { + String healthTimestamp = ConfigData.getTimeFormat().format(System.currentTimeMillis()); + DiscordWebhook.sendHealthRestored(offlinePlayer.getName(), staff.getName(), health, healthTimestamp); + } catch (Exception ex) { + if (ConfigData.isDebugEnabled()) { + InventoryRollback.getInstance().getLogger().warning("Failed to send Discord webhook for health restore: " + ex.getMessage()); + } + } } else { staff.sendMessage(MessageData.getPluginPrefix() + MessageData.getHealthNotOnline(offlinePlayer.getName())); } @@ -467,6 +488,16 @@ else if (icon.getType().equals(Buttons.getHungerIcon())) { player.sendMessage(MessageData.getPluginPrefix() + MessageData.getHungerRestoredPlayer(staff.getName())); if (!staff.getUniqueId().equals(player.getUniqueId())) staff.sendMessage(MessageData.getPluginPrefix() + MessageData.getHungerRestored(player.getName())); + + // Send Discord webhook for hunger restoration + try { + String hungerTimestamp = ConfigData.getTimeFormat().format(System.currentTimeMillis()); + DiscordWebhook.sendHungerRestored(offlinePlayer.getName(), staff.getName(), hunger, hungerTimestamp); + } catch (Exception ex) { + if (ConfigData.isDebugEnabled()) { + InventoryRollback.getInstance().getLogger().warning("Failed to send Discord webhook for hunger restore: " + ex.getMessage()); + } + } } else { staff.sendMessage(MessageData.getPluginPrefix() + MessageData.getHungerNotOnline(offlinePlayer.getName())); } @@ -493,7 +524,17 @@ else if (icon.getType().equals(Buttons.getExperienceIcon())) { player.sendMessage(MessageData.getPluginPrefix() + MessageData.getExperienceRestoredPlayer(staff.getName(), level)); if (!staff.getUniqueId().equals(player.getUniqueId())) staff.sendMessage(MessageData.getPluginPrefix() + MessageData.getExperienceRestored(player.getName(), level)); - } else { + + // Send Discord webhook for experience restoration + try { + String experienceTimestamp = ConfigData.getTimeFormat().format(System.currentTimeMillis()); + DiscordWebhook.sendExperienceRestored(offlinePlayer.getName(), staff.getName(), level, experienceTimestamp); + } catch (Exception ex) { + if (ConfigData.isDebugEnabled()) { + InventoryRollback.getInstance().getLogger().warning("Failed to send Discord webhook for experience restore: " + ex.getMessage()); + } + } + } else { staff.sendMessage(MessageData.getPluginPrefix() + MessageData.getExperienceNotOnlinePlayer(offlinePlayer.getName())); } } @@ -692,4 +733,4 @@ public void run() { } } -} \ No newline at end of file +} diff --git a/src/main/java/me/danjono/inventoryrollback/listeners/EventLogs.java b/src/main/java/me/danjono/inventoryrollback/listeners/EventLogs.java index 170ddc2..e8dc565 100644 --- a/src/main/java/me/danjono/inventoryrollback/listeners/EventLogs.java +++ b/src/main/java/me/danjono/inventoryrollback/listeners/EventLogs.java @@ -4,6 +4,7 @@ import com.tcoded.lightlibs.bukkitversion.BukkitVersion; import me.danjono.inventoryrollback.config.ConfigData; import me.danjono.inventoryrollback.data.LogType; +import me.danjono.inventoryrollback.discord.DiscordWebhook; import me.danjono.inventoryrollback.inventory.SaveInventory; import org.bukkit.entity.Entity; import org.bukkit.entity.LivingEntity; @@ -71,6 +72,16 @@ private void playerJoin(PlayerJoinEvent e) { if (player.hasPermission("inventoryrollbackplus.joinsave")) { new SaveInventory(e.getPlayer(), LogType.JOIN, null, null) .snapshotAndSave(player.getInventory(), player.getEnderChest(), true); + + // Send Discord webhook for backup creation + try { + String timestamp = ConfigData.getTimeFormat().format(System.currentTimeMillis()); + DiscordWebhook.sendBackupCreated(player.getName(), "JOIN", timestamp); + } catch (Exception ex) { + if (ConfigData.isDebugEnabled()) { + main.getLogger().warning("Failed to send Discord webhook for join backup: " + ex.getMessage()); + } + } } if (player.hasPermission("inventoryrollbackplus.adminalerts")) { // can send info to admins here @@ -86,6 +97,16 @@ private void playerQuit(PlayerQuitEvent e) { if (player.hasPermission("inventoryrollbackplus.leavesave")) { new SaveInventory(e.getPlayer(), LogType.QUIT, null, null) .snapshotAndSave(player.getInventory(), player.getEnderChest(), true); + + // Send Discord webhook for backup creation + try { + String timestamp = ConfigData.getTimeFormat().format(System.currentTimeMillis()); + DiscordWebhook.sendBackupCreated(player.getName(), "QUIT", timestamp); + } catch (Exception ex) { + if (ConfigData.isDebugEnabled()) { + main.getLogger().warning("Failed to send Discord webhook for quit backup: " + ex.getMessage()); + } + } } UUID uuid = player.getUniqueId(); @@ -214,6 +235,22 @@ public void playerDeathHandle(PlayerDeathEvent event) { // Remove the snapshot from the cache this.inventoryCache.remove(uuid); } + + // Send Discord webhook for player death + try { + String deathCause = detailedReason.reason != null ? detailedReason.reason : detailedReason.damageCause.name(); + String timestamp = ConfigData.getTimeFormat().format(System.currentTimeMillis()); + DiscordWebhook.sendPlayerDeath( + player.getName(), + player.getLocation(), + deathCause, + timestamp + ); + } catch (Exception e) { + if (ConfigData.isDebugEnabled()) { + main.getLogger().warning("Failed to send Discord webhook for player death: " + e.getMessage()); + } + } } } @@ -226,6 +263,16 @@ private void playerChangeWorld(PlayerChangedWorldEvent e) { if (player.hasPermission("inventoryrollbackplus.worldchangesave")) { new SaveInventory(e.getPlayer(), LogType.WORLD_CHANGE, null, null) .snapshotAndSave(player.getInventory(), player.getEnderChest(), true); + + // Send Discord webhook for backup creation + try { + String timestamp = ConfigData.getTimeFormat().format(System.currentTimeMillis()); + DiscordWebhook.sendBackupCreated(player.getName(), "WORLD_CHANGE", timestamp); + } catch (Exception ex) { + if (ConfigData.isDebugEnabled()) { + main.getLogger().warning("Failed to send Discord webhook for world change backup: " + ex.getMessage()); + } + } } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 82f82fc..3c13b6d 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -30,6 +30,35 @@ mysql: verifyCertificate: true allowPubKeyRetrieval: false +## Discord webhook logging configuration +## Allows sending backup and restore events to a Discord channel via webhook +discord: + enabled: false + webhook-url: '' + # Events to log to Discord (set to false to disable specific events) + events: + backup-created: true + inventory-restored: true + ender-chest-restored: true + health-restored: true + hunger-restored: true + experience-restored: true + player-death: true + force-backup: true + # Additional settings + settings: + # Include server name in Discord messages + include-server-name: true + server-name: 'My Server' + # Use embeds for richer formatting + use-embeds: true + # Color for different event types (hex colors) + colors: + backup: '#00ff00' # Green for backups + restore: '#0099ff' # Blue for restores + death: '#ff3300' # Red for deaths + warning: '#ffcc00' # Yellow for warnings + ## Sounds will play to the player when parts of their player is restored. sounds: teleport: diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index ab8c408..7d04647 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -17,6 +17,47 @@ backup: force-saved-all: 'All online player inventories have been force saved.' not-forced-saved: 'There was an issue with saving %NAME%''s inventory.' +# Discord webhook messages +discord: + # Event titles for Discord embeds + titles: + backup-created: '📦 Backup Created' + inventory-restored: '🎒 Inventory Restored' + ender-chest-restored: '📦 Ender Chest Restored' + health-restored: '❤️ Health Restored' + hunger-restored: '🍖 Hunger Restored' + experience-restored: '✨ Experience Restored' + player-death: '💀 Player Death' + force-backup: '🔧 Force Backup' + + # Event descriptions for Discord embeds + descriptions: + backup-created: 'Player **%PLAYER%** backup created\n**Type:** %TYPE%\n**Time:** %TIME%' + inventory-restored: 'Player **%PLAYER%** inventory restored by **%ADMIN%**\n**From backup:** %TIME%' + ender-chest-restored: 'Player **%PLAYER%** ender chest restored by **%ADMIN%**\n**From backup:** %TIME%' + health-restored: 'Player **%PLAYER%** health restored by **%ADMIN%**\n**Health:** %HEALTH%\n**From backup:** %TIME%' + hunger-restored: 'Player **%PLAYER%** hunger restored by **%ADMIN%**\n**Hunger:** %HUNGER%\n**From backup:** %TIME%' + experience-restored: 'Player **%PLAYER%** experience restored by **%ADMIN%**\n**Level:** %LEVEL%\n**From backup:** %TIME%' + player-death: 'Player **%PLAYER%** died\n**Location:** %WORLD% (%X%, %Y%, %Z%)\n**Cause:** %CAUSE%\n**Time:** %TIME%' + force-backup: 'Force backup created for **%PLAYER%** by **%ADMIN%**\n**Time:** %TIME%' + + # Simple text messages (when embeds are disabled) + messages: + backup-created: '📦 Backup created for %PLAYER% (%TYPE%) at %TIME%' + inventory-restored: '🎒 %PLAYER% inventory restored by %ADMIN% from backup %TIME%' + ender-chest-restored: '📦 %PLAYER% ender chest restored by %ADMIN% from backup %TIME%' + health-restored: '❤️ %PLAYER% health restored by %ADMIN% (Health: %HEALTH%) from backup %TIME%' + hunger-restored: '🍖 %PLAYER% hunger restored by %ADMIN% (Hunger: %HUNGER%) from backup %TIME%' + experience-restored: '✨ %PLAYER% experience restored by %ADMIN% (Level: %LEVEL%) from backup %TIME%' + player-death: '💀 %PLAYER% died at %WORLD% (%X%, %Y%, %Z%) - %CAUSE% at %TIME%' + force-backup: '🔧 Force backup created for %PLAYER% by %ADMIN% at %TIME%' + + # Error messages + errors: + webhook-failed: '&cFailed to send Discord webhook message' + invalid-webhook: '&cInvalid Discord webhook URL configured' + connection-failed: '&cFailed to connect to Discord webhook' + attribute-restore: main-inventory: restored: '%NAME%''s main inventory has been restored.'