diff --git a/build.gradle b/build.gradle index 4c67af1..9c47879 100644 --- a/build.gradle +++ b/build.gradle @@ -7,16 +7,28 @@ sourceCompatibility = 1.8 repositories { mavenCentral() + jcenter() + maven { + url "https://jitpack.io" + } } dependencies { // Logging compile group: 'org.apache.logging.log4j', name: 'log4j-api', version: '2.6.1' compile group: 'org.apache.logging.log4j', name: 'log4j-core', version: '2.6.1' + compile group: 'org.slf4j', name: 'slf4j-api', version: '1.7.21' // D4J Logger routing + compile group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.6.1' // D4J Logger routing // Configuration framework compile group: 'org.apache.commons', name: 'commons-configuration2', version: '2.0' runtime group: 'commons-beanutils', name: 'commons-beanutils', version: '1.9.2' + // Discord Gateway API + compile group: 'com.github.austinv11', name: 'Discord4j', version: '2.4.9' + + // Util + compile group: 'net.jodah', name: 'failsafe', version: '0.8.3' + testCompile group: 'junit', name: 'junit', version: '4.11' } \ No newline at end of file diff --git a/dcf.properties.example b/dcf.properties.example index 134b07d..3b1681c 100644 --- a/dcf.properties.example +++ b/dcf.properties.example @@ -1,2 +1,10 @@ # Bot display name -general.bot_name = DCF-enabled Bot +general.bot_name = DCF Bot + +# Initial connection token... +discord.token = tokenGoesHere + +# ... or username/password combination if you don't use bot mode +# comment out discord.token if using us/pw tuple +#discord.username = email +#discord.password = superSecretPassword \ No newline at end of file diff --git a/src/main/java/net/pingex/dcf/DiscordCommandableFramework.java b/src/main/java/net/pingex/dcf/DiscordCommandableFramework.java index 14cb2fb..c94d097 100644 --- a/src/main/java/net/pingex/dcf/DiscordCommandableFramework.java +++ b/src/main/java/net/pingex/dcf/DiscordCommandableFramework.java @@ -1,14 +1,19 @@ package net.pingex.dcf; import net.pingex.dcf.core.Configuration; +import net.pingex.dcf.core.GatewayConnectionsManager; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import sx.blah.discord.api.ClientBuilder; /** * Main class for DCF */ public class DiscordCommandableFramework { + /** + * Logger + */ private static final Logger LOGGER = LogManager.getLogger(DiscordCommandableFramework.class); /** @@ -16,10 +21,16 @@ public class DiscordCommandableFramework */ public static void main(String[] args) { - LOGGER.info("Hello World"); Configuration.load(); Configuration.init(); + LOGGER.info("Hello World"); + LOGGER.info(Configuration.BOT_NAME); + LOGGER.info("Framework version {}", DiscordCommandableFramework.class.getPackage().getImplementationVersion() != null ? DiscordCommandableFramework.class.getPackage().getImplementationVersion() : "UNKNOWN"); - LOGGER.info("I'm " + Configuration.BOT_NAME); + // Set up initial connection + ClientBuilder builder = new ClientBuilder(); + if(Configuration.isConnectionToken()) builder.withToken(Configuration.CONNECTION_TOKEN); + else builder.withLogin(Configuration.CONNECTION_USERNAME, Configuration.CONNECTION_PASSWORD); + GatewayConnectionsManager.getInstance().registerConnection(builder); } } diff --git a/src/main/java/net/pingex/dcf/core/Configuration.java b/src/main/java/net/pingex/dcf/core/Configuration.java index fbcae11..4ac348d 100644 --- a/src/main/java/net/pingex/dcf/core/Configuration.java +++ b/src/main/java/net/pingex/dcf/core/Configuration.java @@ -19,6 +19,10 @@ public class Configuration * File name to look for */ private static final String FILENAME = "dcf.properties"; + + /** + * Logger + */ private static final Logger LOGGER = LogManager.getLogger(Configuration.class); /** @@ -57,8 +61,31 @@ public class Configuration /** * Bot display name */ - public static String BOT_NAME = "DCF-enabled Bot"; + public static String BOT_NAME = "DCF-enabled Discord Bot"; + /** + * Initial connection token + */ + public static String CONNECTION_TOKEN = null; + + /** + * Initial connection username + */ + public static String CONNECTION_USERNAME = null; + + /** + * Initial connection password + */ + public static String CONNECTION_PASSWORD = null; + + /** + * Tells if the bot is configured to connect using a token or an username/password tuple. + * @return + */ + public static boolean isConnectionToken() + { + return store.containsKey("discord.token"); + } /** * Load config keys @@ -66,5 +93,18 @@ public class Configuration public static void init() { BOT_NAME = store.getString("general.bot_name", BOT_NAME); + + if(isConnectionToken()) + CONNECTION_TOKEN = store.getString("discord.token"); // We imply that the token exists, as its existence is already checked in isConnectionToken() + else + if(!store.containsKey("discord.username") || !store.containsKey("discord.password")) + { + LOGGER.error("Connection username/password not specified !"); + } + else + { + CONNECTION_USERNAME = store.getString("discord.username"); + CONNECTION_PASSWORD = store.getString("discord.password"); + } } } diff --git a/src/main/java/net/pingex/dcf/core/CoreEventsHandler.java b/src/main/java/net/pingex/dcf/core/CoreEventsHandler.java new file mode 100644 index 0000000..948251f --- /dev/null +++ b/src/main/java/net/pingex/dcf/core/CoreEventsHandler.java @@ -0,0 +1,80 @@ +package net.pingex.dcf.core; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import sx.blah.discord.api.EventSubscriber; +import sx.blah.discord.handle.impl.events.*; + +/** + * Very core events handler, should be running independently from the whole events manager thingy. + * It handles things like auto reconnect after gateway disconnection, etc. + */ +public class CoreEventsHandler +{ + /** + * Logger + */ + private static Logger LOGGER = LogManager.getLogger(CoreEventsHandler.class); + + /** + * Singleton instance + */ + private static CoreEventsHandler INSTANCE = new CoreEventsHandler(); + + /** + * Get current instance + */ + public static CoreEventsHandler getInstance() + { + return INSTANCE; + } + + /** + * Basic constructor, enforcing singleton + */ + private CoreEventsHandler() + {} + + @EventSubscriber + public void onReady(ReadyEvent event) + { + LOGGER.info("Connection with user #" + event.getClient().getOurUser().getID() + " ready."); + } + + @EventSubscriber + public void onDiscordDisconnected(DiscordDisconnectedEvent event) + { + LOGGER.warn("Discord connection with user #" + event.getClient().getOurUser().getID() + "lost (" + event.getReason().toString() + "). Reconnecting"); + + } + + @EventSubscriber + public void onMessageSend(MessageSendEvent event) + { + LOGGER.trace("Sent message to channel #" + event.getMessage().getChannel().getID() + "."); + } + + @EventSubscriber + public void onMention(MentionEvent event) + { + LOGGER.trace("Received mention from channel #" + event.getMessage().getChannel().getID() + "."); + } + + @EventSubscriber + public void onMessageReceived(MessageReceivedEvent event) + { + LOGGER.trace("Received message from channel #" + event.getMessage().getChannel().getID() + "."); + } + + @EventSubscriber + public void onGuildUnavailable(GuildUnavailableEvent event) + { + LOGGER.warn("Guild #" + event.getGuild().getID() + " is unavailable."); + } + + @EventSubscriber + public void onGuildCreate(GuildCreateEvent event) + { + LOGGER.info("Joined guild #" + event.getGuild().getID() + "."); + } +} diff --git a/src/main/java/net/pingex/dcf/core/GatewayConnectionsManager.java b/src/main/java/net/pingex/dcf/core/GatewayConnectionsManager.java new file mode 100644 index 0000000..9a1f0cc --- /dev/null +++ b/src/main/java/net/pingex/dcf/core/GatewayConnectionsManager.java @@ -0,0 +1,152 @@ +package net.pingex.dcf.core; + +import net.pingex.dcf.util.DiscordInteractionsUtil; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import sx.blah.discord.api.ClientBuilder; +import sx.blah.discord.api.IDiscordClient; +import sx.blah.discord.util.DiscordException; +import sx.blah.discord.util.HTTP429Exception; +import sx.blah.discord.util.MessageBuilder; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Storage of all active connection to Discord gateway + */ +public class GatewayConnectionsManager +{ + /** + * Main datastore + */ + private Set connectionsDatastore; + + /** + * List of registered listeners for each connection, as we can't access registered listeners in IDC dispatcher. + */ + private Map> registeredListeners; + + /** + * Singleton unique instance + */ + private static final GatewayConnectionsManager INSTANCE = new GatewayConnectionsManager(); + + /** + * Logger + */ + private static final Logger LOGGER = LogManager.getLogger(GatewayConnectionsManager.class); + + /** + * Get singleton instance + */ + public static GatewayConnectionsManager getInstance() + { + return INSTANCE; + } + + /** + * Private constructor, to enforce singleton + */ + private GatewayConnectionsManager() + { + connectionsDatastore = new HashSet<>(); + registeredListeners = new HashMap<>(); + } + + /** + * Registers a connection and login automatically + * @param builder Filled and builder ready for log in + */ + public void registerConnection(ClientBuilder builder) + { + LOGGER.info("Registering new connection"); + + try + { + IDiscordClient builtConnection = builder.login(); + connectionsDatastore.add(builtConnection); + builtConnection.getDispatcher().registerListener(CoreEventsHandler.getInstance()); // Register the core event handler independently from the events package + //updateListeners(builtConnection, null); // TODO: EventRegistry + } + catch (DiscordException e) + { + LOGGER.warn("Failed to login to Discord.", e); + } + } + + /** + * Logout and unregister specified connection + * @param client Target connection + */ + public void unregisterConnection(IDiscordClient client) + { + LOGGER.info("Unregistering connection with user #" + client.getOurUser().getID()); + + DiscordInteractionsUtil.disconnect(client); + connectionsDatastore.remove(client); + registeredListeners.remove(client); + } + + /** + * Update listeners for target connection + * @param target Target conection, must be registered + * @param refListeners Reference listeners list + */ + public void updateListeners(IDiscordClient target, Set refListeners) + { + LOGGER.debug("Updating listeners for target " + target.getOurUser().getID()); + + if(!connectionsDatastore.contains(target)) return; + if(!registeredListeners.containsKey(target)) registeredListeners.put(target, new HashSet<>()); + + Set currentListeners = registeredListeners.get(target); + + // Case 1: item is not registered in IDC + long toRegister = refListeners.stream().filter(item -> !currentListeners.contains(item)).count(); + refListeners.stream().filter(item -> !currentListeners.contains(item)).forEach(item -> + { + target.getDispatcher().registerListener(item); + currentListeners.add(item); + }); + + // Case 2: item is registered, but shouldn't be + long toUnregister = currentListeners.stream().filter(item -> !refListeners.contains(item)).count(); + currentListeners.stream().filter(item -> !refListeners.contains(item)).forEach(item -> + { + target.getDispatcher().unregisterListener(item); + currentListeners.remove(item); + }); + + LOGGER.debug("Registered " + toRegister + " listeners and unregistered " + toUnregister + " listeners."); + } + + /** + * Update all connections listeners + * @param refListeners Reference listeners list + */ + public void updateAllListeners(Set refListeners) + { + for(IDiscordClient i : connectionsDatastore) updateListeners(i, refListeners); + } + + /** + * Reconnect a disconnected gateway connection + * @param target Disconnected connection + */ + public void reconnect(IDiscordClient target) + { + if(!connectionsDatastore.contains(target)) return; + + try + { + target.login(); + } + catch (DiscordException e) + { + LOGGER.warn("User #" + target.getOurUser().getID() + " failed to reconnect to the gateway.", e); + } + } +} diff --git a/src/main/java/net/pingex/dcf/util/DiscordInteractionsUtil.java b/src/main/java/net/pingex/dcf/util/DiscordInteractionsUtil.java new file mode 100644 index 0000000..8c52158 --- /dev/null +++ b/src/main/java/net/pingex/dcf/util/DiscordInteractionsUtil.java @@ -0,0 +1,101 @@ +package net.pingex.dcf.util; + +import net.jodah.failsafe.*; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import sx.blah.discord.api.IDiscordClient; +import sx.blah.discord.handle.obj.IChannel; +import sx.blah.discord.handle.obj.IMessage; +import sx.blah.discord.util.DiscordException; +import sx.blah.discord.util.HTTP429Exception; +import sx.blah.discord.util.MissingPermissionsException; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * Contains some useful functions to avoid having to rewrite the same code over and over + */ +public class DiscordInteractionsUtil +{ + /** + * Logger + */ + private static final Logger LOGGER = LogManager.getLogger(DiscordInteractionsUtil.class); + + /** + * Maximum amount of retries for an interaction + */ + public static final int MAX_GW_RETRIES = 5; + + /** + * Delay between retries + */ + public static final int GW_RETRY_DELAY = 3; + + /** + * Retry policy for sending a message + */ + private static final RetryPolicy MESSAGE_RETRY_POLICY = new RetryPolicy() + .retryOn(HTTP429Exception.class, DiscordException.class) + .withDelay(GW_RETRY_DELAY, TimeUnit.SECONDS) + .withMaxRetries(MAX_GW_RETRIES) + .abortOn(MissingPermissionsException.class); + + /** + * Sends a message + * @param channel Channel to send the message + * @param message Message to send + * @return The sent message, or {@code Optional.empty()} + */ + public static Optional sendMessage(IChannel channel, String message) + { + LOGGER.debug("Attempting to send a message (error-tolerant operation)."); + + SyncFailsafe fs = Failsafe.with(MESSAGE_RETRY_POLICY) + .onFailedAttempt(failure -> LOGGER.warn("Failed to send message.", failure)) + .onRetry((c, f, stats) -> LOGGER.warn("Message sending failure #{}. Retrying.", stats.getExecutions())) + .onSuccess((result, context) -> LOGGER.debug("Sent message after {} attempts.", context.getExecutions())) + .onAbort(failure -> LOGGER.warn("Aborted sending message.", failure)); + + try + { + return Optional.of((IMessage) fs.get(() -> channel.sendMessage(message))); + } + catch(FailsafeException e) + { + LOGGER.error("Failed to send message (exceeded amounts or trials or aborted)", e); + return Optional.empty(); + } + } + + /** + * Policy for a gateway disconnection + */ + private static final RetryPolicy GW_LOGOUT_POLICY = new RetryPolicy() + .retryOn(HTTP429Exception.class, DiscordException.class) + .withDelay(GW_RETRY_DELAY, TimeUnit.SECONDS) + .withMaxRetries(MAX_GW_RETRIES); + + /** + * Disconnect from the gateway + * @param client Client interface + */ + public static void disconnect(IDiscordClient client) + { + LOGGER.debug("Attempting to disconnect user #{} from the gateway (error-tolerant operation).", client.getOurUser().getID()); + + SyncFailsafe fs = Failsafe.with(GW_LOGOUT_POLICY) + .onFailedAttempt(failure -> LOGGER.warn("Failed to disconnect.", failure)) + .onRetry((result, failure, context) -> LOGGER.warn("Disconnection failure #{}. Retrying.", context.getExecutions())) + .onSuccess((result, context) -> LOGGER.info("Disconnected user {} from gateway after {} attempts.", client.getOurUser().getID(), context.getExecutions())); + + try + { + fs.run(client::logout); + } + catch(FailsafeException e) + { + LOGGER.error("Connection #{} failed to disconnect (amount of trials exceeded)", e); + } + } +} diff --git a/src/main/resources/log4j2.properties b/src/main/resources/log4j2.properties index d612e44..4a4e84f 100644 --- a/src/main/resources/log4j2.properties +++ b/src/main/resources/log4j2.properties @@ -5,5 +5,11 @@ appender.console.name = STDOUT appender.console.layout.type = PatternLayout appender.console.layout.pattern = %d{dd/MM/yyyy HH:mm:ss} [%t/%p] %c:%M - %m%n -rootLogger.level = info -rootLogger.appenderRef.stdout.ref = STDOUT \ No newline at end of file +rootLogger.level = trace +rootLogger.appenderRef.stdout.ref = STDOUT + +# Mute jetty garbage +logger.jetty.name = org.eclipse.jetty +logger.jetty.level = info +logger.d4j.name = sx.blah.discord +logger.d4j.level = info \ No newline at end of file