Essentials/EssentialsDiscord/src/main/java/net/essentialsx/discord/JDADiscordService.java

603 lines
24 KiB
Java

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.IEssentialsModule;
import com.earth2me.essentials.User;
import com.earth2me.essentials.utils.FormatUtil;
import com.earth2me.essentials.utils.NumberUtil;
import com.earth2me.essentials.utils.VersionUtil;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.entities.Emote;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Role;
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.dv8tion.jda.api.utils.cache.CacheFlag;
import net.ess3.nms.refl.providers.AchievementListenerProvider;
import net.ess3.nms.refl.providers.AdvancementListenerProvider;
import net.ess3.provider.providers.PaperAdvancementListenerProvider;
import net.essentialsx.api.v2.ChatType;
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.InteractionMember;
import net.essentialsx.api.v2.services.discord.InteractionRole;
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.InteractionMemberImpl;
import net.essentialsx.discord.interactions.InteractionRoleImpl;
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.listeners.EssentialsChatListener;
import net.essentialsx.discord.listeners.BukkitChatListener;
import net.essentialsx.discord.util.ConsoleInjector;
import net.essentialsx.discord.util.DiscordUtil;
import net.essentialsx.discord.util.MessageUtil;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.HandlerList;
import org.bukkit.event.Listener;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.ServicePriority;
import org.jetbrains.annotations.NotNull;
import javax.security.auth.login.LoginException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
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 java.util.regex.Pattern;
import static com.earth2me.essentials.I18n.tl;
public class JDADiscordService implements DiscordService, IEssentialsModule {
private final static Logger logger = EssentialsDiscord.getWrappedLogger();
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<String, MessageType> registeredTypes = new HashMap<>();
private final Map<MessageType, String> typeToChannelId = new HashMap<>();
private final Map<String, WebhookClient> channelIdToWebhook = new HashMap<>();
private ConsoleInjector injector;
private DiscordCommandDispatcher commandDispatcher;
private InteractionControllerImpl interactionController;
private Listener chatListener;
private boolean invalidStartup = false;
public JDADiscordService(EssentialsDiscord plugin) {
this.plugin = plugin;
for (final MessageType type : MessageType.DefaultTypes.values()) {
registerMessageType(plugin, type);
}
}
public TextChannel getChannel(String key, boolean primaryFallback) {
if (NumberUtil.isLong(key)) {
return getDefinedChannel(key, primaryFallback);
}
return getDefinedChannel(getSettings().getMessageChannel(key), primaryFallback);
}
public TextChannel getDefinedChannel(String key, boolean primaryFallback) {
final long resolvedId = getSettings().getChannelId(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();
invalidStartup = true;
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))
.enableCache(CacheFlag.EMOTE)
.disableCache(CacheFlag.MEMBER_OVERRIDES, CacheFlag.VOICE_STATE)
.setContextEnabled(false)
.build()
.awaitReady();
invalidStartup = false;
updatePresence();
logger.log(Level.INFO, tl("discordLoggingInDone", jda.getSelfUser().getAsTag()));
if (jda.getGuilds().isEmpty()) {
invalidStartup = true;
throw new IllegalArgumentException(tl("discordErrorNoGuildSize"));
}
guild = jda.getGuildById(plugin.getSettings().getGuildId());
if (guild == null) {
invalidStartup = true;
throw new IllegalArgumentException(tl("discordErrorNoGuild"));
}
interactionController = new InteractionControllerImpl(this);
// Each will throw an exception if disabled
try {
interactionController.registerCommand(new ExecuteCommand(this));
} catch (InteractionException ignored) {
}
try {
interactionController.registerCommand(new MessageCommand(this));
} catch (InteractionException ignored) {
}
try {
interactionController.registerCommand(new ListCommand(this));
} catch (InteractionException ignored) {
}
// Load emotes into cache, JDA will handle updates from here on out.
guild.retrieveEmotes().queue();
updatePrimaryChannel();
updateConsoleRelay();
updateTypesRelay();
// We will see you in the future :balloon:
// DiscordUtil.cleanWebhooks(guild, DiscordUtil.CONSOLE_RELAY_NAME);
// DiscordUtil.cleanWebhooks(guild, DiscordUtil.ADVANCED_RELAY_NAME);
Bukkit.getPluginManager().registerEvents(new BukkitListener(this), plugin);
updateListener();
try {
if (VersionUtil.getServerBukkitVersion().isHigherThanOrEqualTo(VersionUtil.v1_12_0_R01)) {
try {
Class.forName("io.papermc.paper.advancement.AdvancementDisplay");
Bukkit.getPluginManager().registerEvents(new PaperAdvancementListenerProvider(), plugin);
} catch (ClassNotFoundException e) {
Bukkit.getPluginManager().registerEvents(new AdvancementListenerProvider(), plugin);
}
} else {
Bukkit.getPluginManager().registerEvents(new AchievementListenerProvider(), plugin);
}
} catch (final Throwable e) {
logger.log(Level.WARNING, "Error while loading the achievement/advancement listener. You will not receive achievement/advancement notifications on Discord.", e);
}
getPlugin().getEss().scheduleSyncDelayedTask(() -> DiscordUtil.dispatchDiscordMessage(JDADiscordService.this, MessageType.DefaultTypes.SERVER_START, getSettings().getStartMessage(), true, null, null, null));
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-z][a-z0-9-]*$")) {
throw new IllegalArgumentException("MessageType key must match \"^[a-z][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()) && !NumberUtil.isLong(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 void sendChatMessage(final Player player, final String chatMessage) {
sendChatMessage(ChatType.UNKNOWN, player, chatMessage);
}
@Override
public void sendChatMessage(ChatType chatType, Player player, String chatMessage) {
final User user = getPlugin().getEss().getUser(player);
final String formattedMessage = MessageUtil.formatMessage(getSettings().getMcToDiscordFormat(player, chatType),
MessageUtil.sanitizeDiscordMarkdown(player.getName()),
MessageUtil.sanitizeDiscordMarkdown(player.getDisplayName()),
user.isAuthorized("essentials.discord.markdown") ? chatMessage : MessageUtil.sanitizeDiscordMarkdown(chatMessage),
MessageUtil.sanitizeDiscordMarkdown(getPlugin().getEss().getSettings().getWorldAlias(player.getWorld().getName())),
MessageUtil.sanitizeDiscordMarkdown(FormatUtil.stripEssentialsFormat(getPlugin().getEss().getPermissionsHandler().getPrefix(player))),
MessageUtil.sanitizeDiscordMarkdown(FormatUtil.stripEssentialsFormat(getPlugin().getEss().getPermissionsHandler().getSuffix(player))));
final String avatarUrl = DiscordUtil.getAvatarUrl(this, player);
final String formattedName = MessageUtil.formatMessage(getSettings().getMcToDiscordNameFormat(player),
player.getName(),
player.getDisplayName(),
getPlugin().getEss().getSettings().getWorldAlias(player.getWorld().getName()),
FormatUtil.stripEssentialsFormat(getPlugin().getEss().getPermissionsHandler().getPrefix(player)),
FormatUtil.stripEssentialsFormat(getPlugin().getEss().getPermissionsHandler().getSuffix(player)),
guild.getMember(jda.getSelfUser()).getEffectiveName());
DiscordUtil.dispatchDiscordMessage(this, chatTypeToMessageType(chatType), formattedMessage, user.isAuthorized("essentials.discord.ping"), avatarUrl, formattedName, player.getUniqueId());
}
private MessageType chatTypeToMessageType(ChatType chatType) {
switch (chatType) {
case SHOUT:
return MessageType.DefaultTypes.SHOUT;
case QUESTION:
return MessageType.DefaultTypes.QUESTION;
case LOCAL:
return MessageType.DefaultTypes.LOCAL;
default:
return MessageType.DefaultTypes.CHAT;
}
}
@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) {
throw new RuntimeException(tl("discordErrorNoPerms"));
}
logger.warning(tl("discordErrorNoPrimary", channel.getName()));
}
if (!channel.canTalk()) {
throw new RuntimeException(tl("discordErrorNoPrimaryPerms", channel.getName()));
}
primaryChannel = channel;
}
public String parseMessageEmotes(String message) {
for (final Emote emote : guild.getEmoteCache()) {
message = message.replaceAll(":" + Pattern.quote(emote.getName()) + ":", emote.getAsMention());
}
return message;
}
public void updateListener() {
if (chatListener != null) {
HandlerList.unregisterAll(chatListener);
chatListener = null;
}
chatListener = getSettings().isUseEssentialsEvents() && plugin.isEssentialsChat()
? new EssentialsChatListener(this)
: new BukkitChatListener(this);
Bukkit.getPluginManager().registerEvents(chatListener, plugin);
}
public void updatePresence() {
jda.getPresence().setPresence(plugin.getSettings().getStatus(), plugin.getSettings().getStatusActivity());
}
public void updateTypesRelay() {
if (!getSettings().isShowAvatar() && !getSettings().isShowName() && !getSettings().isShowDisplayName()) {
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 Webhook webhook = DiscordUtil.getOrCreateWebhook(channel, DiscordUtil.ADVANCED_RELAY_NAME).join();
if (webhook == null) {
final WebhookClient current = channelIdToWebhook.get(channel.getId());
if (current != null) {
current.close();
}
channelIdToWebhook.remove(channel.getId()).close();
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 Webhook webhook = DiscordUtil.getOrCreateWebhook(channel, DiscordUtil.CONSOLE_RELAY_NAME).join();
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.isRemoved()) {
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) {
if (!invalidStartup) {
sendMessage(MessageType.DefaultTypes.SERVER_STOP, getSettings().getStopMessage(), true);
DiscordUtil.dispatchDiscordMessage(JDADiscordService.this, MessageType.DefaultTypes.SERVER_STOP, getSettings().getStopMessage(), true, null, null, null);
}
shutdownConsoleRelay(true);
for (WebhookClient webhook : channelIdToWebhook.values()) {
webhook.close();
}
// 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<Void> 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;
}
}
}
@Override
public CompletableFuture<InteractionMember> getMemberById(final String id) {
final CompletableFuture<InteractionMember> future = new CompletableFuture<>();
getGuild().retrieveMemberById(id).queue(member -> {
if (member != null) {
future.complete(new InteractionMemberImpl(member));
return;
}
future.complete(null);
}, fail -> future.complete(null));
return future;
}
@Override
public InteractionRole getRole(String id) {
final Role role = getGuild().getRoleById(id);
return role == null ? null : new InteractionRoleImpl(role);
}
@Override
public CompletableFuture<Void> modifyMemberRoles(InteractionMember member, Collection<InteractionRole> addRoles, Collection<InteractionRole> removeRoles) {
if ((addRoles == null || addRoles.isEmpty()) && (removeRoles == null || removeRoles.isEmpty())) {
return CompletableFuture.completedFuture(null);
}
final List<Role> add = new ArrayList<>();
final List<Role> remove = new ArrayList<>();
if (addRoles != null) {
for (final InteractionRole role : addRoles) {
add.add(((InteractionRoleImpl) role).getJdaObject());
}
}
if (removeRoles != null) {
for (final InteractionRole role : removeRoles) {
remove.add(((InteractionRoleImpl) role).getJdaObject());
}
}
final CompletableFuture<Void> future = new CompletableFuture<>();
guild.modifyMemberRoles(((InteractionMemberImpl) member).getJdaObject(), add, remove).queue(future::complete);
return future;
}
@Override
public String getInviteUrl() {
return getSettings().getDiscordUrl();
}
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 isInvalidStartup() {
return invalidStartup;
}
public boolean isDebug() {
return plugin.getEss().getSettings().isDebug();
}
}