getArguments() {
+ // Should return a list of arguments that will be used in your command.
+ // If you don't want any arguments, you can return null here.
+ return List.of(
+ new InteractionCommandArgument(
+ // This should be the name of the command argument.
+ // Keep it a single world, all lower case.
+ "player",
+ // This is the description of the argument.
+ "The player to check the balance of",
+ // This is the type of the argument you'd like to receive from
+ // discord.
+ InteractionCommandArgumentType.STRING,
+ // Should be set to true if the argument is required to send
+ // the command from discord.
+ true));
+ }
+
+ @Override
+ public boolean isEphemeral() {
+ // Whether or not the command and response should be hidden to other users on discord.
+ // Return true here in order to hide command/responses from other discord users.
+ return false;
+ }
+
+ @Override
+ public boolean isDisabled() {
+ // Whether or not the command should be prevented from being registered/executed.
+ // Return true here in order to mark the command as disabled.
+ return false;
+ }
+}
+```
+
+Once you have created your slash command, it's now time to register it. It is best
+practice to register them in your plugin's `onEnable` so your commands make it in the
+initial batch of commands sent to Discord.
+
+You can register your command with EssentialsX Discord by doing the following:
+```java
+...
+import net.essentialsx.api.v2.services.discord.DiscordService;
+...
+
+public class MyEconomyPlugin {
+ @Override
+ public void onEnable() {
+ final DiscordService api = Bukkit.getServicesManager().load(DiscordService.class);
+ api.getInteractionController().registerCommand(new BalanceSlashCommand());
+ }
+}
+```
+
+---
diff --git a/EssentialsDiscord/build.gradle b/EssentialsDiscord/build.gradle
new file mode 100644
index 000000000..1eabe785d
--- /dev/null
+++ b/EssentialsDiscord/build.gradle
@@ -0,0 +1,58 @@
+plugins {
+ id("essentials.shadow-module")
+}
+
+dependencies {
+ compileOnly project(':EssentialsX')
+ implementation('net.dv8tion:JDA:4.3.0_277') {
+ //noinspection GroovyAssignabilityCheck
+ exclude module: 'opus-java'
+ }
+ implementation 'com.vdurmont:emoji-java:5.1.1'
+ implementation 'club.minnced:discord-webhooks:0.5.6'
+ compileOnly 'org.apache.logging.log4j:log4j-core:2.0-beta9'
+ compileOnly 'me.clip:placeholderapi:2.10.9'
+}
+
+shadowJar {
+ dependencies {
+ // JDA
+ include(dependency('net.dv8tion:JDA'))
+ include(dependency('com.neovisionaries:nv-websocket-client'))
+ include(dependency('com.squareup.okhttp3:okhttp'))
+ include(dependency('com.squareup.okio:okio'))
+ include(dependency('org.apache.commons:commons-collections4'))
+ include(dependency('net.sf.trove4j:trove4j'))
+ include(dependency('com.fasterxml.jackson.core:jackson-databind'))
+ include(dependency('com.fasterxml.jackson.core:jackson-core'))
+ include(dependency('com.fasterxml.jackson.core:jackson-annotations'))
+ include(dependency('org.slf4j:slf4j-api'))
+
+ // Emoji
+ include(dependency('com.vdurmont:emoji-java'))
+ include(dependency('org.json:json'))
+
+ // discord-webhooks
+ include(dependency('club.minnced:discord-webhooks'))
+ }
+ minimize()
+
+ // JDA
+ relocate 'net.dv8tion.jda', 'net.essentialsx.dep.net.dv8tion.jda'
+ relocate 'com.neovisionaries.ws', 'net.essentialsx.dep.com.neovisionaries.ws'
+ relocate 'okhttp3', 'net.essentialsx.dep.okhttp3'
+ relocate 'okio', 'net.essentialsx.dep.okio'
+ relocate 'com.iwebpp.crypto', 'net.essentialsx.dep.com.iwebpp.crypto'
+ relocate 'org.apache.commons.collections4', 'net.essentialsx.dep.org.apache.commons.collections4'
+ relocate 'com.fasterxml.jackson.databind', 'net.essentialsx.dep.com.fasterxml.jackson.databind'
+ relocate 'com.fasterxml.jackson.core', 'net.essentialsx.dep.com.fasterxml.jackson.core'
+ relocate 'com.fasterxml.jackson.annotation', 'net.essentialsx.dep.com.fasterxml.jackson.annotation'
+ relocate 'gnu.trove', 'net.essentialsx.dep.gnu.trove'
+
+ // Emoji
+ relocate 'com.vdurmont.emoji', 'net.essentialsx.dep.com.vdurmont.emoji'
+ relocate 'org.json', 'net.essentialsx.dep.org.json'
+
+ // discord-webhooks
+ relocate 'club.minnced.discord.webhook', 'net.essentialsx.dep.club.minnced.discord.webhook'
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/events/discord/DiscordChatMessageEvent.java b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/events/discord/DiscordChatMessageEvent.java
new file mode 100644
index 000000000..a0a86ace0
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/events/discord/DiscordChatMessageEvent.java
@@ -0,0 +1,72 @@
+package net.essentialsx.api.v2.events.discord;
+
+import org.bukkit.entity.Player;
+import org.bukkit.event.Cancellable;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Fired before a chat message is about to be sent to a Discord channel.
+ * Should be used to block chat messages (such as staff channels) from appearing in Discord.
+ */
+public class DiscordChatMessageEvent extends Event implements Cancellable {
+ private static final HandlerList handlers = new HandlerList();
+
+ private final Player player;
+ private String message;
+ private boolean cancelled = false;
+
+ /**
+ * @param player The player which caused this event.
+ * @param message The message of this event.
+ */
+ public DiscordChatMessageEvent(Player player, String message) {
+ this.player = player;
+ this.message = message;
+ }
+
+ /**
+ * The player which which caused this chat message.
+ * @return the player who caused the event.
+ */
+ public Player getPlayer() {
+ return player;
+ }
+
+ /**
+ * The message being sent in this chat event.
+ * @return the message of this event.
+ */
+ public String getMessage() {
+ return message;
+ }
+
+ /**
+ * Sets the message of this event, and thus the chat message relayed to Discord.
+ * @param message the new message.
+ */
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return cancelled;
+ }
+
+ @Override
+ public void setCancelled(boolean cancel) {
+ this.cancelled = cancel;
+ }
+
+ @NotNull
+ @Override
+ public HandlerList getHandlers() {
+ return handlers;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlers;
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/events/discord/DiscordMessageEvent.java b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/events/discord/DiscordMessageEvent.java
new file mode 100644
index 000000000..23996efff
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/events/discord/DiscordMessageEvent.java
@@ -0,0 +1,157 @@
+package net.essentialsx.api.v2.events.discord;
+
+import net.essentialsx.api.v2.services.discord.MessageType;
+import org.bukkit.event.Cancellable;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.UUID;
+
+/**
+ * Fired before a message is about to be sent to a Discord channel.
+ */
+public class DiscordMessageEvent extends Event implements Cancellable {
+ private static final HandlerList handlers = new HandlerList();
+
+ private boolean cancelled = false;
+ private MessageType type;
+ private String message;
+ private boolean allowGroupMentions;
+ private String avatarUrl;
+ private String name;
+ private final UUID uuid;
+
+ /**
+ * @param type The message type/destination of this event.
+ * @param message The raw message content of this event.
+ * @param allowGroupMentions Whether or not the message should allow the pinging of roles, users, or emotes.
+ */
+ public DiscordMessageEvent(final MessageType type, final String message, final boolean allowGroupMentions) {
+ this(type, message, allowGroupMentions, null, null, null);
+ }
+
+ /**
+ * @param type The message type/destination of this event.
+ * @param message The raw message content of this event.
+ * @param allowGroupMentions Whether or not the message should allow the pinging of roles, users, or emotes.
+ * @param avatarUrl The avatar URL to use for this message (if supported) or null to use the default bot avatar.
+ * @param name The name to use for this message (if supported) or null to use the default bot name.
+ * @param uuid The UUID of the player which caused this event or null if this wasn't a player triggered event.
+ */
+ public DiscordMessageEvent(final MessageType type, final String message, final boolean allowGroupMentions, final String avatarUrl, final String name, final UUID uuid) {
+ this.type = type;
+ this.message = message;
+ this.allowGroupMentions = allowGroupMentions;
+ this.avatarUrl = avatarUrl;
+ this.name = name;
+ this.uuid = uuid;
+ }
+
+ /**
+ * Gets the type of this message. This also defines its destination.
+ * @return The message type.
+ */
+ public MessageType getType() {
+ return type;
+ }
+
+ /**
+ * Sets the message type and therefore its destination.
+ * @param type The new message type.
+ */
+ public void setType(MessageType type) {
+ this.type = type;
+ }
+
+ /**
+ * Gets the raw message content that is about to be sent to Discord.
+ * @return The raw message.
+ */
+ public String getMessage() {
+ return message;
+ }
+
+ /**
+ * Sets the raw message content to be sent to Discord.
+ * @param message The new message content.
+ */
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ /**
+ * Checks if this message allows pinging of roles/@here/@everyone.
+ * @return true if this message is allowed to ping of roles/@here/@everyone.
+ */
+ public boolean isAllowGroupMentions() {
+ return allowGroupMentions;
+ }
+
+ /**
+ * Sets if this message is allowed to ping roles/@here/@everyone.
+ * @param allowGroupMentions If pinging of roles/@here/@everyone should be allowed.
+ */
+ public void setAllowGroupMentions(boolean allowGroupMentions) {
+ this.allowGroupMentions = allowGroupMentions;
+ }
+
+ /**
+ * Gets the avatar URL to use for this message, or null if none is specified.
+ * @return The avatar URL or null.
+ */
+ public String getAvatarUrl() {
+ return avatarUrl;
+ }
+
+ /**
+ * Sets the avatar URL for this message, or null to use the bot's avatar.
+ * @param avatarUrl The avatar URL or null.
+ */
+ public void setAvatarUrl(String avatarUrl) {
+ this.avatarUrl = avatarUrl;
+ }
+
+ /**
+ * Gets the name to use for this message, or null if none is specified.
+ * @return The name or null.
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Sets the name for this message, or null to use the bot's name.
+ * @param name The name or null.
+ */
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ /**
+ * Gets the UUID of the player which caused this event, or null if it wasn't a player triggered event.
+ * @return The UUID or null.
+ */
+ public UUID getUUID() {
+ return uuid;
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return cancelled;
+ }
+
+ @Override
+ public void setCancelled(boolean cancelled) {
+ this.cancelled = cancelled;
+ }
+
+ @Override
+ public @NotNull HandlerList getHandlers() {
+ return handlers;
+ }
+
+ public static HandlerList getHandlerList() {
+ return handlers;
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/DiscordService.java b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/DiscordService.java
new file mode 100644
index 000000000..8a86994a8
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/DiscordService.java
@@ -0,0 +1,44 @@
+package net.essentialsx.api.v2.services.discord;
+
+import org.bukkit.plugin.Plugin;
+
+/**
+ * A class which provides numerous methods to interact with EssentialsX Discord.
+ */
+public interface DiscordService {
+ /**
+ * Sends a message to a message type channel.
+ * @param type The message type/destination of this message.
+ * @param message The exact message to be sent.
+ * @param allowGroupMentions Whether or not the message should allow the pinging of roles, users, or emotes.
+ */
+ void sendMessage(final MessageType type, final String message, final boolean allowGroupMentions);
+
+ /**
+ * Checks if a {@link MessageType} by the given key is already registered.
+ * @param key The {@link MessageType} key to check.
+ * @return true if a {@link MessageType} with the provided key is registered, otherwise false.
+ */
+ boolean isRegistered(final String key);
+
+ /**
+ * Registers a message type to be used in the future.
+ *
+ * In the future, this method will automatically populate the message type in the EssentialsX Discord config.
+ * @param type The {@link MessageType} to be registered.
+ */
+ void registerMessageType(final Plugin plugin, final MessageType type);
+
+ /**
+ * Gets the {@link InteractionController} instance.
+ * @return the {@link InteractionController} instance.
+ */
+ InteractionController getInteractionController();
+
+ /**
+ * Gets unstable API that is subject to change at any time.
+ * @return {@link Unsafe the unsafe} instance.
+ * @see Unsafe
+ */
+ Unsafe getUnsafe();
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionChannel.java b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionChannel.java
new file mode 100644
index 000000000..01cbe81db
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionChannel.java
@@ -0,0 +1,18 @@
+package net.essentialsx.api.v2.services.discord;
+
+/**
+ * Represents a interaction channel argument as a guild channel.
+ */
+public interface InteractionChannel {
+ /**
+ * Gets the name of this channel.
+ * @return this channel's name.
+ */
+ String getName();
+
+ /**
+ * Gets the ID of this channel.
+ * @return this channel's ID.
+ */
+ String getId();
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionCommand.java b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionCommand.java
new file mode 100644
index 000000000..b082f9ba5
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionCommand.java
@@ -0,0 +1,46 @@
+package net.essentialsx.api.v2.services.discord;
+
+import java.util.List;
+
+/**
+ * Represents a command to be registered with the Discord client.
+ */
+public interface InteractionCommand {
+ /**
+ * Whether or not the command has been disabled and should not be registered at the request of the user.
+ * @return true if the command has been disabled.
+ */
+ boolean isDisabled();
+
+ /**
+ * Whether or not the command is ephemeral and if its usage/replies should be private for the user on in Discord client.
+ * @return true if the command is ephemeral.
+ */
+ boolean isEphemeral();
+
+ /**
+ * Gets the name of this command as it appears in Discord.
+ * @return the name of the command.
+ */
+ String getName();
+
+ /**
+ * Gets the brief description of the command as it appears in Discord.
+ * @return the description of the command.
+ */
+ String getDescription();
+
+ /**
+ * Gets the list of arguments registered to this command.
+ *
+ * Note: Arguments can only be registered before the command itself is registered, others will be ignored.
+ * @return the list of arguments.
+ */
+ List getArguments();
+
+ /**
+ * Called when an interaction command is received from Discord.
+ * @param event The {@link InteractionEvent} which caused this command to be executed.
+ */
+ void onCommand(InteractionEvent event);
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionCommandArgument.java b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionCommandArgument.java
new file mode 100644
index 000000000..35c460f24
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionCommandArgument.java
@@ -0,0 +1,57 @@
+package net.essentialsx.api.v2.services.discord;
+
+/**
+ * Represents an argument for a command to be shown to Discord users.
+ */
+public class InteractionCommandArgument {
+ private final String name;
+ private final String description;
+ private final InteractionCommandArgumentType type;
+ private final boolean required;
+
+ /**
+ * Builds a command argument.
+ * @param name The name of the argument to be shown to the Discord client.
+ * @param description A brief description of the argument to be shown to the Discord client.
+ * @param type The type of argument.
+ * @param required Whether or not the argument is required in order to send the command in the Discord client.
+ */
+ public InteractionCommandArgument(String name, String description, InteractionCommandArgumentType type, boolean required) {
+ this.name = name;
+ this.description = description;
+ this.type = type;
+ this.required = required;
+ }
+
+ /**
+ * Gets the name of this argument.
+ * @return the name of the argument.
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Gets the description of this argument.
+ * @return the description of the argument.
+ */
+ public String getDescription() {
+ return description;
+ }
+
+ /**
+ * Gets the type of this argument.
+ * @return the argument type.
+ */
+ public InteractionCommandArgumentType getType() {
+ return type;
+ }
+
+ /**
+ * Whether or not this argument is required or not.
+ * @return true if the argument is required.
+ */
+ public boolean isRequired() {
+ return required;
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionCommandArgumentType.java b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionCommandArgumentType.java
new file mode 100644
index 000000000..1e5a17f9f
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionCommandArgumentType.java
@@ -0,0 +1,25 @@
+package net.essentialsx.api.v2.services.discord;
+
+/**
+ * Represents an argument type to be shown on the Discord client.
+ */
+public enum InteractionCommandArgumentType {
+ STRING(3),
+ INTEGER(4),
+ BOOLEAN(5),
+ USER(6),
+ CHANNEL(7);
+
+ private final int id;
+ InteractionCommandArgumentType(int id) {
+ this.id = id;
+ }
+
+ /**
+ * Gets the internal Discord ID for this argument type.
+ * @return the internal Discord ID.
+ */
+ public int getId() {
+ return id;
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionController.java b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionController.java
new file mode 100644
index 000000000..2412b032d
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionController.java
@@ -0,0 +1,20 @@
+package net.essentialsx.api.v2.services.discord;
+
+/**
+ * A class which provides numerous methods to interact with Discord slash commands.
+ */
+public interface InteractionController {
+ /**
+ * Gets the command with the given name or null if no command by that name exists.
+ * @param name The name of the command.
+ * @return The {@link InteractionCommand command} by the given name, or null.
+ */
+ InteractionCommand getCommand(String name);
+
+ /**
+ * Registers the given slash command with Discord.
+ * @param command The slash command to be registered.
+ * @throws InteractionException if a command with that name was already registered or if the given command was already registered.
+ */
+ void registerCommand(InteractionCommand command) throws InteractionException;
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionEvent.java b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionEvent.java
new file mode 100644
index 000000000..c4dbdbdc6
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionEvent.java
@@ -0,0 +1,59 @@
+package net.essentialsx.api.v2.services.discord;
+
+/**
+ * Represents a triggered interaction event.
+ */
+public interface InteractionEvent {
+ /**
+ * Appends the given string to the initial response message and creates one if it doesn't exist.
+ * @param message The message to append.
+ */
+ void reply(String message);
+
+ /**
+ * Gets the member which caused this event.
+ * @return the member which caused the event.
+ */
+ InteractionMember getMember();
+
+ /**
+ * Get the value of the argument matching the given key represented as a String, or null if no argument by that name is present.
+ * @param key The key of the argument to lookup.
+ * @return the string value or null.
+ */
+ String getStringArgument(String key);
+
+ /**
+ * Get the Long representation of the argument by the given key or null if none by that key is present.
+ * @param key The key of the argument to lookup.
+ * @return the long value or null
+ */
+ Long getIntegerArgument(String key);
+
+ /**
+ * Helper method to get the Boolean representation of the argument by the given key or null if none by that key is present.
+ * @param key The key of the argument to lookup.
+ * @return the boolean value or null
+ */
+ Boolean getBooleanArgument(String key);
+
+ /**
+ * Helper method to get the user representation of the argument by the given key or null if none by that key is present.
+ * @param key The key of the argument to lookup.
+ * @return the user value or null
+ */
+ InteractionMember getUserArgument(String key);
+
+ /**
+ * Helper method to get the channel representation of the argument by the given key or null if none by that key is present.
+ * @param key The key of the argument to lookup.
+ * @return the channel value or null
+ */
+ InteractionChannel getChannelArgument(String key);
+
+ /**
+ * Gets the channel ID where this interaction occurred.
+ * @return the channel ID.
+ */
+ String getChannelId();
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionException.java b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionException.java
new file mode 100644
index 000000000..34f9d70a2
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionException.java
@@ -0,0 +1,10 @@
+package net.essentialsx.api.v2.services.discord;
+
+/**
+ * Thrown when an error occurs during an operation dealing with Discord interactions.
+ */
+public class InteractionException extends Exception {
+ public InteractionException(String message) {
+ super(message);
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionMember.java b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionMember.java
new file mode 100644
index 000000000..afe741487
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/InteractionMember.java
@@ -0,0 +1,67 @@
+package net.essentialsx.api.v2.services.discord;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Represents the interaction command executor as a guild member.
+ */
+public interface InteractionMember {
+ /**
+ * Gets the username of this member.
+ * @return this member's username.
+ */
+ String getName();
+
+ /**
+ * Gets the four numbers after the {@code #} in the member's username.
+ * @return this member's discriminator.
+ */
+ String getDiscriminator();
+
+ /**
+ * Gets this member's name and discriminator split by a {@code #}.
+ * @return this member's tag.
+ */
+ default String getTag() {
+ return getName() + "#" + getDiscriminator();
+ }
+
+ /**
+ * Gets the nickname of this member or their username if they don't have one.
+ * @return this member's nickname or username if none is present.
+ */
+ String getEffectiveName();
+
+ /**
+ * Gets the nickname of this member or null if they do not have one.
+ * @return this member's nickname or null.
+ */
+ String getNickname();
+
+ /**
+ * Gets the ID of this member.
+ * @return this member's ID.
+ */
+ String getId();
+
+ /**
+ * Checks if this member has the administrator permission on Discord.
+ * @return true if this user has administrative permissions.
+ */
+ boolean isAdmin();
+
+ /**
+ * Returns true if the user has one of the specified roles.
+ * @param roleDefinitions A list of role definitions from the config.
+ * @return true if the member has one of the given roles.
+ */
+ boolean hasRoles(List roleDefinitions);
+
+ /**
+ * Sends a private message to this member with the given content.
+ * @param content The message to send.
+ * @return A future which will complete a boolean stating the success of the message.
+ */
+ CompletableFuture sendPrivateMessage(String content);
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/MessageType.java b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/MessageType.java
new file mode 100644
index 000000000..9c17c8655
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/MessageType.java
@@ -0,0 +1,73 @@
+package net.essentialsx.api.v2.services.discord;
+
+/**
+ * Indicates the type of message being sent and its literal channel name used in the config.
+ */
+public final class MessageType {
+ private final String key;
+ private final boolean player;
+
+ /**
+ * Creates a {@link MessageType} which will send channels to the specified channel key.
+ *
+ * The message type key may only contain: lowercase letters, numbers, and dashes.
+ * @param key The channel key defined in the {@code message-types} section of the config.
+ */
+ public MessageType(final String key) {
+ this(key, false);
+ }
+
+ /**
+ * Internal constructor used by EssentialsX Discord
+ */
+ private MessageType(String key, boolean player) {
+ if (!key.matches("^[a-z0-9-]*$")) {
+ throw new IllegalArgumentException("Key must match \"^[a-z0-9-]*$\"");
+ }
+ this.key = key;
+ this.player = player;
+ }
+
+ /**
+ * Gets the key used in {@code message-types} section of the config.
+ * @return The config key.
+ */
+ public String getKey() {
+ return key;
+ }
+
+ /**
+ * Checks if this message type should be beholden to player-specific config settings.
+ * @return true if message type should be beholden to player-specific config settings.
+ */
+ public boolean isPlayer() {
+ return player;
+ }
+
+ @Override
+ public String toString() {
+ return key;
+ }
+
+ /**
+ * Default {@link MessageType MessageTypes} provided and documented by EssentialsX Discord.
+ */
+ public static final class DefaultTypes {
+ public final static MessageType JOIN = new MessageType("join", true);
+ public final static MessageType LEAVE = new MessageType("leave", true);
+ public final static MessageType CHAT = new MessageType("chat", true);
+ public final static MessageType DEATH = new MessageType("death", true);
+ public final static MessageType AFK = new MessageType("afk", true);
+ public final static MessageType KICK = new MessageType("kick", false);
+ public final static MessageType MUTE = new MessageType("mute", false);
+ private final static MessageType[] VALUES = new MessageType[]{JOIN, LEAVE, CHAT, DEATH, AFK, KICK, MUTE};
+
+ /**
+ * Gets an array of all the default {@link MessageType MessageTypes}.
+ * @return An array of all the default {@link MessageType MessageTypes}.
+ */
+ public static MessageType[] values() {
+ return VALUES;
+ }
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/Unsafe.java b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/Unsafe.java
new file mode 100644
index 000000000..08aba8196
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/api/v2/services/discord/Unsafe.java
@@ -0,0 +1,15 @@
+package net.essentialsx.api.v2.services.discord;
+
+import net.dv8tion.jda.api.JDA;
+
+/**
+ * Unstable methods that may vary with our implementation.
+ * These methods have no guarantee of remaining consistent and may change at any time.
+ */
+public interface Unsafe {
+ /**
+ * Gets the JDA instance associated with this EssentialsX Discord instance, if available.
+ * @return the {@link JDA} instance or null if not ready.
+ */
+ JDA getJDAInstance();
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/DiscordSettings.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/DiscordSettings.java
new file mode 100644
index 000000000..78d3ad7fe
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/DiscordSettings.java
@@ -0,0 +1,354 @@
+package net.essentialsx.discord;
+
+import com.earth2me.essentials.IConf;
+import com.earth2me.essentials.config.ConfigurateUtil;
+import com.earth2me.essentials.config.EssentialsConfiguration;
+import com.earth2me.essentials.utils.FormatUtil;
+import net.dv8tion.jda.api.OnlineStatus;
+import net.dv8tion.jda.api.entities.Activity;
+import org.apache.logging.log4j.Level;
+import org.bukkit.entity.Player;
+
+import java.io.File;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+public class DiscordSettings implements IConf {
+ private final EssentialsConfiguration config;
+ private final EssentialsDiscord plugin;
+
+ private final Map nameToChannelIdMap = new HashMap<>();
+ private final Map> channelIdToNamesMap = new HashMap<>();
+
+ private OnlineStatus status;
+ private Activity statusActivity;
+
+ private Pattern discordFilter;
+
+ private MessageFormat consoleFormat;
+ private Level consoleLogLevel;
+
+ private MessageFormat discordToMcFormat;
+ private MessageFormat tempMuteFormat;
+ private MessageFormat tempMuteReasonFormat;
+ private MessageFormat permMuteFormat;
+ private MessageFormat permMuteReasonFormat;
+ private MessageFormat unmuteFormat;
+ private MessageFormat kickFormat;
+
+ public DiscordSettings(EssentialsDiscord plugin) {
+ this.plugin = plugin;
+ this.config = new EssentialsConfiguration(new File(plugin.getDataFolder(), "config.yml"), "/config.yml", EssentialsDiscord.class);
+ reloadConfig();
+ }
+
+ public String getBotToken() {
+ return config.getString("token", "");
+ }
+
+ public long getGuildId() {
+ return config.getLong("guild", 0);
+ }
+
+ public long getPrimaryChannelId() {
+ return config.getLong("channels.primary", 0);
+ }
+
+ public long getChannelId(String key) {
+ try {
+ return Long.parseLong(key);
+ } catch (NumberFormatException ignored) {
+ return nameToChannelIdMap.getOrDefault(key, 0L);
+ }
+ }
+
+ public List getKeysFromChannelId(long channelId) {
+ return channelIdToNamesMap.get(channelId);
+ }
+
+ public String getMessageChannel(String key) {
+ return config.getString("message-types." + key, "none");
+ }
+
+ public boolean isShowDiscordAttachments() {
+ return config.getBoolean("show-discord-attachments", true);
+ }
+
+ public List getPermittedFormattingRoles() {
+ return config.getList("permit-formatting-roles", String.class);
+ }
+
+ public OnlineStatus getStatus() {
+ return status;
+ }
+
+ public Activity getStatusActivity() {
+ return statusActivity;
+ }
+
+ public boolean isAlwaysReceivePrimary() {
+ return config.getBoolean("always-receive-primary", false);
+ }
+
+ public int getChatDiscordMaxLength() {
+ return config.getInt("chat.discord-max-length", 2000);
+ }
+
+ public boolean isChatFilterNewlines() {
+ return config.getBoolean("chat.filter-newlines", true);
+ }
+
+ public Pattern getDiscordFilter() {
+ return discordFilter;
+ }
+
+ public boolean isShowWebhookMessages() {
+ return config.getBoolean("chat.show-webhook-messages", false);
+ }
+
+ public boolean isShowBotMessages() {
+ return config.getBoolean("chat.show-bot-messages", false);
+ }
+
+ public boolean isShowAllChat() {
+ return config.getBoolean("chat.show-all-chat", false);
+ }
+
+ public String getConsoleChannelDef() {
+ return config.getString("console.channel", "none");
+ }
+
+ public MessageFormat getConsoleFormat() {
+ return consoleFormat;
+ }
+
+ public String getConsoleWebhookName() {
+ return config.getString("console.webhook-name", "EssentialsX Console Relay");
+ }
+
+ public boolean isConsoleCommandRelay() {
+ return config.getBoolean("console.command-relay", false);
+ }
+
+ public Level getConsoleLogLevel() {
+ return consoleLogLevel;
+ }
+
+ public boolean isShowAvatar() {
+ return config.getBoolean("show-avatar", false);
+ }
+
+ public boolean isShowName() {
+ return config.getBoolean("show-name", false);
+ }
+
+ // General command settings
+
+ public boolean isCommandEnabled(String command) {
+ return config.getBoolean("commands." + command + ".enabled", true);
+ }
+
+ public boolean isCommandEphemeral(String command) {
+ return config.getBoolean("commands." + command + ".hide-command", true);
+ }
+
+ public List getCommandSnowflakes(String command) {
+ return config.getList("commands." + command + ".allowed-roles", String.class);
+ }
+
+ public List getCommandAdminSnowflakes(String command) {
+ return config.getList("commands." + command + ".admin-roles", String.class);
+ }
+
+ // Message formats
+
+ public MessageFormat getDiscordToMcFormat() {
+ return discordToMcFormat;
+ }
+
+ public MessageFormat getMcToDiscordFormat(Player player) {
+ final String format = getFormatString("mc-to-discord");
+ final String filled;
+ if (plugin.isPAPI() && format != null) {
+ filled = me.clip.placeholderapi.PlaceholderAPI.setPlaceholders(player, format);
+ } else {
+ filled = format;
+ }
+ return generateMessageFormat(filled, "{displayname}: {message}", false,
+ "username", "displayname", "message", "world", "prefix", "suffix");
+ }
+
+ public MessageFormat getTempMuteFormat() {
+ return tempMuteFormat;
+ }
+
+ public MessageFormat getTempMuteReasonFormat() {
+ return tempMuteReasonFormat;
+ }
+
+ public MessageFormat getPermMuteFormat() {
+ return permMuteFormat;
+ }
+
+ public MessageFormat getPermMuteReasonFormat() {
+ return permMuteReasonFormat;
+ }
+
+ public MessageFormat getUnmuteFormat() {
+ return unmuteFormat;
+ }
+
+ public MessageFormat getJoinFormat(Player player) {
+ final String format = getFormatString("join");
+ final String filled;
+ if (plugin.isPAPI() && format != null) {
+ filled = me.clip.placeholderapi.PlaceholderAPI.setPlaceholders(player, format);
+ } else {
+ filled = format;
+ }
+ return generateMessageFormat(filled, ":arrow_right: {displayname} has joined!", false,
+ "username", "displayname", "joinmessage");
+ }
+
+ public MessageFormat getQuitFormat(Player player) {
+ final String format = getFormatString("quit");
+ final String filled;
+ if (plugin.isPAPI() && format != null) {
+ filled = me.clip.placeholderapi.PlaceholderAPI.setPlaceholders(player, format);
+ } else {
+ filled = format;
+ }
+ return generateMessageFormat(filled, ":arrow_left: {displayname} has left!", false,
+ "username", "displayname", "quitmessage");
+ }
+
+ public MessageFormat getDeathFormat(Player player) {
+ final String format = getFormatString("death");
+ final String filled;
+ if (plugin.isPAPI() && format != null) {
+ filled = me.clip.placeholderapi.PlaceholderAPI.setPlaceholders(player, format);
+ } else {
+ filled = format;
+ }
+ return generateMessageFormat(filled, ":skull: {deathmessage}", false,
+ "username", "displayname", "deathmessage");
+ }
+
+ public MessageFormat getAfkFormat(Player player) {
+ final String format = getFormatString("afk");
+ final String filled;
+ if (plugin.isPAPI() && format != null) {
+ filled = me.clip.placeholderapi.PlaceholderAPI.setPlaceholders(player, format);
+ } else {
+ filled = format;
+ }
+ return generateMessageFormat(filled, ":person_walking: {displayname} is now AFK!", false,
+ "username", "displayname");
+ }
+
+ public MessageFormat getUnAfkFormat(Player player) {
+ final String format = getFormatString("un-afk");
+ final String filled;
+ if (plugin.isPAPI() && format != null) {
+ filled = me.clip.placeholderapi.PlaceholderAPI.setPlaceholders(player, format);
+ } else {
+ filled = format;
+ }
+ return generateMessageFormat(filled, ":keyboard: {displayname} is no longer AFK!", false,
+ "username", "displayname");
+ }
+
+ public MessageFormat getKickFormat() {
+ return kickFormat;
+ }
+
+ private String getFormatString(String node) {
+ final String pathPrefix = node.startsWith(".") ? "" : "messages.";
+ return config.getString(pathPrefix + (pathPrefix.isEmpty() ? node.substring(1) : node), null);
+ }
+
+ private MessageFormat generateMessageFormat(String content, String defaultStr, boolean format, String... arguments) {
+ content = content == null ? defaultStr : content;
+ content = format ? FormatUtil.replaceFormat(content) : FormatUtil.stripFormat(content);
+ for (int i = 0; i < arguments.length; i++) {
+ content = content.replace("{" + arguments[i] + "}", "{" + i + "}");
+ }
+ return new MessageFormat(content);
+ }
+
+ @Override
+ public void reloadConfig() {
+ config.load();
+
+ // Build channel maps
+ nameToChannelIdMap.clear();
+ channelIdToNamesMap.clear();
+ final Map section = ConfigurateUtil.getRawMap(config, "channels");
+ for (Map.Entry entry : section.entrySet()) {
+ if (entry.getValue() instanceof Long) {
+ final long value = (long) entry.getValue();
+ nameToChannelIdMap.put(entry.getKey(), value);
+ channelIdToNamesMap.computeIfAbsent(value, o -> new ArrayList<>()).add(entry.getKey());
+ }
+ }
+
+ // Presence stuff
+ status = OnlineStatus.fromKey(config.getString("presence.status", "online"));
+ if (status == OnlineStatus.UNKNOWN) {
+ // Default invalid status to online
+ status = OnlineStatus.ONLINE;
+ }
+
+ final String activity = config.getString("presence.activity", "default").trim().toUpperCase().replace("CUSTOM_STATUS", "DEFAULT");
+ statusActivity = null;
+ Activity.ActivityType activityType = null;
+ try {
+ if (!activity.equals("NONE")) {
+ activityType = Activity.ActivityType.valueOf(activity);
+ }
+ } catch (IllegalArgumentException e) {
+ activityType = Activity.ActivityType.DEFAULT;
+ }
+ if (activityType != null) {
+ statusActivity = Activity.of(activityType, config.getString("presence.message", "Minecraft"));
+ }
+
+ final String filter = config.getString("chat.discord-filter", null);
+ if (filter != null) {
+ try {
+ discordFilter = Pattern.compile(filter);
+ } catch (PatternSyntaxException e) {
+ plugin.getLogger().log(java.util.logging.Level.WARNING, "Invalid pattern for \"chat.discord-filter\": " + e.getMessage());
+ discordFilter = null;
+ }
+ } else {
+ discordFilter = null;
+ }
+
+ consoleLogLevel = Level.toLevel(config.getString("console.log-level", null), Level.INFO);
+
+ consoleFormat = generateMessageFormat(getFormatString(".console.format"), "[{timestamp} {level}] {message}", false,
+ "timestamp", "level", "message");
+
+ discordToMcFormat = generateMessageFormat(getFormatString("discord-to-mc"), "&6[#{channel}] &3{fullname}&7: &f{message}", true,
+ "channel", "username", "discriminator", "fullname", "nickname", "color", "message");
+ unmuteFormat = generateMessageFormat(getFormatString("unmute"), "{displayname} unmuted.", false, "username", "displayname");
+ tempMuteFormat = generateMessageFormat(getFormatString("temporary-mute"), "{controllerdisplayname} has muted player {displayname} for {time}.", false,
+ "username", "displayname", "controllername", "controllerdisplayname", "time");
+ permMuteFormat = generateMessageFormat(getFormatString("permanent-mute"), "{controllerdisplayname} permanently muted {displayname}.", false,
+ "username", "displayname", "controllername", "controllerdisplayname");
+ tempMuteReasonFormat = generateMessageFormat(getFormatString("temporary-mute-reason"), "{controllerdisplayname} has muted player {displayname} for {time}. Reason: {reason}.", false,
+ "username", "displayname", "controllername", "controllerdisplayname", "time", "reason");
+ permMuteReasonFormat = generateMessageFormat(getFormatString("permanent-mute-reason"), "{controllerdisplayname} has muted player {displayname}. Reason: {reason}.", false,
+ "username", "displayname", "controllername", "controllerdisplayname", "reason");
+ kickFormat = generateMessageFormat(getFormatString("kick"), "{displayname} was kicked with reason: {reason}", false,
+ "username", "displayname", "reason");
+
+ plugin.onReload();
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/EssentialsDiscord.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/EssentialsDiscord.java
new file mode 100644
index 000000000..cf803f7e0
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/EssentialsDiscord.java
@@ -0,0 +1,86 @@
+package net.essentialsx.discord;
+
+import com.earth2me.essentials.IEssentials;
+import com.earth2me.essentials.IEssentialsModule;
+import com.earth2me.essentials.metrics.MetricsWrapper;
+import net.essentialsx.discord.interactions.InteractionControllerImpl;
+import org.bukkit.plugin.java.JavaPlugin;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.earth2me.essentials.I18n.tl;
+
+public class EssentialsDiscord extends JavaPlugin implements IEssentialsModule {
+ private final static Logger logger = Logger.getLogger("EssentialsDiscord");
+ private transient IEssentials ess;
+ private transient MetricsWrapper metrics = null;
+
+ private JDADiscordService jda;
+ private DiscordSettings settings;
+ private boolean isPAPI = false;
+
+ @Override
+ public void onEnable() {
+ ess = (IEssentials) getServer().getPluginManager().getPlugin("Essentials");
+ if (ess == null || !ess.isEnabled()) {
+ setEnabled(false);
+ return;
+ }
+ if (!getDescription().getVersion().equals(ess.getDescription().getVersion())) {
+ getLogger().log(Level.WARNING, tl("versionMismatchAll"));
+ }
+
+ isPAPI = getServer().getPluginManager().getPlugin("PlaceholderAPI") != null;
+
+ settings = new DiscordSettings(this);
+ ess.addReloadListener(settings);
+
+ if (jda == null) {
+ jda = new JDADiscordService(this);
+ try {
+ jda.startup();
+ ess.scheduleSyncDelayedTask(() -> ((InteractionControllerImpl) jda.getInteractionController()).processBatchRegistration());
+ } catch (Exception e) {
+ logger.log(Level.SEVERE, tl("discordErrorLogin", e.getMessage()));
+ if (ess.getSettings().isDebug()) {
+ e.printStackTrace();
+ }
+ setEnabled(false);
+ return;
+ }
+ }
+
+ if (metrics == null) {
+ metrics = new MetricsWrapper(this, 9824, false);
+ }
+ }
+
+ public void onReload() {
+ if (jda != null) {
+ jda.updatePresence();
+ jda.updatePrimaryChannel();
+ jda.updateConsoleRelay();
+ jda.updateTypesRelay();
+ }
+ }
+
+ public IEssentials getEss() {
+ return ess;
+ }
+
+ public DiscordSettings getSettings() {
+ return settings;
+ }
+
+ public boolean isPAPI() {
+ return isPAPI;
+ }
+
+ @Override
+ public void onDisable() {
+ if (jda != null) {
+ jda.shutdown();
+ }
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/JDADiscordService.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/JDADiscordService.java
new file mode 100644
index 000000000..97e0d3f8b
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/JDADiscordService.java
@@ -0,0 +1,412 @@
+package net.essentialsx.discord;
+
+import club.minnced.discord.webhook.WebhookClient;
+import club.minnced.discord.webhook.WebhookClientBuilder;
+import club.minnced.discord.webhook.send.WebhookMessage;
+import club.minnced.discord.webhook.send.WebhookMessageBuilder;
+import com.earth2me.essentials.utils.FormatUtil;
+import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.JDABuilder;
+import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.Webhook;
+import net.dv8tion.jda.api.events.ShutdownEvent;
+import net.dv8tion.jda.api.hooks.EventListener;
+import net.dv8tion.jda.api.hooks.ListenerAdapter;
+import net.essentialsx.api.v2.events.discord.DiscordMessageEvent;
+import net.essentialsx.api.v2.services.discord.DiscordService;
+import net.essentialsx.api.v2.services.discord.InteractionController;
+import net.essentialsx.api.v2.services.discord.InteractionException;
+import net.essentialsx.api.v2.services.discord.MessageType;
+import net.essentialsx.api.v2.services.discord.Unsafe;
+import net.essentialsx.discord.interactions.InteractionControllerImpl;
+import net.essentialsx.discord.interactions.commands.ExecuteCommand;
+import net.essentialsx.discord.interactions.commands.ListCommand;
+import net.essentialsx.discord.interactions.commands.MessageCommand;
+import net.essentialsx.discord.listeners.BukkitListener;
+import net.essentialsx.discord.listeners.DiscordCommandDispatcher;
+import net.essentialsx.discord.listeners.DiscordListener;
+import net.essentialsx.discord.util.ConsoleInjector;
+import net.essentialsx.discord.util.DiscordUtil;
+import org.bukkit.Bukkit;
+import org.bukkit.event.HandlerList;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.plugin.ServicePriority;
+import org.jetbrains.annotations.NotNull;
+
+import javax.security.auth.login.LoginException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.regex.Matcher;
+
+import static com.earth2me.essentials.I18n.tl;
+
+public class JDADiscordService implements DiscordService {
+ private final static Logger logger = Logger.getLogger("EssentialsDiscord");
+ private final EssentialsDiscord plugin;
+ private final Unsafe unsafe = this::getJda;
+
+ private JDA jda;
+ private Guild guild;
+ private TextChannel primaryChannel;
+ private WebhookClient consoleWebhook;
+ private String lastConsoleId;
+ private final Map registeredTypes = new HashMap<>();
+ private final Map typeToChannelId = new HashMap<>();
+ private final Map channelIdToWebhook = new HashMap<>();
+ private ConsoleInjector injector;
+ private DiscordCommandDispatcher commandDispatcher;
+ private InteractionControllerImpl interactionController;
+
+ public JDADiscordService(EssentialsDiscord plugin) {
+ this.plugin = plugin;
+ for (final MessageType type : MessageType.DefaultTypes.values()) {
+ registerMessageType(plugin, type);
+ }
+ }
+
+ public TextChannel getChannel(String key, boolean primaryFallback) {
+ long resolvedId;
+ try {
+ resolvedId = Long.parseLong(key);
+ } catch (NumberFormatException ignored) {
+ resolvedId = getSettings().getChannelId(getSettings().getMessageChannel(key));
+ }
+
+ if (isDebug()) {
+ logger.log(Level.INFO, "Channel definition " + key + " resolved as " + resolvedId);
+ }
+ TextChannel channel = guild.getTextChannelById(resolvedId);
+ if (channel == null && primaryFallback) {
+ if (isDebug()) {
+ logger.log(Level.WARNING, "Resolved channel id " + resolvedId + " was not found! Falling back to primary channel.");
+ }
+ channel = primaryChannel;
+ }
+ return channel;
+ }
+
+ public WebhookMessage getWebhookMessage(String message) {
+ return getWebhookMessage(message, jda.getSelfUser().getAvatarUrl(), getSettings().getConsoleWebhookName(), false);
+ }
+
+ public WebhookMessage getWebhookMessage(String message, String avatarUrl, String name, boolean groupMentions) {
+ return new WebhookMessageBuilder()
+ .setAvatarUrl(avatarUrl)
+ .setAllowedMentions(groupMentions ? DiscordUtil.ALL_MENTIONS_WEBHOOK : DiscordUtil.NO_GROUP_MENTIONS_WEBHOOK)
+ .setUsername(name)
+ .setContent(message)
+ .build();
+ }
+
+ public void sendMessage(DiscordMessageEvent event, String message, boolean groupMentions) {
+ final TextChannel channel = getChannel(event.getType().getKey(), true);
+
+ final String strippedContent = FormatUtil.stripFormat(message);
+
+ final String webhookChannelId = typeToChannelId.get(event.getType());
+ if (webhookChannelId != null) {
+ final WebhookClient client = channelIdToWebhook.get(webhookChannelId);
+ if (client != null) {
+ final String avatarUrl = event.getAvatarUrl() != null ? event.getAvatarUrl() : jda.getSelfUser().getAvatarUrl();
+ final String name = event.getName() != null ? event.getName() : guild.getSelfMember().getEffectiveName();
+ client.send(getWebhookMessage(strippedContent, avatarUrl, name, groupMentions));
+ return;
+ }
+ }
+
+ if (!channel.canTalk()) {
+ logger.warning(tl("discordNoSendPermission", channel.getName()));
+ return;
+ }
+ channel.sendMessage(strippedContent)
+ .allowedMentions(groupMentions ? null : DiscordUtil.NO_GROUP_MENTIONS)
+ .queue();
+ }
+
+ public void startup() throws LoginException, InterruptedException {
+ shutdown();
+
+ logger.log(Level.INFO, tl("discordLoggingIn"));
+ if (plugin.getSettings().getBotToken().replace("INSERT-TOKEN-HERE", "").trim().isEmpty()) {
+ throw new IllegalArgumentException(tl("discordErrorNoToken"));
+ }
+
+ jda = JDABuilder.createDefault(plugin.getSettings().getBotToken())
+ .addEventListeners(new DiscordListener(this))
+ .setContextEnabled(false)
+ .setRawEventsEnabled(true)
+ .build()
+ .awaitReady();
+ updatePresence();
+ logger.log(Level.INFO, tl("discordLoggingInDone", jda.getSelfUser().getAsTag()));
+
+ if (jda.getGuilds().isEmpty()) {
+ throw new IllegalArgumentException(tl("discordErrorNoGuildSize"));
+ }
+
+ guild = jda.getGuildById(plugin.getSettings().getGuildId());
+ if (guild == null) {
+ throw new IllegalArgumentException(tl("discordErrorNoGuild"));
+ }
+
+ interactionController = new InteractionControllerImpl(this);
+ try {
+ interactionController.registerCommand(new ExecuteCommand(this));
+ interactionController.registerCommand(new MessageCommand(this));
+ interactionController.registerCommand(new ListCommand(this));
+ } catch (InteractionException ignored) {
+ // won't happen
+ }
+
+ updatePrimaryChannel();
+
+ updateConsoleRelay();
+
+ updateTypesRelay();
+
+ Bukkit.getPluginManager().registerEvents(new BukkitListener(this), plugin);
+
+ Bukkit.getServicesManager().register(DiscordService.class, this, plugin, ServicePriority.Normal);
+ }
+
+ @Override
+ public boolean isRegistered(String key) {
+ return registeredTypes.containsKey(key);
+ }
+
+ @Override
+ public void registerMessageType(Plugin plugin, MessageType type) {
+ if (!type.getKey().matches("^[a-z0-9-]*$")) {
+ throw new IllegalArgumentException("MessageType key must match \"^[a-z0-9-]*$\"");
+ }
+
+ if (registeredTypes.containsKey(type.getKey())) {
+ throw new IllegalArgumentException("A MessageType with that key is already registered!");
+ }
+
+ registeredTypes.put(type.getKey(), type);
+ }
+
+ @Override
+ public void sendMessage(MessageType type, String message, boolean allowGroupMentions) {
+ if (!registeredTypes.containsKey(type.getKey())) {
+ logger.warning("Sending message to channel \"" + type.getKey() + "\" which is an unregistered type! If you are a plugin author, you should be registering your MessageType before using them.");
+ }
+ final DiscordMessageEvent event = new DiscordMessageEvent(type, FormatUtil.stripFormat(message), allowGroupMentions);
+ if (Bukkit.getServer().isPrimaryThread()) {
+ Bukkit.getPluginManager().callEvent(event);
+ } else {
+ Bukkit.getScheduler().runTask(plugin, () -> Bukkit.getPluginManager().callEvent(event));
+ }
+ }
+
+ @Override
+ public InteractionController getInteractionController() {
+ return interactionController;
+ }
+
+ public void updatePrimaryChannel() {
+ TextChannel channel = guild.getTextChannelById(plugin.getSettings().getPrimaryChannelId());
+ if (channel == null) {
+ channel = guild.getDefaultChannel();
+ if (channel == null || !channel.canTalk()) {
+ throw new RuntimeException(tl("discordErrorNoPerms"));
+ }
+ }
+ primaryChannel = channel;
+ }
+
+ public void updatePresence() {
+ jda.getPresence().setPresence(plugin.getSettings().getStatus(), plugin.getSettings().getStatusActivity());
+ }
+
+ public void updateTypesRelay() {
+ if (!getSettings().isShowAvatar() && !getSettings().isShowName()) {
+ for (WebhookClient webhook : channelIdToWebhook.values()) {
+ webhook.close();
+ }
+ typeToChannelId.clear();
+ channelIdToWebhook.clear();
+ return;
+ }
+
+ for (MessageType type : MessageType.DefaultTypes.values()) {
+ if (!type.isPlayer()) {
+ continue;
+ }
+
+ final TextChannel channel = getChannel(type.getKey(), true);
+ if (channel.getId().equals(typeToChannelId.get(type))) {
+ continue;
+ }
+
+ final String webhookName = "EssX Advanced Relay";
+ Webhook webhook = DiscordUtil.getAndCleanWebhooks(channel, webhookName).join();
+ webhook = webhook == null ? DiscordUtil.createWebhook(channel, webhookName).join() : webhook;
+ if (webhook == null) {
+ final WebhookClient current = channelIdToWebhook.get(channel.getId());
+ if (current != null) {
+ current.close();
+ }
+ channelIdToWebhook.remove(channel.getId());
+ continue;
+ }
+ typeToChannelId.put(type, channel.getId());
+ channelIdToWebhook.put(channel.getId(), DiscordUtil.getWebhookClient(webhook.getIdLong(), webhook.getToken(), jda.getHttpClient()));
+ }
+ }
+
+ public void updateConsoleRelay() {
+ final String consoleDef = getSettings().getConsoleChannelDef();
+ final Matcher matcher = WebhookClientBuilder.WEBHOOK_PATTERN.matcher(consoleDef);
+ final long webhookId;
+ final String webhookToken;
+ if (matcher.matches()) {
+ webhookId = Long.parseUnsignedLong(matcher.group(1));
+ webhookToken = matcher.group(2);
+ if (commandDispatcher != null) {
+ jda.removeEventListener(commandDispatcher);
+ commandDispatcher = null;
+ }
+ } else {
+ final TextChannel channel = getChannel(consoleDef, false);
+ if (channel != null) {
+ if (getSettings().isConsoleCommandRelay()) {
+ if (commandDispatcher == null) {
+ commandDispatcher = new DiscordCommandDispatcher(this);
+ jda.addEventListener(commandDispatcher);
+ }
+ commandDispatcher.setChannelId(channel.getId());
+ } else if (commandDispatcher != null) {
+ jda.removeEventListener(commandDispatcher);
+ commandDispatcher = null;
+ }
+
+ if (channel.getId().equals(lastConsoleId)) {
+ return;
+ }
+
+ final String webhookName = "EssX Console Relay";
+ Webhook webhook = DiscordUtil.getAndCleanWebhooks(channel, webhookName).join();
+ webhook = webhook == null ? DiscordUtil.createWebhook(channel, webhookName).join() : webhook;
+ if (webhook == null) {
+ logger.info(tl("discordErrorLoggerNoPerms"));
+ return;
+ }
+ webhookId = webhook.getIdLong();
+ webhookToken = webhook.getToken();
+ lastConsoleId = channel.getId();
+ } else if (!getSettings().getConsoleChannelDef().equals("none") && !getSettings().getConsoleChannelDef().startsWith("0")) {
+ logger.info(tl("discordErrorLoggerInvalidChannel"));
+ shutdownConsoleRelay(true);
+ return;
+ } else {
+ // It's either not configured at all or knowingly disabled.
+ shutdownConsoleRelay(true);
+ return;
+ }
+ }
+
+ shutdownConsoleRelay(false);
+ consoleWebhook = DiscordUtil.getWebhookClient(webhookId, webhookToken, jda.getHttpClient());
+ if (injector == null) {
+ injector = new ConsoleInjector(this);
+ injector.start();
+ }
+ }
+
+ private void shutdownConsoleRelay(final boolean closeInjector) {
+ if (consoleWebhook != null && !consoleWebhook.isShutdown()) {
+ consoleWebhook.close();
+ }
+ consoleWebhook = null;
+
+ if (closeInjector) {
+ if (injector != null) {
+ injector.remove();
+ injector = null;
+ }
+
+ if (commandDispatcher != null) {
+ jda.removeEventListener(commandDispatcher);
+ commandDispatcher = null;
+ }
+ }
+ }
+
+ public void shutdown() {
+ if (interactionController != null) {
+ interactionController.shutdown();
+ }
+
+ if (jda != null) {
+ shutdownConsoleRelay(true);
+
+ // Unregister leftover jda listeners
+ for (Object obj : jda.getRegisteredListeners()) {
+ if (!(obj instanceof EventListener)) { // Yeah bro I wish I knew too :/
+ jda.removeEventListener(obj);
+ }
+ }
+
+ // Unregister Bukkit Events
+ HandlerList.unregisterAll(plugin);
+
+ // Creates a future which will be completed when JDA fully shutdowns
+ final CompletableFuture future = new CompletableFuture<>();
+ jda.addEventListener(new ListenerAdapter() {
+ @Override
+ public void onShutdown(@NotNull ShutdownEvent event) {
+ future.complete(null);
+ }
+ });
+
+ // Tell JDA to wrap it up
+ jda.shutdown();
+ try {
+ // Wait for JDA to wrap it up
+ future.get(5, TimeUnit.SECONDS);
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+ logger.warning("JDA took longer than expected to shutdown, this may have caused some problems.");
+ } finally {
+ jda = null;
+ }
+ }
+ }
+
+ public JDA getJda() {
+ return jda;
+ }
+
+ @Override
+ public Unsafe getUnsafe() {
+ return unsafe;
+ }
+
+ public Guild getGuild() {
+ return guild;
+ }
+
+ public EssentialsDiscord getPlugin() {
+ return plugin;
+ }
+
+ public DiscordSettings getSettings() {
+ return plugin.getSettings();
+ }
+
+ public WebhookClient getConsoleWebhook() {
+ return consoleWebhook;
+ }
+
+ public boolean isDebug() {
+ return plugin.getEss().getSettings().isDebug();
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionChannelImpl.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionChannelImpl.java
new file mode 100644
index 000000000..3963bd60f
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionChannelImpl.java
@@ -0,0 +1,26 @@
+package net.essentialsx.discord.interactions;
+
+import net.dv8tion.jda.api.entities.GuildChannel;
+import net.essentialsx.api.v2.services.discord.InteractionChannel;
+
+public class InteractionChannelImpl implements InteractionChannel {
+ private final GuildChannel channel;
+
+ public InteractionChannelImpl(GuildChannel channel) {
+ this.channel = channel;
+ }
+
+ @Override
+ public String getName() {
+ return channel.getName();
+ }
+
+ public GuildChannel getJdaObject() {
+ return channel;
+ }
+
+ @Override
+ public String getId() {
+ return channel.getId();
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionCommandImpl.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionCommandImpl.java
new file mode 100644
index 000000000..8840f739a
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionCommandImpl.java
@@ -0,0 +1,54 @@
+package net.essentialsx.discord.interactions;
+
+import net.essentialsx.api.v2.services.discord.InteractionCommand;
+import net.essentialsx.api.v2.services.discord.InteractionCommandArgument;
+import net.essentialsx.discord.JDADiscordService;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public abstract class InteractionCommandImpl implements InteractionCommand {
+ protected final JDADiscordService jda;
+ private final String name;
+ private final String description;
+ private final List arguments = new ArrayList<>();
+
+ public InteractionCommandImpl(JDADiscordService jda, String name, String description) {
+ this.jda = jda;
+ this.name = name;
+ this.description = description;
+ }
+
+ @Override
+ public final boolean isDisabled() {
+ return !jda.getSettings().isCommandEnabled(name);
+ }
+
+ @Override
+ public final boolean isEphemeral() {
+ return jda.getSettings().isCommandEphemeral(name);
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public String getDescription() {
+ return description;
+ }
+
+ @Override
+ public List getArguments() {
+ return arguments;
+ }
+
+ public List getAdminSnowflakes() {
+ return jda.getSettings().getCommandAdminSnowflakes(name);
+ }
+
+ public void addArgument(InteractionCommandArgument argument) {
+ arguments.add(argument);
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionControllerImpl.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionControllerImpl.java
new file mode 100644
index 000000000..c5c9150bc
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionControllerImpl.java
@@ -0,0 +1,161 @@
+package net.essentialsx.discord.interactions;
+
+import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
+import net.dv8tion.jda.api.exceptions.ErrorResponseException;
+import net.dv8tion.jda.api.hooks.ListenerAdapter;
+import net.dv8tion.jda.api.interactions.commands.Command;
+import net.dv8tion.jda.api.interactions.commands.OptionType;
+import net.dv8tion.jda.api.interactions.commands.build.CommandData;
+import net.dv8tion.jda.api.requests.ErrorResponse;
+import net.essentialsx.api.v2.services.discord.InteractionCommand;
+import net.essentialsx.api.v2.services.discord.InteractionCommandArgument;
+import net.essentialsx.api.v2.services.discord.InteractionController;
+import net.essentialsx.api.v2.services.discord.InteractionEvent;
+import net.essentialsx.api.v2.services.discord.InteractionException;
+import net.essentialsx.discord.JDADiscordService;
+import net.essentialsx.discord.util.DiscordUtil;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.earth2me.essentials.I18n.tl;
+
+public class InteractionControllerImpl extends ListenerAdapter implements InteractionController {
+ private final static Logger logger = Logger.getLogger("EssentialsDiscord");
+
+ private final JDADiscordService jda;
+
+ private final Map commandMap = new ConcurrentHashMap<>();
+ private final Map batchRegistrationQueue = new HashMap<>();
+ private boolean initialBatchRegistration = false;
+
+ public InteractionControllerImpl(JDADiscordService jda) {
+ this.jda = jda;
+ jda.getJda().addEventListener(this);
+ }
+
+ @Override
+ public void onSlashCommand(@NotNull SlashCommandEvent event) {
+ if (event.getGuild() == null || event.getMember() == null || !commandMap.containsKey(event.getName())) {
+ return;
+ }
+
+ final InteractionCommand command = commandMap.get(event.getName());
+
+ if (command.isDisabled()) {
+ event.reply(tl("discordErrorCommandDisabled")).setEphemeral(true).queue();
+ return;
+ }
+
+ event.deferReply(command.isEphemeral()).queue(null, failure -> logger.log(Level.SEVERE, "Error while deferring Discord command", failure));
+
+ final InteractionEvent interactionEvent = new InteractionEventImpl(event);
+ if (!DiscordUtil.hasRoles(event.getMember(), jda.getSettings().getCommandSnowflakes(command.getName()))) {
+ interactionEvent.reply(tl("noAccessCommand"));
+ return;
+ }
+ jda.getPlugin().getEss().scheduleSyncDelayedTask(() -> command.onCommand(interactionEvent));
+ }
+
+ @Override
+ public InteractionCommand getCommand(String name) {
+ return commandMap.get(name);
+ }
+
+ public void processBatchRegistration() {
+ if (!initialBatchRegistration && !batchRegistrationQueue.isEmpty()) {
+ initialBatchRegistration = true;
+ final List list = new ArrayList<>();
+ for (final InteractionCommand command : batchRegistrationQueue.values()) {
+ final CommandData data = new CommandData(command.getName(), command.getDescription());
+ if (command.getArguments() != null) {
+ for (final InteractionCommandArgument argument : command.getArguments()) {
+ data.addOption(OptionType.valueOf(argument.getType().name()), argument.getName(), argument.getDescription(), argument.isRequired());
+ }
+ }
+ list.add(data);
+ }
+
+ jda.getGuild().updateCommands().addCommands(list).queue(success -> {
+ for (final Command command : success) {
+ commandMap.put(command.getName(), batchRegistrationQueue.get(command.getName()));
+ batchRegistrationQueue.remove(command.getName());
+ if (jda.isDebug()) {
+ logger.info("Registered guild command " + command.getName() + " with id " + command.getId());
+ }
+ }
+
+ if (!batchRegistrationQueue.isEmpty()) {
+ logger.warning(batchRegistrationQueue.size() + " Discord commands were lost during command registration!");
+ if (jda.isDebug()) {
+ logger.warning("Lost commands: " + batchRegistrationQueue.keySet());
+ }
+ batchRegistrationQueue.clear();
+ }
+ }, failure -> {
+ if (failure instanceof ErrorResponseException && ((ErrorResponseException) failure).getErrorResponse() == ErrorResponse.MISSING_ACCESS) {
+ logger.severe(tl("discordErrorCommand"));
+ return;
+ }
+ logger.log(Level.SEVERE, "Error while registering command", failure);
+ });
+ }
+ }
+
+ @Override
+ public void registerCommand(InteractionCommand command) throws InteractionException {
+ if (command.isDisabled()) {
+ throw new InteractionException("The given command has been disabled!");
+ }
+
+ if (commandMap.containsKey(command.getName())) {
+ throw new InteractionException("A command with that name is already registered!");
+ }
+
+ if (!initialBatchRegistration) {
+ if (jda.isDebug()) {
+ logger.info("Marked guild command for batch registration: " + command.getName());
+ }
+ batchRegistrationQueue.put(command.getName(), command);
+ return;
+ }
+
+ final CommandData data = new CommandData(command.getName(), command.getDescription());
+ if (command.getArguments() != null) {
+ for (final InteractionCommandArgument argument : command.getArguments()) {
+ data.addOption(OptionType.valueOf(argument.getType().name()), argument.getName(), argument.getDescription(), argument.isRequired());
+ }
+ }
+
+ jda.getGuild().upsertCommand(data).queue(success -> {
+ commandMap.put(command.getName(), command);
+ if (jda.isDebug()) {
+ logger.info("Registered guild command " + success.getName() + " with id " + success.getId());
+ }
+ }, failure -> {
+ if (failure instanceof ErrorResponseException && ((ErrorResponseException) failure).getErrorResponse() == ErrorResponse.MISSING_ACCESS) {
+ logger.severe(tl("discordErrorCommand"));
+ return;
+ }
+ logger.log(Level.SEVERE, "Error while registering command", failure);
+ });
+ }
+
+ public void shutdown() {
+ try {
+ jda.getGuild().updateCommands().complete();
+ } catch (Throwable e) {
+ logger.severe("Error while deleting commands: " + e.getMessage());
+ if (jda.isDebug()) {
+ e.printStackTrace();
+ }
+ }
+ commandMap.clear();
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionEventImpl.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionEventImpl.java
new file mode 100644
index 000000000..f6b062227
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionEventImpl.java
@@ -0,0 +1,79 @@
+package net.essentialsx.discord.interactions;
+
+import com.earth2me.essentials.utils.FormatUtil;
+import com.google.common.base.Joiner;
+import net.dv8tion.jda.api.MessageBuilder;
+import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
+import net.dv8tion.jda.api.interactions.commands.OptionMapping;
+import net.essentialsx.api.v2.services.discord.InteractionChannel;
+import net.essentialsx.api.v2.services.discord.InteractionEvent;
+import net.essentialsx.api.v2.services.discord.InteractionMember;
+import net.essentialsx.discord.util.DiscordUtil;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * A class which provides information about what triggered an interaction event.
+ */
+public class InteractionEventImpl implements InteractionEvent {
+ private final static Logger logger = Logger.getLogger("EssentialsDiscord");
+ private final SlashCommandEvent event;
+ private final InteractionMember member;
+ private final List replyBuffer = new ArrayList<>();
+
+ public InteractionEventImpl(final SlashCommandEvent jdaEvent) {
+ this.event = jdaEvent;
+ this.member = new InteractionMemberImpl(jdaEvent.getMember());
+ }
+
+ @Override
+ public void reply(String message) {
+ message = FormatUtil.stripFormat(message).replace("§", ""); // Don't ask
+ replyBuffer.add(message);
+ event.getHook().editOriginal(new MessageBuilder().setContent(Joiner.on('\n').join(replyBuffer)).setAllowedMentions(DiscordUtil.NO_GROUP_MENTIONS).build())
+ .queue(null, error -> logger.log(Level.SEVERE, "Error while editing command interaction response", error));
+ }
+
+ @Override
+ public InteractionMember getMember() {
+ return member;
+ }
+
+ @Override
+ public String getStringArgument(String key) {
+ final OptionMapping mapping = event.getOption(key);
+ return mapping == null ? null : mapping.getAsString();
+ }
+
+ @Override
+ public Long getIntegerArgument(String key) {
+ final OptionMapping mapping = event.getOption(key);
+ return mapping == null ? null : mapping.getAsLong();
+ }
+
+ @Override
+ public Boolean getBooleanArgument(String key) {
+ final OptionMapping mapping = event.getOption(key);
+ return mapping == null ? null : mapping.getAsBoolean();
+ }
+
+ @Override
+ public InteractionMember getUserArgument(String key) {
+ final OptionMapping mapping = event.getOption(key);
+ return mapping == null ? null : new InteractionMemberImpl(mapping.getAsMember());
+ }
+
+ @Override
+ public InteractionChannel getChannelArgument(String key) {
+ final OptionMapping mapping = event.getOption(key);
+ return mapping == null ? null : new InteractionChannelImpl(mapping.getAsGuildChannel());
+ }
+
+ @Override
+ public String getChannelId() {
+ return event.getChannel().getId();
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionMemberImpl.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionMemberImpl.java
new file mode 100644
index 000000000..1184fdba8
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/InteractionMemberImpl.java
@@ -0,0 +1,72 @@
+package net.essentialsx.discord.interactions;
+
+import net.dv8tion.jda.api.Permission;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.PrivateChannel;
+import net.essentialsx.api.v2.services.discord.InteractionMember;
+import net.essentialsx.discord.util.DiscordUtil;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+public class InteractionMemberImpl implements InteractionMember {
+ private final Member member;
+
+ public InteractionMemberImpl(Member member) {
+ this.member = member;
+ }
+
+ @Override
+ public String getName() {
+ return member.getUser().getName();
+ }
+
+ @Override
+ public String getDiscriminator() {
+ return member.getUser().getDiscriminator();
+ }
+
+ @Override
+ public String getEffectiveName() {
+ return member.getEffectiveName();
+ }
+
+ @Override
+ public String getNickname() {
+ return member.getNickname();
+ }
+
+ @Override
+ public String getId() {
+ return member.getId();
+ }
+
+ @Override
+ public boolean isAdmin() {
+ return member.hasPermission(Permission.ADMINISTRATOR);
+ }
+
+ @Override
+ public boolean hasRoles(List roleDefinitions) {
+ return DiscordUtil.hasRoles(member, roleDefinitions);
+ }
+
+ public Member getJdaObject() {
+ return member;
+ }
+
+ @Override
+ public CompletableFuture sendPrivateMessage(String content) {
+ final CompletableFuture future = new CompletableFuture<>();
+ final CompletableFuture privateFuture = member.getUser().openPrivateChannel().submit();
+ privateFuture.thenCompose(privateChannel -> privateChannel.sendMessage(content).submit())
+ .whenComplete((m, error) -> {
+ if (error != null) {
+ future.complete(false);
+ return;
+ }
+ future.complete(true);
+ });
+ return future;
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/commands/ExecuteCommand.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/commands/ExecuteCommand.java
new file mode 100644
index 000000000..99b724d4d
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/commands/ExecuteCommand.java
@@ -0,0 +1,26 @@
+package net.essentialsx.discord.interactions.commands;
+
+import net.essentialsx.api.v2.services.discord.InteractionCommandArgument;
+import net.essentialsx.api.v2.services.discord.InteractionCommandArgumentType;
+import net.essentialsx.api.v2.services.discord.InteractionEvent;
+import net.essentialsx.discord.JDADiscordService;
+import net.essentialsx.discord.interactions.InteractionCommandImpl;
+import net.essentialsx.discord.util.DiscordCommandSender;
+import org.bukkit.Bukkit;
+
+import static com.earth2me.essentials.I18n.tl;
+
+public class ExecuteCommand extends InteractionCommandImpl {
+ public ExecuteCommand(JDADiscordService jda) {
+ super(jda, "execute", tl("discordCommandExecuteDescription"));
+ addArgument(new InteractionCommandArgument("command", tl("discordCommandExecuteArgumentCommand"), InteractionCommandArgumentType.STRING, true));
+ }
+
+ @Override
+ public void onCommand(InteractionEvent event) {
+ final String command = event.getStringArgument("command");
+ event.reply(tl("discordCommandExecuteReply", command));
+ Bukkit.getScheduler().runTask(jda.getPlugin(), () ->
+ Bukkit.dispatchCommand(new DiscordCommandSender(jda, Bukkit.getConsoleSender(), event::reply).getSender(), command));
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/commands/ListCommand.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/commands/ListCommand.java
new file mode 100644
index 000000000..dc68a7f43
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/commands/ListCommand.java
@@ -0,0 +1,51 @@
+package net.essentialsx.discord.interactions.commands;
+
+import com.earth2me.essentials.IEssentials;
+import com.earth2me.essentials.PlayerList;
+import com.earth2me.essentials.User;
+import net.essentialsx.api.v2.services.discord.InteractionCommandArgument;
+import net.essentialsx.api.v2.services.discord.InteractionCommandArgumentType;
+import net.essentialsx.api.v2.services.discord.InteractionEvent;
+import net.essentialsx.discord.JDADiscordService;
+import net.essentialsx.discord.interactions.InteractionCommandImpl;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import static com.earth2me.essentials.I18n.tl;
+
+public class ListCommand extends InteractionCommandImpl {
+
+ public ListCommand(JDADiscordService jda) {
+ super(jda, "list", tl("discordCommandListDescription"));
+ addArgument(new InteractionCommandArgument("group", tl("discordCommandListArgumentGroup"), InteractionCommandArgumentType.STRING, false));
+ }
+
+ @Override
+ public void onCommand(InteractionEvent event) {
+ final boolean showHidden = event.getMember().hasRoles(getAdminSnowflakes());
+ final List output = new ArrayList<>();
+ final IEssentials ess = jda.getPlugin().getEss();
+
+ output.add(PlayerList.listSummary(ess, null, showHidden));
+ final Map> playerList = PlayerList.getPlayerLists(ess, null, showHidden);
+
+ final String group = event.getStringArgument("group");
+ if (group != null) {
+ try {
+ output.add(PlayerList.listGroupUsers(ess, playerList, group));
+ } catch (Exception e) {
+ output.add(tl("errorWithMessage", e.getMessage()));
+ }
+ } else {
+ output.addAll(PlayerList.prepareGroupedList(ess, getName(), playerList));
+ }
+
+ final StringBuilder stringBuilder = new StringBuilder();
+ for (final String str : output) {
+ stringBuilder.append(str).append("\n");
+ }
+ event.reply(stringBuilder.substring(0, stringBuilder.length() - 2));
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/commands/MessageCommand.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/commands/MessageCommand.java
new file mode 100644
index 000000000..5a7b8e8a3
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/interactions/commands/MessageCommand.java
@@ -0,0 +1,59 @@
+package net.essentialsx.discord.interactions.commands;
+
+import com.earth2me.essentials.User;
+import com.earth2me.essentials.commands.PlayerNotFoundException;
+import com.earth2me.essentials.utils.FormatUtil;
+import net.essentialsx.api.v2.services.discord.InteractionCommandArgument;
+import net.essentialsx.api.v2.services.discord.InteractionCommandArgumentType;
+import net.essentialsx.api.v2.services.discord.InteractionEvent;
+import net.essentialsx.discord.JDADiscordService;
+import net.essentialsx.discord.interactions.InteractionCommandImpl;
+import net.essentialsx.discord.util.DiscordMessageRecipient;
+import org.bukkit.Bukkit;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import static com.earth2me.essentials.I18n.tl;
+
+public class MessageCommand extends InteractionCommandImpl {
+ public MessageCommand(JDADiscordService jda) {
+ super(jda, "msg", tl("discordCommandMessageDescription"));
+ addArgument(new InteractionCommandArgument("username", tl("discordCommandMessageArgumentUsername"), InteractionCommandArgumentType.STRING, true));
+ addArgument(new InteractionCommandArgument("message", tl("discordCommandMessageArgumentMessage"), InteractionCommandArgumentType.STRING, true));
+ }
+
+ @Override
+ public void onCommand(InteractionEvent event) {
+ final boolean getHidden = event.getMember().hasRoles(getAdminSnowflakes());
+ final User user;
+ try {
+ user = jda.getPlugin().getEss().matchUser(Bukkit.getServer(), null, event.getStringArgument("username"), getHidden, false);
+ } catch (PlayerNotFoundException e) {
+ event.reply(tl("errorWithMessage", e.getMessage()));
+ return;
+ }
+
+ if (!getHidden && user.isIgnoreMsg()) {
+ event.reply(tl("msgIgnore", user.getDisplayName()));
+ return;
+ }
+
+ if (user.isAfk()) {
+ if (user.getAfkMessage() != null) {
+ event.reply(tl("userAFKWithMessage", user.getDisplayName(), user.getAfkMessage()));
+ } else {
+ event.reply(tl("userAFK", user.getDisplayName()));
+ }
+ }
+
+ final String message = event.getMember().hasRoles(jda.getSettings().getPermittedFormattingRoles()) ?
+ FormatUtil.replaceFormat(event.getStringArgument("message")) : FormatUtil.stripFormat(event.getStringArgument("message"));
+ event.reply(tl("msgFormat", tl("meSender"), user.getDisplayName(), message));
+
+ user.sendMessage(tl("msgFormat", event.getMember().getTag(), tl("meRecipient"), message));
+ // We use an atomic reference here so that java will garbage collect the recipient
+ final AtomicReference ref = new AtomicReference<>(new DiscordMessageRecipient(event.getMember()));
+ jda.getPlugin().getEss().runTaskLaterAsynchronously(() -> ref.set(null), 6000); // Expires after 5 minutes
+ user.setReplyRecipient(ref.get());
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/BukkitListener.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/BukkitListener.java
new file mode 100644
index 000000000..5e6c5f66b
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/BukkitListener.java
@@ -0,0 +1,191 @@
+package net.essentialsx.discord.listeners;
+
+import com.earth2me.essentials.Console;
+import com.earth2me.essentials.utils.DateUtil;
+import com.earth2me.essentials.utils.FormatUtil;
+import net.ess3.api.events.AfkStatusChangeEvent;
+import net.ess3.api.events.MuteStatusChangeEvent;
+import net.essentialsx.api.v2.events.AsyncUserDataLoadEvent;
+import net.essentialsx.api.v2.events.discord.DiscordChatMessageEvent;
+import net.essentialsx.api.v2.events.discord.DiscordMessageEvent;
+import net.essentialsx.api.v2.services.discord.MessageType;
+import net.essentialsx.discord.JDADiscordService;
+import net.essentialsx.discord.util.MessageUtil;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+import org.bukkit.event.Listener;
+import org.bukkit.event.entity.PlayerDeathEvent;
+import org.bukkit.event.player.AsyncPlayerChatEvent;
+import org.bukkit.event.player.PlayerKickEvent;
+import org.bukkit.event.player.PlayerQuitEvent;
+
+import java.text.MessageFormat;
+import java.util.UUID;
+
+public class BukkitListener implements Listener {
+ private final static String AVATAR_URL = "https://crafthead.net/helm/{uuid}";
+ private final JDADiscordService jda;
+
+ public BukkitListener(JDADiscordService jda) {
+ this.jda = jda;
+ }
+
+ /**
+ * Processes messages from all other events.
+ * This way it allows other plugins to modify route/message or just cancel it.
+ */
+ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+ public void onDiscordMessage(DiscordMessageEvent event) {
+ jda.sendMessage(event, event.getMessage(), event.isAllowGroupMentions());
+ }
+
+ // Bukkit Events
+
+ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+ public void onMute(MuteStatusChangeEvent event) {
+ if (!event.getValue()) {
+ sendDiscordMessage(MessageType.DefaultTypes.MUTE,
+ MessageUtil.formatMessage(jda.getSettings().getUnmuteFormat(),
+ MessageUtil.sanitizeDiscordMarkdown(event.getAffected().getName()),
+ MessageUtil.sanitizeDiscordMarkdown(event.getAffected().getDisplayName())));
+ } else if (event.getTimestamp().isPresent()) {
+ final boolean console = event.getController() == null;
+ final MessageFormat msg = event.getReason() == null ? jda.getSettings().getTempMuteFormat() : jda.getSettings().getTempMuteReasonFormat();
+ sendDiscordMessage(MessageType.DefaultTypes.MUTE,
+ MessageUtil.formatMessage(msg,
+ MessageUtil.sanitizeDiscordMarkdown(event.getAffected().getName()),
+ MessageUtil.sanitizeDiscordMarkdown(event.getAffected().getDisplayName()),
+ MessageUtil.sanitizeDiscordMarkdown(console ? Console.NAME : event.getController().getName()),
+ MessageUtil.sanitizeDiscordMarkdown(console ? Console.DISPLAY_NAME : event.getController().getDisplayName()),
+ DateUtil.formatDateDiff(event.getTimestamp().get()),
+ MessageUtil.sanitizeDiscordMarkdown(event.getReason())));
+ } else {
+ final boolean console = event.getController() == null;
+ final MessageFormat msg = event.getReason() == null ? jda.getSettings().getPermMuteFormat() : jda.getSettings().getPermMuteReasonFormat();
+ sendDiscordMessage(MessageType.DefaultTypes.MUTE,
+ MessageUtil.formatMessage(msg,
+ MessageUtil.sanitizeDiscordMarkdown(event.getAffected().getName()),
+ MessageUtil.sanitizeDiscordMarkdown(event.getAffected().getDisplayName()),
+ MessageUtil.sanitizeDiscordMarkdown(console ? Console.NAME : event.getController().getName()),
+ MessageUtil.sanitizeDiscordMarkdown(console ? Console.DISPLAY_NAME : event.getController().getDisplayName()),
+ MessageUtil.sanitizeDiscordMarkdown(event.getReason())));
+ }
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+ public void onChat(AsyncPlayerChatEvent event) {
+ final Player player = event.getPlayer();
+ Bukkit.getScheduler().runTask(jda.getPlugin(), () -> {
+ final DiscordChatMessageEvent chatEvent = new DiscordChatMessageEvent(event.getPlayer(), event.getMessage());
+ chatEvent.setCancelled(!jda.getSettings().isShowAllChat() && !event.getRecipients().containsAll(Bukkit.getOnlinePlayers()));
+ Bukkit.getPluginManager().callEvent(chatEvent);
+ if (chatEvent.isCancelled()) {
+ return;
+ }
+
+ sendDiscordMessage(MessageType.DefaultTypes.CHAT,
+ MessageUtil.formatMessage(jda.getSettings().getMcToDiscordFormat(player),
+ MessageUtil.sanitizeDiscordMarkdown(player.getName()),
+ MessageUtil.sanitizeDiscordMarkdown(player.getDisplayName()),
+ player.hasPermission("essentials.discord.markdown") ? chatEvent.getMessage() : MessageUtil.sanitizeDiscordMarkdown(chatEvent.getMessage()),
+ MessageUtil.sanitizeDiscordMarkdown(player.getWorld().getName()),
+ MessageUtil.sanitizeDiscordMarkdown(FormatUtil.stripEssentialsFormat(jda.getPlugin().getEss().getPermissionsHandler().getPrefix(player))),
+ MessageUtil.sanitizeDiscordMarkdown(FormatUtil.stripEssentialsFormat(jda.getPlugin().getEss().getPermissionsHandler().getSuffix(player)))),
+ player.hasPermission("essentials.discord.ping"),
+ jda.getSettings().isShowAvatar() ? AVATAR_URL.replace("{uuid}", player.getUniqueId().toString()) : null,
+ jda.getSettings().isShowName() ? player.getName() : null,
+ player.getUniqueId());
+ });
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onJoin(AsyncUserDataLoadEvent event) {
+ // Delay join to let nickname load
+ if (event.getJoinMessage() != null) {
+ sendDiscordMessage(MessageType.DefaultTypes.JOIN,
+ MessageUtil.formatMessage(jda.getSettings().getJoinFormat(event.getUser().getBase()),
+ MessageUtil.sanitizeDiscordMarkdown(event.getUser().getName()),
+ MessageUtil.sanitizeDiscordMarkdown(event.getUser().getDisplayName()),
+ MessageUtil.sanitizeDiscordMarkdown(event.getJoinMessage())),
+ false,
+ jda.getSettings().isShowAvatar() ? AVATAR_URL.replace("{uuid}", event.getUser().getBase().getUniqueId().toString()) : null,
+ jda.getSettings().isShowName() ? event.getUser().getName() : null,
+ event.getUser().getBase().getUniqueId());
+ }
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+ public void onQuit(PlayerQuitEvent event) {
+ if (event.getQuitMessage() != null) {
+ sendDiscordMessage(MessageType.DefaultTypes.LEAVE,
+ MessageUtil.formatMessage(jda.getSettings().getQuitFormat(event.getPlayer()),
+ MessageUtil.sanitizeDiscordMarkdown(event.getPlayer().getName()),
+ MessageUtil.sanitizeDiscordMarkdown(event.getPlayer().getDisplayName()),
+ MessageUtil.sanitizeDiscordMarkdown(event.getQuitMessage())),
+ false,
+ jda.getSettings().isShowAvatar() ? AVATAR_URL.replace("{uuid}", event.getPlayer().getUniqueId().toString()) : null,
+ jda.getSettings().isShowName() ? event.getPlayer().getName() : null,
+ event.getPlayer().getUniqueId());
+ }
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+ public void onDeath(PlayerDeathEvent event) {
+ sendDiscordMessage(MessageType.DefaultTypes.DEATH,
+ MessageUtil.formatMessage(jda.getSettings().getDeathFormat(event.getEntity()),
+ MessageUtil.sanitizeDiscordMarkdown(event.getEntity().getName()),
+ MessageUtil.sanitizeDiscordMarkdown(event.getEntity().getDisplayName()),
+ MessageUtil.sanitizeDiscordMarkdown(event.getDeathMessage())),
+ false,
+ jda.getSettings().isShowAvatar() ? AVATAR_URL.replace("{uuid}", event.getEntity().getUniqueId().toString()) : null,
+ jda.getSettings().isShowName() ? event.getEntity().getName() : null,
+ event.getEntity().getUniqueId());
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+ public void onAfk(AfkStatusChangeEvent event) {
+ final MessageFormat format;
+ if (event.getValue()) {
+ format = jda.getSettings().getAfkFormat(event.getAffected().getBase());
+ } else {
+ format = jda.getSettings().getUnAfkFormat(event.getAffected().getBase());
+ }
+
+ sendDiscordMessage(MessageType.DefaultTypes.AFK,
+ MessageUtil.formatMessage(format,
+ MessageUtil.sanitizeDiscordMarkdown(event.getAffected().getName()),
+ MessageUtil.sanitizeDiscordMarkdown(event.getAffected().getDisplayName())),
+ false,
+ jda.getSettings().isShowAvatar() ? AVATAR_URL.replace("{uuid}", event.getAffected().getBase().getUniqueId().toString()) : null,
+ jda.getSettings().isShowName() ? event.getAffected().getName() : null,
+ event.getAffected().getBase().getUniqueId());
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+ public void onKick(PlayerKickEvent event) {
+ sendDiscordMessage(MessageType.DefaultTypes.KICK,
+ MessageUtil.formatMessage(jda.getSettings().getKickFormat(),
+ MessageUtil.sanitizeDiscordMarkdown(event.getPlayer().getName()),
+ MessageUtil.sanitizeDiscordMarkdown(event.getPlayer().getDisplayName()),
+ MessageUtil.sanitizeDiscordMarkdown(event.getReason())));
+ }
+
+ private void sendDiscordMessage(final MessageType messageType, final String message) {
+ sendDiscordMessage(messageType, message, false, null, null, null);
+ }
+
+ private void sendDiscordMessage(final MessageType messageType, final String message, final boolean allowPing, final String avatarUrl, final String name, final UUID uuid) {
+ if (jda.getPlugin().getSettings().getMessageChannel(messageType.getKey()).equalsIgnoreCase("none")) {
+ return;
+ }
+
+ final DiscordMessageEvent event = new DiscordMessageEvent(messageType, FormatUtil.stripFormat(message), allowPing, avatarUrl, name, uuid);
+ if (Bukkit.getServer().isPrimaryThread()) {
+ Bukkit.getPluginManager().callEvent(event);
+ } else {
+ Bukkit.getScheduler().runTask(jda.getPlugin(), () -> Bukkit.getPluginManager().callEvent(event));
+ }
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/DiscordCommandDispatcher.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/DiscordCommandDispatcher.java
new file mode 100644
index 000000000..40757572d
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/DiscordCommandDispatcher.java
@@ -0,0 +1,31 @@
+package net.essentialsx.discord.listeners;
+
+import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent;
+import net.dv8tion.jda.api.hooks.ListenerAdapter;
+import net.essentialsx.discord.JDADiscordService;
+import net.essentialsx.discord.util.DiscordCommandSender;
+import org.bukkit.Bukkit;
+import org.jetbrains.annotations.NotNull;
+
+public class DiscordCommandDispatcher extends ListenerAdapter {
+ private final JDADiscordService jda;
+ private String channelId = null;
+
+ public DiscordCommandDispatcher(JDADiscordService jda) {
+ this.jda = jda;
+ }
+
+ @Override
+ public void onGuildMessageReceived(@NotNull GuildMessageReceivedEvent event) {
+ if (jda.getConsoleWebhook() != null && event.getChannel().getId().equals(channelId)
+ && !event.isWebhookMessage() && !event.getAuthor().isBot()) {
+ Bukkit.getScheduler().runTask(jda.getPlugin(), () ->
+ Bukkit.dispatchCommand(new DiscordCommandSender(jda, Bukkit.getConsoleSender(), message ->
+ event.getMessage().reply(message).queue()).getSender(), event.getMessage().getContentRaw()));
+ }
+ }
+
+ public void setChannelId(String channelId) {
+ this.channelId = channelId;
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/DiscordListener.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/DiscordListener.java
new file mode 100644
index 000000000..e63ee6212
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/listeners/DiscordListener.java
@@ -0,0 +1,101 @@
+package net.essentialsx.discord.listeners;
+
+import com.earth2me.essentials.utils.FormatUtil;
+import com.vdurmont.emoji.EmojiParser;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.Message;
+import net.dv8tion.jda.api.entities.User;
+import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent;
+import net.dv8tion.jda.api.hooks.ListenerAdapter;
+import net.ess3.api.IUser;
+import net.essentialsx.discord.JDADiscordService;
+import net.essentialsx.discord.util.DiscordUtil;
+import net.essentialsx.discord.util.MessageUtil;
+import org.apache.commons.lang.StringUtils;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+public class DiscordListener extends ListenerAdapter {
+ private final static Logger logger = Logger.getLogger("EssentialsDiscord");
+
+ private final JDADiscordService plugin;
+
+ public DiscordListener(JDADiscordService plugin) {
+ this.plugin = plugin;
+ }
+
+ @Override
+ public void onGuildMessageReceived(@NotNull GuildMessageReceivedEvent event) {
+ if (event.getAuthor().isBot() && !event.isWebhookMessage() && (!plugin.getSettings().isShowBotMessages() || event.getAuthor().getId().equals(plugin.getJda().getSelfUser().getId()))) {
+ return;
+ }
+
+ if (event.isWebhookMessage() && (!plugin.getSettings().isShowWebhookMessages() || DiscordUtil.ACTIVE_WEBHOOKS.contains(event.getAuthor().getId()))) {
+ return;
+ }
+
+ // Get list of channel names that have this channel id mapped
+ final List keys = plugin.getPlugin().getSettings().getKeysFromChannelId(event.getChannel().getIdLong());
+ if (keys == null || keys.size() == 0) {
+ if (plugin.isDebug()) {
+ logger.log(Level.INFO, "Skipping message due to no channel keys for id " + event.getChannel().getIdLong() + "!");
+ }
+ return;
+ }
+
+ final User user = event.getAuthor();
+ final Member member = event.getMember();
+ final Message message = event.getMessage();
+
+ assert member != null; // Member will never be null
+
+ if (plugin.getSettings().getDiscordFilter() != null && plugin.getSettings().getDiscordFilter().matcher(message.getContentDisplay()).find()) {
+ if (plugin.isDebug()) {
+ logger.log(Level.INFO, "Skipping message " + message.getId() + " with content, \"" + message.getContentDisplay() + "\" as it matched the filter!");
+ }
+ return;
+ }
+
+ final StringBuilder messageBuilder = new StringBuilder(message.getContentDisplay());
+ if (plugin.getPlugin().getSettings().isShowDiscordAttachments()) {
+ for (final Message.Attachment attachment : message.getAttachments()) {
+ messageBuilder.append(" ").append(attachment.getUrl());
+ }
+ }
+
+ // Strip message
+ final String strippedMessage = StringUtils.abbreviate(
+ messageBuilder.toString()
+ .replace(plugin.getSettings().isChatFilterNewlines() ? '\n' : ' ', ' ')
+ .trim(), plugin.getSettings().getChatDiscordMaxLength());
+
+ // Apply or strip color formatting
+ final String finalMessage = DiscordUtil.hasRoles(member, plugin.getPlugin().getSettings().getPermittedFormattingRoles()) ?
+ FormatUtil.replaceFormat(strippedMessage) : FormatUtil.stripFormat(strippedMessage);
+
+ // Don't send blank messages
+ if (finalMessage.trim().length() == 0) {
+ if (plugin.isDebug()) {
+ logger.log(Level.INFO, "Skipping finalized empty message " + message.getId());
+ }
+ return;
+ }
+
+ final String formattedMessage = EmojiParser.parseToAliases(MessageUtil.formatMessage(plugin.getPlugin().getSettings().getDiscordToMcFormat(),
+ event.getChannel().getName(), user.getName(), user.getDiscriminator(), user.getAsTag(),
+ member.getEffectiveName(), DiscordUtil.getRoleColorFormat(member), finalMessage), EmojiParser.FitzpatrickAction.REMOVE);
+
+ for (IUser essUser : plugin.getPlugin().getEss().getOnlineUsers()) {
+ for (String group : keys) {
+ final String perm = "essentials.discord.receive." + group;
+ final boolean primaryOverride = plugin.getSettings().isAlwaysReceivePrimary() && group.equalsIgnoreCase("primary");
+ if (primaryOverride || (essUser.isPermissionSet(perm) && essUser.isAuthorized(perm))) {
+ essUser.sendMessage(formattedMessage);
+ }
+ }
+ }
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/ConsoleInjector.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/ConsoleInjector.java
new file mode 100644
index 000000000..8b33315ab
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/ConsoleInjector.java
@@ -0,0 +1,89 @@
+package net.essentialsx.discord.util;
+
+import com.earth2me.essentials.utils.FormatUtil;
+import com.google.common.base.Splitter;
+import net.dv8tion.jda.api.entities.Message;
+import net.essentialsx.discord.JDADiscordService;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.Logger;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.plugins.Plugin;
+import org.bukkit.Bukkit;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+
+import static com.earth2me.essentials.I18n.tl;
+
+@Plugin(name = "EssentialsX-ConsoleInjector", category = "Core", elementType = "appender", printObject = true)
+public class ConsoleInjector extends AbstractAppender {
+ private final static java.util.logging.Logger logger = java.util.logging.Logger.getLogger("EssentialsDiscord");
+
+ private final JDADiscordService jda;
+ private final BlockingQueue messageQueue = new LinkedBlockingQueue<>();
+ private final SimpleDateFormat timestampFormat = new SimpleDateFormat("HH:mm:ss");
+ private final int taskId;
+
+ public ConsoleInjector(JDADiscordService jda) {
+ super("EssentialsX-ConsoleInjector", null, null, false);
+ this.jda = jda;
+ ((Logger) LogManager.getRootLogger()).addAppender(this);
+ taskId = Bukkit.getScheduler().runTaskTimerAsynchronously(jda.getPlugin(), () -> {
+ final StringBuilder buffer = new StringBuilder();
+ String curLine;
+ while ((curLine = messageQueue.peek()) != null) {
+ if (buffer.length() + curLine.length() > Message.MAX_CONTENT_LENGTH - 2) {
+ sendMessage(buffer.toString());
+ buffer.setLength(0);
+ continue;
+ }
+ buffer.append("\n").append(messageQueue.poll());
+ }
+ if (buffer.length() != 0) {
+ sendMessage(buffer.toString());
+ }
+ }, 20, 40).getTaskId();
+ }
+
+ private void sendMessage(String content) {
+ jda.getConsoleWebhook().send(jda.getWebhookMessage(content)).exceptionally(e -> {
+ logger.severe(tl("discordErrorWebhook"));
+ remove();
+ return null;
+ });
+ }
+
+ @Override
+ public void append(LogEvent event) {
+ if (event.getLevel().intLevel() > jda.getSettings().getConsoleLogLevel().intLevel()) {
+ return;
+ }
+
+ // Ansi strip is for normal colors, normal strip is for 1.16 hex color codes as they are not formatted correctly
+ String entry = FormatUtil.stripFormat(FormatUtil.stripAnsi(event.getMessage().getFormattedMessage())).trim();
+ if (entry.isEmpty()) {
+ return;
+ }
+
+ final String loggerName = event.getLoggerName();
+ if (!loggerName.isEmpty() && !loggerName.contains(".")) {
+ entry = "[" + event.getLoggerName() + "] " + entry;
+ }
+
+ //noinspection UnstableApiUsage
+ messageQueue.addAll(Splitter.fixedLength(Message.MAX_CONTENT_LENGTH).splitToList(
+ MessageUtil.formatMessage(jda.getSettings().getConsoleFormat(),
+ timestampFormat.format(new Date()),
+ event.getLevel().name(),
+ MessageUtil.sanitizeDiscordMarkdown(entry))));
+ }
+
+ public void remove() {
+ ((Logger) LogManager.getRootLogger()).removeAppender(this);
+ Bukkit.getScheduler().cancelTask(taskId);
+ messageQueue.clear();
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/DiscordCommandSender.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/DiscordCommandSender.java
new file mode 100644
index 000000000..8bf7c5dec
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/DiscordCommandSender.java
@@ -0,0 +1,46 @@
+package net.essentialsx.discord.util;
+
+import com.earth2me.essentials.utils.FormatUtil;
+import com.earth2me.essentials.utils.VersionUtil;
+import net.ess3.provider.providers.BukkitSenderProvider;
+import net.ess3.provider.providers.PaperCommandSender;
+import net.essentialsx.discord.JDADiscordService;
+import org.bukkit.Bukkit;
+import org.bukkit.command.ConsoleCommandSender;
+import org.bukkit.scheduler.BukkitTask;
+
+public class DiscordCommandSender {
+ private final BukkitSenderProvider sender;
+ private BukkitTask task;
+ private String responseBuffer = "";
+ private long lastTime = System.currentTimeMillis();
+
+ public DiscordCommandSender(JDADiscordService jda, ConsoleCommandSender sender, CmdCallback callback) {
+ final BukkitSenderProvider.MessageHook hook = message -> {
+ responseBuffer = responseBuffer + (responseBuffer.isEmpty() ? "" : "\n") + MessageUtil.sanitizeDiscordMarkdown(FormatUtil.stripFormat(message));
+ lastTime = System.currentTimeMillis();
+ };
+ this.sender = (VersionUtil.isPaper() && VersionUtil.getServerBukkitVersion().isHigherThanOrEqualTo(VersionUtil.v1_16_5_R01)) ? new PaperCommandSender(sender, hook) : new BukkitSenderProvider(sender, hook);
+
+ task = Bukkit.getScheduler().runTaskTimerAsynchronously(jda.getPlugin(), () -> {
+ if (!responseBuffer.isEmpty() && System.currentTimeMillis() - lastTime >= 1000) {
+ callback.onMessage(responseBuffer);
+ responseBuffer = "";
+ lastTime = System.currentTimeMillis();
+ return;
+ }
+
+ if (System.currentTimeMillis() - lastTime >= 20000) {
+ task.cancel();
+ }
+ }, 0, 20);
+ }
+
+ public interface CmdCallback {
+ void onMessage(String message);
+ }
+
+ public BukkitSenderProvider getSender() {
+ return sender;
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/DiscordMessageRecipient.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/DiscordMessageRecipient.java
new file mode 100644
index 000000000..c047917c6
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/DiscordMessageRecipient.java
@@ -0,0 +1,75 @@
+package net.essentialsx.discord.util;
+
+import com.earth2me.essentials.messaging.IMessageRecipient;
+import com.earth2me.essentials.utils.FormatUtil;
+import net.essentialsx.api.v2.services.discord.InteractionMember;
+import org.bukkit.entity.Player;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static com.earth2me.essentials.I18n.tl;
+
+public class DiscordMessageRecipient implements IMessageRecipient {
+ private final InteractionMember member;
+ private final AtomicBoolean died = new AtomicBoolean(false);
+
+ public DiscordMessageRecipient(InteractionMember member) {
+ this.member = member;
+ }
+
+ @Override
+ public void sendMessage(String message) {
+ }
+
+ @Override
+ public MessageResponse sendMessage(IMessageRecipient recipient, String message) {
+ return MessageResponse.UNREACHABLE;
+ }
+
+ @Override
+ public MessageResponse onReceiveMessage(IMessageRecipient sender, String message) {
+ if (died.get()) {
+ sender.setReplyRecipient(null);
+ return MessageResponse.UNREACHABLE;
+ }
+
+ final String cleanMessage = MessageUtil.sanitizeDiscordMarkdown(FormatUtil.stripFormat(message));
+
+ member.sendPrivateMessage(tl("replyFromDiscord", sender.getName(), cleanMessage)).thenAccept(success -> {
+ if (!success) {
+ died.set(true);
+ }
+ });
+ return MessageResponse.SUCCESS;
+ }
+
+ @Override
+ public String getName() {
+ return member.getTag();
+ }
+
+ @Override
+ public String getDisplayName() {
+ return member.getTag();
+ }
+
+ @Override
+ public boolean isReachable() {
+ return !died.get();
+ }
+
+ @Override
+ public IMessageRecipient getReplyRecipient() {
+ return null;
+ }
+
+ @Override
+ public void setReplyRecipient(IMessageRecipient recipient) {
+
+ }
+
+ @Override
+ public boolean isHiddenFrom(Player player) {
+ return died.get();
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/DiscordUtil.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/DiscordUtil.java
new file mode 100644
index 000000000..0daa82c3e
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/DiscordUtil.java
@@ -0,0 +1,168 @@
+package net.essentialsx.discord.util;
+
+import club.minnced.discord.webhook.WebhookClient;
+import club.minnced.discord.webhook.WebhookClientBuilder;
+import club.minnced.discord.webhook.send.AllowedMentions;
+import com.earth2me.essentials.utils.DownsampleUtil;
+import com.earth2me.essentials.utils.FormatUtil;
+import com.earth2me.essentials.utils.VersionUtil;
+import com.google.common.collect.ImmutableList;
+import net.dv8tion.jda.api.Permission;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.Message;
+import net.dv8tion.jda.api.entities.Role;
+import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.Webhook;
+import okhttp3.OkHttpClient;
+
+import java.awt.Color;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.Consumer;
+
+public final class DiscordUtil {
+ public final static List NO_GROUP_MENTIONS;
+ public final static AllowedMentions ALL_MENTIONS_WEBHOOK = AllowedMentions.all();
+ public final static AllowedMentions NO_GROUP_MENTIONS_WEBHOOK = new AllowedMentions().withParseEveryone(false).withParseRoles(false).withParseUsers(true);
+ public final static CopyOnWriteArrayList ACTIVE_WEBHOOKS = new CopyOnWriteArrayList<>();
+
+ static {
+ final ImmutableList.Builder types = new ImmutableList.Builder<>();
+ types.add(Message.MentionType.USER);
+ types.add(Message.MentionType.CHANNEL);
+ types.add(Message.MentionType.EMOTE);
+ NO_GROUP_MENTIONS = types.build();
+ }
+
+ private DiscordUtil() {
+ }
+
+ /**
+ * Creates a {@link WebhookClient}.
+ *
+ * @param id The id of the webhook.
+ * @param token The token of the webhook.
+ * @param client The http client of the webhook.
+ * @return The {@link WebhookClient}.
+ */
+ public static WebhookClient getWebhookClient(long id, String token, OkHttpClient client) {
+ return new WebhookClientBuilder(id, token)
+ .setAllowedMentions(AllowedMentions.none())
+ .setHttpClient(client)
+ .setDaemon(true)
+ .build();
+ }
+
+ /**
+ * Gets and cleans webhooks with the given name from channels other than the specified one.
+ *
+ * @param channel The channel to search for webhooks in.
+ * @param webhookName The name of the webhook to validate it.
+ *
+ * @return A future which completes with the webhook by the given name in the given channel, if present, otherwise null.
+ */
+ public static CompletableFuture getAndCleanWebhooks(final TextChannel channel, final String webhookName) {
+ final Member self = channel.getGuild().getSelfMember();
+
+ final CompletableFuture future = new CompletableFuture<>();
+ final Consumer> consumer = webhooks -> {
+ boolean foundWebhook = false;
+ for (final Webhook webhook : webhooks) {
+ if (webhook.getName().equalsIgnoreCase(webhookName)) {
+ if (foundWebhook || !webhook.getChannel().equals(channel)) {
+ ACTIVE_WEBHOOKS.remove(webhook.getId());
+ webhook.delete().reason("EssX Webhook Cleanup").queue();
+ continue;
+ }
+ ACTIVE_WEBHOOKS.addIfAbsent(webhook.getId());
+ future.complete(webhook);
+ foundWebhook = true;
+ }
+ }
+
+ if (!foundWebhook) {
+ future.complete(null);
+ }
+ };
+
+ if (self.hasPermission(Permission.MANAGE_WEBHOOKS)) {
+ channel.getGuild().retrieveWebhooks().queue(consumer);
+ } else if (self.hasPermission(channel, Permission.MANAGE_WEBHOOKS)) {
+ channel.retrieveWebhooks().queue(consumer);
+ } else {
+ return CompletableFuture.completedFuture(null);
+ }
+
+ return future;
+ }
+
+ /**
+ * Creates a webhook with the given name in the given channel.
+ *
+ * @param channel The channel to search for webhooks in.
+ * @param webhookName The name of the webhook to look for.
+ * @return A future which completes with the webhook by the given name in the given channel or null if no permissions.
+ */
+ public static CompletableFuture createWebhook(TextChannel channel, String webhookName) {
+ if (!channel.getGuild().getSelfMember().hasPermission(channel, Permission.MANAGE_WEBHOOKS)) {
+ return CompletableFuture.completedFuture(null);
+ }
+
+ final CompletableFuture future = new CompletableFuture<>();
+ channel.createWebhook(webhookName).queue(webhook -> {
+ future.complete(webhook);
+ ACTIVE_WEBHOOKS.addIfAbsent(webhook.getId());
+ });
+ return future;
+ }
+
+ /**
+ * Gets the uppermost bukkit color code of a given member or an empty string if the server version is < 1.16.
+ *
+ * @param member The target member.
+ * @return The bukkit color code or blank string.
+ */
+ public static String getRoleColorFormat(Member member) {
+ final Color color = member.getColor();
+
+ if (color == null) {
+ return "";
+ }
+
+ if (VersionUtil.getServerBukkitVersion().isHigherThanOrEqualTo(VersionUtil.v1_16_1_R01)) {
+ // Essentials' FormatUtil allows us to not have to use bungee's chatcolor since bukkit's own one doesn't support rgb
+ return FormatUtil.replaceFormat("" + Integer.toHexString(color.getRGB()).substring(2));
+ }
+ return FormatUtil.replaceFormat("&" + DownsampleUtil.nearestTo(color.getRGB()));
+ }
+
+ /**
+ * Checks is the supplied user has at least one of the supplied roles.
+ *
+ * @param member The member to check.
+ * @param roleDefinitions A list with either the name or id of roles.
+ * @return true if member has role.
+ */
+ public static boolean hasRoles(Member member, List roleDefinitions) {
+ if (member.hasPermission(Permission.ADMINISTRATOR)) {
+ return true;
+ }
+
+ final List roles = member.getRoles();
+ for (String roleDefinition : roleDefinitions) {
+ roleDefinition = roleDefinition.trim();
+
+ if (roleDefinition.equals("*") || member.getId().equals(roleDefinition)) {
+ return true;
+ }
+
+ for (final Role role : roles) {
+ if (role.getId().equals(roleDefinition) || role.getName().equalsIgnoreCase(roleDefinition)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+}
diff --git a/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/MessageUtil.java b/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/MessageUtil.java
new file mode 100644
index 000000000..e84f3f2fb
--- /dev/null
+++ b/EssentialsDiscord/src/main/java/net/essentialsx/discord/util/MessageUtil.java
@@ -0,0 +1,31 @@
+package net.essentialsx.discord.util;
+
+import java.text.MessageFormat;
+
+public final class MessageUtil {
+ private MessageUtil() {
+ }
+
+ /**
+ * Sanitizes text to be sent to Discord, escaping any Markdown syntax.
+ */
+ public static String sanitizeDiscordMarkdown(String message) {
+ if (message == null) {
+ return null;
+ }
+
+ return message.replace("*", "\\*")
+ .replace("~", "\\~")
+ .replace("_", "\\_")
+ .replace("`", "\\`")
+ .replace(">", "\\>")
+ .replace("|", "\\|");
+ }
+
+ /**
+ * Shortcut method allowing for use of varags in {@link MessageFormat} instances
+ */
+ public static String formatMessage(MessageFormat format, Object... args) {
+ return format.format(args);
+ }
+}
diff --git a/EssentialsDiscord/src/main/resources/config.yml b/EssentialsDiscord/src/main/resources/config.yml
new file mode 100644
index 000000000..78d0916fe
--- /dev/null
+++ b/EssentialsDiscord/src/main/resources/config.yml
@@ -0,0 +1,299 @@
+#############################################################
+# +-------------------------------------------------------+ #
+# | EssentialsX Discord | #
+# +-------------------------------------------------------+ #
+#############################################################
+
+# This is the config file for EssentialsX Discord.
+# This config was generated for version ${full.version}.
+
+# You need to create a bot user in order to connect your server to Discord.
+# You can find instructions on this here: https://essentialsx.net/discord-tutorial
+
+# The token for your bot from the Discord Developers site.
+# Please make sure to use this site to add the bot to your server as it grants special permissions you may not be familiar with: https://essentialsx.net/discord.html
+token: "INSERT-TOKEN-HERE"
+
+# The ID of your server.
+guild: 000000000000000000
+
+# Defined text channels
+# =====================
+#
+# Channels defined here can be used for two different purposes:
+#
+# Firstly, channels defined here can be used to give players permission to see messages from said channel.
+# This can be done by give your players the permission node "essentials.discord.receive.".
+# For example, if you wanted to let a player see messages from the primary channel, you'd give them "essentials.discord.receive.primary".
+#
+# Secondly, channels defined here can be used in the section below to specify which channel a message goes to.
+# If a defined channel ID is invalid, the primary channel will be used as a fallback.
+# If the primary channel is not defined or invalid, the default channel of the server will be used.
+# If your server doesn't have any text channels, the plugin will be disabled.
+# By default, two channels are defined:
+# - primary, which will send basic join/leave/death/chat messages
+# - staff, which will send kick/mute messages
+# (note: you will need to replace the zeros with the actual channel ID you want to use)
+channels:
+ primary: 000000000000000000
+ staff: 000000000000000000
+
+# Should all players receive Discord messages from the primary channel, regardless of their permissions?
+# This is intended for use for people without permission plugins. If you have a permission plugin, please give your
+# players the essentials.discord.receive.primary permission.
+always-receive-primary: false
+
+# Chat relay settings
+# General settings for chat relays between Minecraft and Discord.
+# To configure the channel Minecraft chat is sent to, see the "message-types" section of the config.
+chat:
+ # The maximum amount of characters messages from Discord should be before being truncated.
+ discord-max-length: 2000
+ # Whether or not new lines from Discord should be filtered or not.
+ filter-newlines: true
+ # A regex pattern which will not send matching messages through to Discord.
+ # By default, this will ignore messages starting with '!' and '?'.
+ discord-filter: "^[!?]"
+ # Whether or not webhook messages from Discord should be shown in Minecraft.
+ show-webhook-messages: false
+ # Whether or not bot messages from Discord should be shown in Minecraft.
+ show-bot-messages: false
+ # Whether or not to show all Minecraft chat messages that are not shown to all players.
+ # You shouldn't need to enable this unless you're not seeing all chat messages go through to Discord.
+ show-all-chat: false
+
+# Console relay settings
+# The console relay sends every message shown in the console to a Discord channel.
+console:
+ # The channel ID (or webhook URL) to send the console output to.
+ # If the channel ID/webhook URL is invalid or set to 'none', the console relay will be disabled.
+ # Note: If you use a channel ID, the bot must have the "Manage Webhooks" permission in Discord or else the console relay will not work.
+ channel: 000000000000000000
+ # The format of the console message. The following placeholders can be used:
+ # - {timestamp}: Timestamp in HH:mm:ss format
+ # - {level}: The console logging level
+ # - {message}: The actual message being logged
+ format: "[{timestamp} {level}] {message}"
+ # The name of the webhook that will be created, if a channel ID is provided.
+ webhook-name: "EssentialsX Console Relay"
+ # Set to true if all messages in the console relay channel should be treated as commands.
+ # Note: Enabling this means everyone who can send messages in the console channel will be able to send commands as the
+ # console. It's recommended you stick to the /execute command which has role permission checks (see command configuration below).
+ # Note 2: This option requires a channel ID and is not supported if you specify a webhook URL above. You'll need to use /execute in Discord if you use a webhook URL.
+ command-relay: false
+ # The maximum log level of messages to send to the console relay.
+ # The following is a list of available log levels in order of lowest to highest.
+ # Changing the log level will send all log levels above it to the console relay.
+ # For example, setting this to 'info' will display log messages with info, warn, error, and fatal levels.
+ # Log Levels:
+ # - fatal
+ # - error
+ # - warn
+ # - info
+ # - debug
+ # - trace
+ log-level: info
+
+# Configure which Discord channels different messages will be sent to.
+# You can either use the names of the channels listed above or just the id of a channel.
+# If an invalid channel is used, the primary channel will be used instead.
+#
+# To disable a message from showing, use 'none' as the channel name.
+message-types:
+ # Join messages sent when a player joins the Minecraft server.
+ join: primary
+ # Leave messages sent when a player leaves the Minecraft server.
+ leave: primary
+ # Chat messages sent when a player chats on the Minecraft server.
+ chat: primary
+ # Death messages sent when a player dies on the Minecraft server.
+ death: primary
+ # AFK status change messages sent when a player's AFK status changes.
+ afk: primary
+ # Message sent when a player is kicked from the Minecraft server.
+ kick: staff
+ # Message sent when a player's mute state is changed on the Minecraft server.
+ mute: staff
+
+# Whether or not player messages should show their avatar as the profile picture in Discord.
+show-avatar: false
+# Whether or not player messages should show their name as the bot name in Discord.
+show-name: false
+
+# Settings pertaining to the varies commands registered by EssentialsX on Discord.
+commands:
+ # The execute command allows for discord users to execute MC commands from Discord.
+ # MC commands executed by this will be ran as the console and you should therefore be careful to who you grant this to.
+ execute:
+ # Set to false if you do not want this command to show up on the Discord command selector.
+ # You must restart your server after changing this.
+ enabled: true
+ # Whether or not the command should be hidden from other people in the channel.
+ # If set to false, members of the Discord guild will be able to see the exact command you executed as well as its response.
+ hide-command: true
+ # List of user IDs or role names/IDs allowed to use this command (or * to allow anyone to access it).
+ allowed-roles:
+ - "Admins"
+ - "123456789012345678"
+ # The msg command allows for Discord users to message MC players from Discord.
+ msg:
+ # Set to false if you do not want this command to show up on the Discord command selector.
+ # You must restart your server after changing this.
+ enabled: true
+ # Whether or not the command should be hidden from other people in the channel.
+ # If set to false, members of the Discord guild will be able to see the target and content of your message.
+ hide-command: true
+ # List of user IDs or role names/IDs allowed to use this command (or '*' to allow anyone to access it).
+ allowed-roles:
+ - "*"
+ # List of user IDs or role names/IDs who can message vanished players or players who disable messages. If '*' is
+ # used, all people on Discord would be allowed to message vanished players (and therefore expose they are actually online)
+ # and message players who disable messages.
+ admin-roles:
+ - "Admins"
+ - "123456789012345678"
+ # The list command allows Discord users to see a list of players currently online on Minecraft.
+ list:
+ # Set to false if you do not want this command to show up on the Discord command selector.
+ # You must restart your server after changing this.
+ enabled: true
+ # Whether or not the command should be hidden from other people in the channel.
+ # If set to false, members of the Discord guild will be able to see when you use this command as well as its response.
+ hide-command: true
+ # List of user IDs or role names/IDs allowed to use this command (or '*' to allow anyone to access it).
+ allowed-roles:
+ - "*"
+ # List of user IDs or role names/IDs who can see vanished players in the player list. If '*' is used, all people
+ # on Discord would be allowed to see vanished players (and therefore expose they are actually online).
+ admin-roles:
+ - "Admins"
+ - "123456789012345678"
+
+# Whether or not links to attachments in Discord messages should be displayed in chat or not.
+# If this is set to false and a message from Discord only contains an image/file and not any text, nothing will be sent.
+show-discord-attachments: true
+
+# A list of roles allowed to send Minecraft color/formatting codes from Discord to MC.
+# This applies to all aspects such as that Discord->MC chat relay as well as commands.
+# You can either use '*' (for everyone), a role name/ID, or a user ID.
+permit-formatting-roles:
+ - "Admins"
+ - "Color Codes"
+
+# The presence of the bot, including its status, activity and status message.
+presence:
+ # The online status of the bot. Must be one of the following:
+ # - "online": Shows as green circle (Online)
+ # - "idle": Shows as yellow half-circle (Away)
+ # - "dnd": Shows as red circle (Do Not Disturb)
+ # - "invisible": Makes the bot appear offline
+ status: online
+ # The activity of the bot to be prefixed before your message below. Must be one of the following;
+ # - "playing": Shows up as "Playing "
+ # - "listening": Shows up as "Listening to "
+ # - "watching": Shows up as "Watching "
+ # - "competing": Shows up as "Competing in "
+ # - "none": Don't show any activity message
+ activity: "playing"
+ # The activity status message.
+ message: "Minecraft"
+
+# The following entries allow you to customize the formatting of messages sent by the plugin.
+# Each message has a description of how it is used along with placeholders that can be used.
+messages:
+ # This is the message that is used to show discord chat to players in game.
+ # Color/formatting codes and the follow placeholders can be used here:
+ # - {channel}: The name of the discord channel the message was sent from
+ # - {username}: The username of the user who sent the message
+ # - {discriminator}: The four numbers displayed after the user's name
+ # - {fullname}: Equivalent to typing "{username}#{discriminator}"
+ # - {nickname}: The nickname of the user who sent the message. (Will return username if user has no nickname)
+ # - {color}: The minecraft color representative of the user's topmost role color on discord. If the user doesn't have a role color, the placeholder is empty.
+ # - {message}: The content of the message being sent
+ discord-to-mc: "&6[#{channel}] &3{fullname}&7: &f{message}"
+ # This is the message that is used to relay minecraft chat in discord.
+ # The following placeholders can be used here:
+ # - {username}: The username of the player sending the message
+ # - {displayname}: The display name of the player sending the message (This would be their nickname)
+ # - {message}: The content of the message being sent
+ # - {world}: The name of the world the player sending the message is in
+ # - {prefix}: The prefix of the player sending the message
+ # - {suffix}: The suffix of the player sending the message
+ # ... PlaceholderAPI placeholders are also supported here too!
+ mc-to-discord: "{displayname}: {message}"
+ # This is the message sent to discord when a player is temporarily muted in minecraft.
+ # The following placeholders can be used here:
+ # - {username}: The username of the player being muted
+ # - {displayname}: The display name of the player being muted
+ # - {controllername}: The username of the user who muted the player
+ # - {controllerdisplayname}: The display name of the user who muted the player
+ # - {time}: The amount of time the player was muted for
+ temporary-mute: "{controllerdisplayname} has muted player {displayname} for {time}."
+ # This is the message sent to discord when a player is temporarily muted (with a reason specified) in minecraft.
+ # The following placeholders can be used here:
+ # - {username}: The username of the player being muted
+ # - {displayname}: The display name of the player being muted
+ # - {controllername}: The username of the user who muted the player
+ # - {controllerdisplayname}: The display name of the user who muted the player
+ # - {time}: The amount of time the player was muted for
+ # - {reason}: The reason the player was muted for
+ temporary-mute-reason: "{controllerdisplayname} has muted player {displayname} for {time}. Reason: {reason}."
+ # This is the message sent to discord when a player is permanently muted in minecraft.
+ # The following placeholders can be used here:
+ # - {username}: The username of the player being muted
+ # - {displayname}: The display name of the player being muted
+ # - {controllername}: The username of the user who muted the player
+ # - {controllerdisplayname}: The display name of the user who muted the player
+ permanent-mute: "{controllerdisplayname} has muted player {displayname}."
+ # This is the message sent to discord when a player is permanently muted (with a reason specified) in minecraft.
+ # The following placeholders can be used here:
+ # - {username}: The username of the player being muted
+ # - {displayname}: The display name of the player being muted
+ # - {controllername}: The username of the user who muted the player
+ # - {controllerdisplayname}: The display name of the user who muted the player
+ # - {reason}: The reason the player was muted for
+ permanent-mute-reason: "{controllerdisplayname} has permanently muted player {displayname}. Reason: {reason}."
+ # This is the message sent to discord when a player is unmuted in minecraft.
+ # The following placeholders can be used here:
+ # - {username}: The username of the player being unmuted
+ # - {displayname}: The display name of the player being unmuted
+ unmute: "{displayname} unmuted."
+ # This is the message sent to discord when a player joins the minecraft server.
+ # The following placeholders can be used here:
+ # - {username}: The name of the user joining
+ # - {displayname}: The display name of the user joining
+ # - {joinmessage}: The full default join message used in game
+ # ... PlaceholderAPI placeholders are also supported here too!
+ join: ":arrow_right: {displayname} has joined!"
+ # This is the message sent to discord when a player leaves the minecraft server.
+ # The following placeholders can be used here:
+ # - {username}: The name of the user leaving
+ # - {displayname}: The display name of the user leaving
+ # - {quitmessage}: The full default leave message used in game
+ # ... PlaceholderAPI placeholders are also supported here too!
+ quit: ":arrow_left: {displayname} has left!"
+ # This is the message sent to discord when a player dies.
+ # The following placeholders can be used here:
+ # - {username}: The name of the user who died
+ # - {displayname}: The display name of the user who died
+ # - {deathmessage}: The full default death message used in game
+ # ... PlaceholderAPI placeholders are also supported here too!
+ death: ":skull: {deathmessage}"
+ # This is the message sent to discord when a player becomes afk.
+ # The following placeholders can be used here:
+ # - {username}: The name of the user who became afk
+ # - {displayname}: The display name of the user who became afk
+ # ... PlaceholderAPI placeholders are also supported here too!
+ afk: ":person_walking: {displayname} is now AFK!"
+ # This is the message sent to discord when a player is no longer afk.
+ # The following placeholders can be used here:
+ # - {username}: The name of the user who is no longer afk
+ # - {displayname}: The display name of the user who is no longer afk
+ # ... PlaceholderAPI placeholders are also supported here too!
+ un-afk: ":keyboard: {displayname} is no longer AFK!"
+ # This is the message sent to discord when a player is kicked from the server.
+ # The following placeholders can be used here:
+ # - {username}: The name of the user who got kicked
+ # - {displayname}: The display name of the user who got kicked
+ # - {reason}: The reason the player was kicked
+ kick: "{displayname} was kicked with reason: {reason}"
diff --git a/EssentialsDiscord/src/main/resources/plugin.yml b/EssentialsDiscord/src/main/resources/plugin.yml
new file mode 100644
index 000000000..32679b7db
--- /dev/null
+++ b/EssentialsDiscord/src/main/resources/plugin.yml
@@ -0,0 +1,10 @@
+name: EssentialsDiscord
+main: net.essentialsx.discord.EssentialsDiscord
+# Note to developers: This next line cannot change, or the automatic versioning system will break.
+version: ${full.version}
+website: https://essentialsx.net/
+description: Provides integration between Minecraft and Discord servers.
+authors: [mdcfe, JRoy, pop4959, Glare]
+depend: [Essentials]
+softdepend: [EssentialsChat, PlaceholderAPI]
+api-version: 1.13
diff --git a/providers/BaseProviders/src/main/java/net/ess3/provider/providers/BukkitSenderProvider.java b/providers/BaseProviders/src/main/java/net/ess3/provider/providers/BukkitSenderProvider.java
new file mode 100644
index 000000000..9496f9091
--- /dev/null
+++ b/providers/BaseProviders/src/main/java/net/ess3/provider/providers/BukkitSenderProvider.java
@@ -0,0 +1,150 @@
+package net.ess3.provider.providers;
+
+import net.md_5.bungee.api.chat.BaseComponent;
+import net.md_5.bungee.api.chat.TextComponent;
+import org.bukkit.Server;
+import org.bukkit.command.CommandSender;
+import org.bukkit.command.ConsoleCommandSender;
+import org.bukkit.permissions.Permission;
+import org.bukkit.permissions.PermissionAttachment;
+import org.bukkit.permissions.PermissionAttachmentInfo;
+import org.bukkit.plugin.Plugin;
+
+import java.util.Set;
+import java.util.UUID;
+
+public class BukkitSenderProvider implements CommandSender {
+ private final ConsoleCommandSender base;
+ private final MessageHook hook;
+
+ public BukkitSenderProvider(ConsoleCommandSender base, MessageHook hook) {
+ this.base = base;
+ this.hook = hook;
+ }
+
+ public interface MessageHook {
+ void sendMessage(String message);
+ }
+
+ @Override
+ public void sendMessage(String message) {
+ hook.sendMessage(message);
+ }
+
+ @Override
+ public void sendMessage(String[] messages) {
+ for (String msg : messages) {
+ sendMessage(msg);
+ }
+ }
+
+ @Override
+ public void sendMessage(UUID uuid, String message) {
+ sendMessage(message);
+ }
+
+ @Override
+ public void sendMessage(UUID uuid, String[] messages) {
+ sendMessage(messages);
+ }
+
+ @Override
+ public Server getServer() {
+ return base.getServer();
+ }
+
+ @Override
+ public String getName() {
+ return base.getName();
+ }
+
+ @Override
+ public Spigot spigot() {
+ return new Spigot() {
+ @Override
+ public void sendMessage(BaseComponent component) {
+ BukkitSenderProvider.this.sendMessage(component.toLegacyText());
+ }
+
+ @Override
+ public void sendMessage(BaseComponent... components) {
+ sendMessage(new TextComponent(components));
+ }
+
+ @Override
+ public void sendMessage(UUID sender, BaseComponent... components) {
+ sendMessage(components);
+ }
+
+ @Override
+ public void sendMessage(UUID sender, BaseComponent component) {
+ sendMessage(component);
+ }
+ };
+ }
+
+ @Override
+ public boolean isPermissionSet(String name) {
+ return base.isPermissionSet(name);
+ }
+
+ @Override
+ public boolean isPermissionSet(Permission perm) {
+ return base.isPermissionSet(perm);
+ }
+
+ @Override
+ public boolean hasPermission(String name) {
+ return base.hasPermission(name);
+ }
+
+ @Override
+ public boolean hasPermission(Permission perm) {
+ return base.hasPermission(perm);
+ }
+
+ @Override
+ public PermissionAttachment addAttachment(Plugin plugin, String name, boolean value) {
+ return base.addAttachment(plugin, name, value);
+ }
+
+ @Override
+ public PermissionAttachment addAttachment(Plugin plugin) {
+ return base.addAttachment(plugin);
+ }
+
+ @Override
+ public PermissionAttachment addAttachment(Plugin plugin, String name, boolean value, int ticks) {
+ return base.addAttachment(plugin, name, value, ticks);
+ }
+
+ @Override
+ public PermissionAttachment addAttachment(Plugin plugin, int ticks) {
+ return base.addAttachment(plugin, ticks);
+ }
+
+ @Override
+ public void removeAttachment(PermissionAttachment attachment) {
+ base.removeAttachment(attachment);
+ }
+
+ @Override
+ public void recalculatePermissions() {
+ base.recalculatePermissions();
+ }
+
+ @Override
+ public Set getEffectivePermissions() {
+ return base.getEffectivePermissions();
+ }
+
+ @Override
+ public boolean isOp() {
+ return base.isOp();
+ }
+
+ @Override
+ public void setOp(boolean value) {
+ base.setOp(value);
+ }
+}
diff --git a/providers/PaperProvider/src/main/java/net/ess3/provider/providers/PaperCommandSender.java b/providers/PaperProvider/src/main/java/net/ess3/provider/providers/PaperCommandSender.java
new file mode 100644
index 000000000..25fba1166
--- /dev/null
+++ b/providers/PaperProvider/src/main/java/net/ess3/provider/providers/PaperCommandSender.java
@@ -0,0 +1,79 @@
+package net.ess3.provider.providers;
+
+import net.kyori.adventure.audience.MessageType;
+import net.kyori.adventure.identity.Identified;
+import net.kyori.adventure.identity.Identity;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.ComponentLike;
+import org.bukkit.Bukkit;
+import org.bukkit.command.ConsoleCommandSender;
+
+public class PaperCommandSender extends BukkitSenderProvider {
+ public PaperCommandSender(ConsoleCommandSender base, MessageHook hook) {
+ super(base, hook);
+ }
+
+ @Override
+ public void sendMessage(Identity identity, Component message, MessageType type) {
+ sendDumbComponent(message);
+ }
+
+ @Override
+ public void sendMessage(ComponentLike message) {
+ sendDumbComponent(message.asComponent());
+ }
+
+ @Override
+ public void sendMessage(Identified source, ComponentLike message) {
+ sendDumbComponent(message.asComponent());
+ }
+
+ @Override
+ public void sendMessage(Identity source, ComponentLike message) {
+ sendDumbComponent(message.asComponent());
+ }
+
+ @Override
+ public void sendMessage(Component message) {
+ sendDumbComponent(message);
+ }
+
+ @Override
+ public void sendMessage(Identified source, Component message) {
+ sendDumbComponent(message);
+ }
+
+ @Override
+ public void sendMessage(Identity source, Component message) {
+ sendDumbComponent(message);
+ }
+
+ @Override
+ public void sendMessage(ComponentLike message, MessageType type) {
+ sendDumbComponent(message.asComponent());
+ }
+
+ @Override
+ public void sendMessage(Identified source, ComponentLike message, MessageType type) {
+ sendDumbComponent(message.asComponent());
+ }
+
+ @Override
+ public void sendMessage(Identity source, ComponentLike message, MessageType type) {
+ sendDumbComponent(message.asComponent());
+ }
+
+ @Override
+ public void sendMessage(Component message, MessageType type) {
+ sendDumbComponent(message);
+ }
+
+ @Override
+ public void sendMessage(Identified source, Component message, MessageType type) {
+ sendDumbComponent(message);
+ }
+
+ public void sendDumbComponent(Component message) {
+ sendMessage(Bukkit.getUnsafe().legacyComponentSerializer().serialize(message));
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 16bd70739..bd9fa6fc1 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -8,6 +8,12 @@ dependencyResolutionManagement {
maven("https://repo.codemc.org/repository/maven-public") {
content { includeGroup("org.bstats") }
}
+ maven("https://m2.dv8tion.net/releases/") {
+ content { includeGroup("net.dv8tion") }
+ }
+ maven("https://repo.extendedclip.com/content/repositories/placeholderapi/") {
+ content { includeGroup("me.clip") }
+ }
mavenCentral {
content { includeGroup("net.kyori") }
}
@@ -26,6 +32,7 @@ sequenceOf(
"",
"AntiBuild",
"Chat",
+ "Discord",
"GeoIP",
"Protect",
"Spawn",