From e4c4be429c30b9a5acd740f9a6eef545ff2834cb Mon Sep 17 00:00:00 2001 From: Logan Fick Date: Thu, 7 Jul 2022 12:50:29 -0400 Subject: [PATCH] Added code comments. --- .../java/dev/logal/crabstero/Crabstero.java | 32 ++++- .../crabstero/CrabsteroThreadFactory.java | 9 ++ .../crabstero/listeners/MessageCreate.java | 29 ++++- .../listeners/RoleChangePermissions.java | 15 +++ .../listeners/ServerBecomesAvailable.java | 14 +++ ...erChannelChangeOverwrittenPermissions.java | 14 +++ .../logal/crabstero/listeners/ServerJoin.java | 32 +++-- .../crabstero/listeners/UserRoleAdd.java | 15 +++ .../tasks/ChannelHistoryIngestionTask.java | 40 ++++-- .../logal/crabstero/utils/MarkovChain.java | 117 ++++++++++++++++-- .../crabstero/utils/MarkovChainMessages.java | 48 ++++++- 11 files changed, 334 insertions(+), 31 deletions(-) diff --git a/src/main/java/dev/logal/crabstero/Crabstero.java b/src/main/java/dev/logal/crabstero/Crabstero.java index f149b6e..498abbf 100644 --- a/src/main/java/dev/logal/crabstero/Crabstero.java +++ b/src/main/java/dev/logal/crabstero/Crabstero.java @@ -20,6 +20,9 @@ import redis.clients.jedis.JedisPoolConfig; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +/** + * The simple nonversation Discord bot. + */ public final class Crabstero { private static final Logger logger = LoggerFactory.getLogger(Crabstero.class); @@ -27,6 +30,13 @@ public final class Crabstero { private final JedisPool jedisPool; private final ScheduledExecutorService workerPool; + /** + * Creates a new instance of Crabstero using a given Discord bot token to connect to Discord and a given Redis host and port to connect to a Redis database. + * + * @param token The Discord bot token. + * @param redisHost The host of the Redis server. + * @param redisPort The port of the Redis server. + */ public Crabstero(final String token, final String redisHost, final int redisPort) { this.jedisPool = new JedisPool(new JedisPoolConfig(), redisHost, redisPort); this.workerPool = Executors.newScheduledThreadPool(4, new CrabsteroThreadFactory()); @@ -54,23 +64,43 @@ public final class Crabstero { this.discordApi.setMessageCacheSize(0, 1); } + /** + * Entrypoint to the standalone executable build of Crabstero. This simply starts an instance of Crabstero + * + * @param arguments Command line arguments. Not used. + */ public static void main(final String[] arguments) { final String token = System.getenv("TOKEN"); final String redisHost = System.getenv("REDIS_HOST"); logger.info("Starting Crabstero..."); - new Crabstero(token, redisHost, 6379); + new Crabstero(token, redisHost, 6379); // TODO: Don't use a hardcoded port number. logger.info("Crabstero started!"); } + /** + * Gets the Discord API used by this instance of Crabstero. + * + * @return The Discord API. + */ public DiscordApi getDiscordApi() { return this.discordApi; } + /** + * Gets the Jedis pool used by this instance of Crabstero. + * + * @return The Jedis pool. + */ public JedisPool getJedisPool() { return this.jedisPool; } + /** + * Gets the worker pool used by this instance of Crabstero. + * + * @return The scheduled executor service. + */ public ScheduledExecutorService getWorkerPool() { return this.workerPool; } diff --git a/src/main/java/dev/logal/crabstero/CrabsteroThreadFactory.java b/src/main/java/dev/logal/crabstero/CrabsteroThreadFactory.java index 71093d0..7ae935a 100644 --- a/src/main/java/dev/logal/crabstero/CrabsteroThreadFactory.java +++ b/src/main/java/dev/logal/crabstero/CrabsteroThreadFactory.java @@ -10,9 +10,18 @@ package dev.logal.crabstero; import java.util.concurrent.ThreadFactory; +/** + * Creates threads with custom branded names for visibility in tools such as htop. + */ public final class CrabsteroThreadFactory implements ThreadFactory { private int threadNumber = 1; + /** + * Creates a new thread with an automatically set name. + * + * @param runnable The runnable to create the thread with. + * @return A new thread with the name automatically set. + */ @Override public Thread newThread(final Runnable runnable) { final Thread thread = new Thread(runnable); diff --git a/src/main/java/dev/logal/crabstero/listeners/MessageCreate.java b/src/main/java/dev/logal/crabstero/listeners/MessageCreate.java index 613865f..1c7627b 100644 --- a/src/main/java/dev/logal/crabstero/listeners/MessageCreate.java +++ b/src/main/java/dev/logal/crabstero/listeners/MessageCreate.java @@ -17,37 +17,56 @@ import org.javacord.api.entity.message.MessageType; import org.javacord.api.event.message.MessageCreateEvent; import org.javacord.api.listener.message.MessageCreateListener; +/** + * Handles created messages. + */ public final class MessageCreate implements MessageCreateListener { private final MarkovChainMessages markovChainMessages; + /** + * Creates a new message creation handler owned by a given instance of Crabstero. + * + * @param crabstero The instance of Crabstero. + */ public MessageCreate(final Crabstero crabstero) { this.markovChainMessages = new MarkovChainMessages(crabstero); } + /** + * Responds to mentions and ingests normal text messages. + * + * @param event The event. + */ @Override public void onMessageCreate(final MessageCreateEvent event) { // Is this message being created outside of a server text channel or a server thread channel? final TextChannel channel = event.getChannel(); if (channel.asServerTextChannel().isEmpty() && channel.asServerThreadChannel().isEmpty()) { - return; // Yes. Ignore it. + // Yes. Ignore it. + return; } // Is the user creating this message another bot, a webhook, or Crabstero itself? final MessageAuthor author = event.getMessageAuthor(); if (author.isBotUser() || author.isWebhook() || author.isYourself()) { - return; // Yes. Ignore it. + // Yes. Ignore it. + return; } // Is this message mentioning Crabstero? final Message message = event.getMessage(); if (message.getMentionedUsers().contains(event.getApi().getYourself())) { - this.markovChainMessages.replyToMessage(message); // Yes. Respond to it. - return; // Prevent further processing of the message. + // Yes. Respond to it. + this.markovChainMessages.replyToMessage(message); + + // Prevent further processing of the message. + return; } // Is this message a normal message outside of a server thread channel? if (message.getType() == MessageType.NORMAL && channel.asServerThreadChannel().isEmpty()) { - this.markovChainMessages.ingestMessage(message); // Yes. Ingest the message. + // Yes. Ingest the message. + this.markovChainMessages.ingestMessage(message); } } } \ No newline at end of file diff --git a/src/main/java/dev/logal/crabstero/listeners/RoleChangePermissions.java b/src/main/java/dev/logal/crabstero/listeners/RoleChangePermissions.java index 789c400..50704f4 100644 --- a/src/main/java/dev/logal/crabstero/listeners/RoleChangePermissions.java +++ b/src/main/java/dev/logal/crabstero/listeners/RoleChangePermissions.java @@ -13,16 +13,31 @@ import dev.logal.crabstero.tasks.ChannelHistoryIngestionTask; import org.javacord.api.event.server.role.RoleChangePermissionsEvent; import org.javacord.api.listener.server.role.RoleChangePermissionsListener; +/** + * Handles roles with changing permissions. + */ public final class RoleChangePermissions implements RoleChangePermissionsListener { private final Crabstero crabstero; + /** + * Creates a new role change permission handler owned by a given instance of Crabstero. + * + * @param crabstero The instance of Crabstero. + */ public RoleChangePermissions(final Crabstero crabstero) { this.crabstero = crabstero; } + /** + * Queues all text channels for message history ingestion when role permissions change. + * + * @param event The event. + */ @Override public void onRoleChangePermissions(final RoleChangePermissionsEvent event) { + // Does the updated role include Crabstero as a member? if (event.getRole().hasUser(event.getApi().getYourself())) { + // Queue every text channel for message history ingestion. event.getServer().getTextChannels().forEach((channel) -> { this.crabstero.getWorkerPool().submit(new ChannelHistoryIngestionTask(channel, crabstero)); }); diff --git a/src/main/java/dev/logal/crabstero/listeners/ServerBecomesAvailable.java b/src/main/java/dev/logal/crabstero/listeners/ServerBecomesAvailable.java index 620b16d..4e0a1f5 100644 --- a/src/main/java/dev/logal/crabstero/listeners/ServerBecomesAvailable.java +++ b/src/main/java/dev/logal/crabstero/listeners/ServerBecomesAvailable.java @@ -13,15 +13,29 @@ import dev.logal.crabstero.tasks.ChannelHistoryIngestionTask; import org.javacord.api.event.server.ServerBecomesAvailableEvent; import org.javacord.api.listener.server.ServerBecomesAvailableListener; +/** + * Handles when servers become available. + */ public final class ServerBecomesAvailable implements ServerBecomesAvailableListener { private final Crabstero crabstero; + /** + * Creates a new server becoming available handler owned by a given instance of Crabstero. + * + * @param crabstero The instance of Crabstero. + */ public ServerBecomesAvailable(final Crabstero crabstero) { this.crabstero = crabstero; } + /** + * Queues all text channels for message history ingestion when a server becomes available. + * + * @param event The event. + */ @Override public void onServerBecomesAvailable(final ServerBecomesAvailableEvent event) { + // Queue every text channel for message history ingestion. event.getServer().getTextChannels().forEach((channel) -> { this.crabstero.getWorkerPool().submit(new ChannelHistoryIngestionTask(channel, crabstero)); }); diff --git a/src/main/java/dev/logal/crabstero/listeners/ServerChannelChangeOverwrittenPermissions.java b/src/main/java/dev/logal/crabstero/listeners/ServerChannelChangeOverwrittenPermissions.java index 38a9212..8b31b10 100644 --- a/src/main/java/dev/logal/crabstero/listeners/ServerChannelChangeOverwrittenPermissions.java +++ b/src/main/java/dev/logal/crabstero/listeners/ServerChannelChangeOverwrittenPermissions.java @@ -13,15 +13,29 @@ import dev.logal.crabstero.tasks.ChannelHistoryIngestionTask; import org.javacord.api.event.channel.server.ServerChannelChangeOverwrittenPermissionsEvent; import org.javacord.api.listener.channel.server.ServerChannelChangeOverwrittenPermissionsListener; +/** + * Handles channels with changing overwritten permissions. + */ public final class ServerChannelChangeOverwrittenPermissions implements ServerChannelChangeOverwrittenPermissionsListener { private final Crabstero crabstero; + /** + * Creates a new server channel changing overwritten permissions handler owned by a given instance of Crabstero. + * + * @param crabstero The instance of Crabstero. + */ public ServerChannelChangeOverwrittenPermissions(final Crabstero crabstero) { this.crabstero = crabstero; } + /** + * Queues all text channels for message history ingestion when channel override permissions change. + * + * @param event The event. + */ @Override public void onServerChannelChangeOverwrittenPermissions(final ServerChannelChangeOverwrittenPermissionsEvent event) { + // Queue every text channel for message history ingestion. event.getChannel().asServerTextChannel().ifPresent((channel) -> { this.crabstero.getWorkerPool().submit(new ChannelHistoryIngestionTask(channel, crabstero)); }); diff --git a/src/main/java/dev/logal/crabstero/listeners/ServerJoin.java b/src/main/java/dev/logal/crabstero/listeners/ServerJoin.java index 416f576..414a9c5 100644 --- a/src/main/java/dev/logal/crabstero/listeners/ServerJoin.java +++ b/src/main/java/dev/logal/crabstero/listeners/ServerJoin.java @@ -18,34 +18,50 @@ import org.javacord.api.util.logging.ExceptionLogger; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.awt.*; +import java.awt.Color; +/** + * Handles joining new servers. + */ public final class ServerJoin implements ServerJoinListener { private static final Logger logger = LoggerFactory.getLogger(ServerJoin.class); private final Crabstero crabstero; + /** + * Creates a new server join handler owned by a given instance of Crabstero. + * + * @param crabstero The instance of Crabstero. + */ public ServerJoin(final Crabstero crabstero) { this.crabstero = crabstero; } + /** + * Queues all text channels for message history ingestion when joining a new server. + * + * @param event The event. + */ @Override public void onServerJoin(final ServerJoinEvent event) { + // Log the server join. final Server server = event.getServer(); logger.info("Joined new server! (Name: \"" + server.getName() + "\" | ID: " + server.getIdAsString() + ")"); + // Start building an embed with basic server information. final EmbedBuilder embed = new EmbedBuilder(); embed.setTitle("Joined New Server"); embed.setColor(new Color(255, 165, 0)); embed.addField(server.getName() + " (" + server.getIdAsString() + ")", server.getMemberCount() + " members"); - server.getIcon().ifPresent((icon) -> { - embed.setImage(icon.getUrl().toString()); - }); - embed.setFooter(event.getApi().getServers().size() + " total servers"); - event.getApi().getOwner().thenAcceptAsync((owner) -> { - owner.sendMessage(embed).exceptionally(ExceptionLogger.get()); - }); + server.getIcon().ifPresent((icon) -> embed.setImage(icon.getUrl().toString())); + // Add the current total server count. + embed.setFooter(event.getApi().getServers().size() + " total servers"); + + // Send this embed to the bot owner. + event.getApi().getOwner().thenAcceptAsync((owner) -> owner.sendMessage(embed).exceptionally(ExceptionLogger.get())); + + // Queue all text channels for message history ingestion. server.getTextChannels().forEach((channel) -> { this.crabstero.getWorkerPool().submit(new ChannelHistoryIngestionTask(channel, crabstero)); }); diff --git a/src/main/java/dev/logal/crabstero/listeners/UserRoleAdd.java b/src/main/java/dev/logal/crabstero/listeners/UserRoleAdd.java index 2affa6a..e0aad36 100644 --- a/src/main/java/dev/logal/crabstero/listeners/UserRoleAdd.java +++ b/src/main/java/dev/logal/crabstero/listeners/UserRoleAdd.java @@ -13,16 +13,31 @@ import dev.logal.crabstero.tasks.ChannelHistoryIngestionTask; import org.javacord.api.event.server.role.UserRoleAddEvent; import org.javacord.api.listener.server.role.UserRoleAddListener; +/** + * Handles when roles are added to users. + */ public final class UserRoleAdd implements UserRoleAddListener { private final Crabstero crabstero; + /** + * Creates a new user role add handler owned by a given instance of Crabstero. + * + * @param crabstero The instance of Crabstero. + */ public UserRoleAdd(final Crabstero crabstero) { this.crabstero = crabstero; } + /** + * Queues all text channels for message history ingestion when added to a new role. + * + * @param event The event. + */ @Override public void onUserRoleAdd(final UserRoleAddEvent event) { + // Is the user who was assigned the role Crabstero? if (event.getUser().isYourself()) { + // Queue all text channels for message history ingestion. event.getServer().getTextChannels().forEach((channel) -> { this.crabstero.getWorkerPool().submit(new ChannelHistoryIngestionTask(channel, crabstero)); }); diff --git a/src/main/java/dev/logal/crabstero/tasks/ChannelHistoryIngestionTask.java b/src/main/java/dev/logal/crabstero/tasks/ChannelHistoryIngestionTask.java index 20f42ec..fd68590 100644 --- a/src/main/java/dev/logal/crabstero/tasks/ChannelHistoryIngestionTask.java +++ b/src/main/java/dev/logal/crabstero/tasks/ChannelHistoryIngestionTask.java @@ -15,59 +15,85 @@ import org.javacord.api.entity.message.Message; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import redis.clients.jedis.Jedis; -import redis.clients.jedis.Pipeline; import java.util.Iterator; import java.util.stream.Stream; +/** + * Bulk-ingests the message history of a channel. + */ public final class ChannelHistoryIngestionTask implements Runnable { - private static final String INGESTED_CHANNELS_KEY = "ingestedChannels"; - private static final int MAXIMUM_MESSAGES_PER_CHANNEL = 50000; + private static final String INGESTED_CHANNELS_KEY = "ingestedChannels"; // Key in redis for storing the IDs of channels which have been ingested. + private static final int MAXIMUM_MESSAGES_PER_CHANNEL = 50000; // The maximum amount of historical messages to ingest per channel. private static final Logger logger = LoggerFactory.getLogger(ChannelHistoryIngestionTask.class); private final Crabstero crabstero; private final ServerTextChannel channel; + /** + * Creates a new task for ingesting the message history of a channel. + * + * @param channel The channel to ingest. + * @param crabstero The Crabstero instance running this task. + */ public ChannelHistoryIngestionTask(final ServerTextChannel channel, final Crabstero crabstero) { this.channel = channel; this.crabstero = crabstero; } + /** + * Runs the task. The task will be ended early if permissions do not allow ingesting this channel or if it has already been ingested in the past. + */ @Override public void run() { try { + // Do permissions allow Crabstero to read message history in this channel? if (!this.channel.canYouReadMessageHistory()) { + // No. Log the problem and stop further processing this channel. logger.warn("Unable to ingest text channel history due to lacking permissions. Ignoring. (Name: \"" + this.channel.getName() + "\" | ID: " + this.channel.getIdAsString() + " | Server ID: " + this.channel.getServer().getIdAsString() + ")"); return; } try (final Jedis jedis = this.crabstero.getJedisPool().getResource()) { + // Have we already ingested this channel before? if (jedis.lrange(INGESTED_CHANNELS_KEY, 0, -1).contains(this.channel.getIdAsString())) { + // Yes. Stop further processing of this channel. return; } else { - final Pipeline pipeline = jedis.pipelined(); - pipeline.lpush(INGESTED_CHANNELS_KEY, this.channel.getIdAsString()); + // No. Add this channel's ID to the list of ingested channels. + jedis.lpush(INGESTED_CHANNELS_KEY, this.channel.getIdAsString()); + + // Log the start of the ingestion of this channel. logger.info("Starting ingestion of text channel history. (Name: \"" + this.channel.getName() + "\" | ID: " + this.channel.getIdAsString() + " | Server ID: " + this.channel.getServer().getIdAsString() + ")"); } } final MarkovChainMessages markovChainMessages = new MarkovChainMessages(this.crabstero); + // Start streaming messages in this channel from newest to oldest. try (final Stream history = this.channel.getMessagesAsStream()) { final Iterator iterator = history.iterator(); int i = 0; + // While the stream still has messages... while (iterator.hasNext()) { i++; + + //Ingest the message. markovChainMessages.ingestMessage(iterator.next()); - if (i == MAXIMUM_MESSAGES_PER_CHANNEL) { + + // Have we reached the maximum amount of messages to ingest per channel? + if (i >= MAXIMUM_MESSAGES_PER_CHANNEL) { + // Yes. Stop ingesting. break; } } + // Log the results of the ingestion. logger.info("Ingestion of text channel history complete. " + i + " messages ingested. (Name: \"" + this.channel.getName() + "\" | ID: " + this.channel.getIdAsString() + " | Server ID: " + this.channel.getServer().getIdAsString() + ")"); } } catch (final Throwable exception) { - logger.error("An error occured while ingesting text channel history! (Name: \"" + this.channel.getName() + "\" | ID: " + this.channel.getIdAsString() + " | Server ID: " + this.channel.getServer().getIdAsString() + ")", exception); + // A problem occurred somewhere in this task. Log the problem. + logger.error("An error occurred while ingesting text channel history! (Name: \"" + this.channel.getName() + "\" | ID: " + this.channel.getIdAsString() + " | Server ID: " + this.channel.getServer().getIdAsString() + ")", exception); } } } \ No newline at end of file diff --git a/src/main/java/dev/logal/crabstero/utils/MarkovChain.java b/src/main/java/dev/logal/crabstero/utils/MarkovChain.java index 95e8cb6..f618728 100644 --- a/src/main/java/dev/logal/crabstero/utils/MarkovChain.java +++ b/src/main/java/dev/logal/crabstero/utils/MarkovChain.java @@ -16,116 +16,215 @@ import java.security.SecureRandom; import java.util.List; import java.util.Random; +/** + * Ingests sentences and generates new ones using a Markov chain with a Redis storage. + */ public final class MarkovChain { - private static final char DEFAULT_SENTENCE_END = '§'; + private static final char DEFAULT_SENTENCE_END = '§'; // Default character to end sentences with if it does not include a period, exclamation mark, or question mark. - private final long id; - private final Crabstero crabstero; - private final Random rng; + private final long id; // A unique number for this Markov chain to segment it away from other Markov chains. This is usually the channel ID given by Discord. + private final Crabstero crabstero; // The Crabstero instance using this Markov chain. + private final Random rng; // A source of randomness. + /** + * Creates a new Markov chain identified by a given number and owned by a given instance of Crabstero. + * + * @param id The unique number. + * @param crabstero The instance of Crabstero. + */ public MarkovChain(final long id, final Crabstero crabstero) { this.id = id; this.crabstero = crabstero; - this.rng = new SecureRandom(); + this.rng = new SecureRandom(); // Probably placebo effect, but SecureRandom seems to produce better results. } + /** + * Creates a new Markov chain identified by a given number, owned by a given instance of Crabstero, and using a custom source of randomness. + * + * @param id The unique number. + * @param crabstero The instance of Crabstero. + * @param random The custom randomness source. + */ public MarkovChain(final long id, final Crabstero crabstero, final Random random) { this.id = id; this.crabstero = crabstero; this.rng = random; } + /** + * Checks whether a given sentence ends with a default sentence end character, a period, an exclamation mark, or a question mark. + * + * @param sentence The sentence to test. + * @return True if the sentence ends with a default sentence end character, a period, an exclamation mark, or a question mark, false otherwise. + */ private static boolean isCompleteSentence(final String sentence) { + // Is the given sentence an empty string? if (sentence.isEmpty()) { + // Yes. Therefor, it is not a complete sentence. return false; } + // Get the last character of the given sentence. final char lastChar = sentence.charAt(sentence.length() - 1); + + // Return true if th character is a default sentence end character, a period, an exclamation mark, or a question mark. return (lastChar == DEFAULT_SENTENCE_END || lastChar == '.' || lastChar == '!' || lastChar == '?'); } + /** + * Ingests a string potentially containing multiple smaller sentences. + * + * @param paragraph The paragraph of sentences to ingest. + */ public void ingest(String paragraph) { + // Is the given paragraph as a whole complete? if (!isCompleteSentence(paragraph)) { + // No. Add DEFAULT_SENTENCE_END to the end. paragraph += DEFAULT_SENTENCE_END; } + // First, trim all leading and trailing spaces. + // Next, replace repeated spaces with a single space. + // Then, replace new lines with spaces. + // Finally, split the paragraph into a string array with each element being a single sentence. final String[] sentences = paragraph.trim().replaceAll(" +", " ").replaceAll("\n", " ").split("(?<=[.!?]) "); + // Ingest each sentence individually. for (String sentence : sentences) { this.ingestSentence(sentence); } } + /** + * Ingests a string containing a single sentence. + * + * @param sentence The sentence to ingest. + */ private void ingestSentence(String sentence) { + // Is the given sentence complete? if (!isCompleteSentence(sentence)) { + // No. Add DEFAULT_SENTENCE_END to the end. sentence += DEFAULT_SENTENCE_END; } + // First, trim all leading and trailing spaces. + // Next, replace repeated spaces with a single space. + // Finally, split the sentence into a string array with each element being a word. String[] words = sentence.trim().replaceAll(" +", " ").split(" "); + // Get a reference to Jedis from the pool. try (final Jedis jedis = this.crabstero.getJedisPool().getResource()) { + // Use a pipeline to avoid blocking caused by waiting for responses from the Redis server. final Pipeline pipeline = jedis.pipelined(); + + // Iterate over the array of words, starting from the first element until the second to last element. for (int i = 0; i < words.length - 1; i++) { + // Is this the first word? if (i == 0) { + // Yes. Push the word into a special list containing starting words. pipeline.lpush(this.id + ":start", words[i]); - pipeline.lpush(this.id + "::" + words[i], words[i + 1]); - } else { - pipeline.lpush(this.id + "::" + words[i], words[i + 1]); } + + // Push the word after this one into a list identified by this word. + pipeline.lpush(this.id + "::" + words[i], words[i + 1]); } } } + /** + * Generates a new sentence using word learned from previously ingested sentences. + * + * @param softCharacterLimit The amount of characters to try and limit sentence length around. + * @param hardCharacterLimit The amount of characters to cut off the sentence at if it gets too long. + * @return A new sentence. + */ public String generate(final int softCharacterLimit, final int hardCharacterLimit) { + // Declare a new builder for building the new sentence. final StringBuilder newSentence = new StringBuilder(); + // Get a reference to Jedis from the pool. try (final Jedis jedis = this.crabstero.getJedisPool().getResource()) { + // Does this Markov chain have any starting words? if (!jedis.exists(this.id + ":start")) { + // No. Quickly ingest something to avoid throwing an error. + // TODO: Could this soft lock since a reference to the jedis pool is already held? this.ingestSentence("Hello world!"); } - String word = ""; + // Declare a variable for holding the word about to be added to the new sentence. + String word; + // Get a list of all starting words. final List startingWords = jedis.lrange(this.id + ":start", 0, -1); + + // Pick a random index for a starting word. int index = rng.nextInt(startingWords.size()); + + // Get the word. word = startingWords.get(index); + + // Add it to the output sentence. newSentence.append(word); + // While the selected word is in complete... + // Although quite rare, this loop will skip if the starting word is already complete on its own (e.g. "Yes.") while (!isCompleteSentence(word)) { + // Get the list of words which can be added after the previous word. final List wordChoices = jedis.lrange(this.id + "::" + word, 0, -1); + // Reset the index. index = -1; + + // Is the new sentence length above the soft character limit? if (newSentence.length() >= softCharacterLimit) { + // Yes. Time to aggressively search for words which can complete this sentence. + + // Iterate over the word choices. for (int i = 0; i < wordChoices.size(); i++) { + // Get current candidate word. final String candidate = wordChoices.get(i); + // Does this word conclude the sentence? if (isCompleteSentence(candidate)) { + // Select the index for this word and stop evaluating choices. index = i; break; } } + // Was no word to conclude the new sentence found? if (index == -1) { + // Yes. Pick a random word and hope the next word can conclude it. index = rng.nextInt(wordChoices.size()); } } else { + // No, there is still room. Pick a random index. index = rng.nextInt(wordChoices.size()); } + // Get the selected word. word = wordChoices.get(index); + + // Append a space and this word to the new sentence. newSentence.append(" ").append(word); + // Is the new sentence now longer than the hard character limit? final int sentenceLength = newSentence.length(); if (sentenceLength >= hardCharacterLimit) { + // Yes. Chop off the characters beyond the hard limit. newSentence.delete(hardCharacterLimit, sentenceLength); + // Break out of the loop to forcefully declare the new sentence complete. break; } } } + // Is the last character of the new sentence the default sentence end character? if (newSentence.charAt(newSentence.length() - 1) == DEFAULT_SENTENCE_END) { + // Yes. Get a rid of it. The default sentence end character is meant to internally mark the end of sentences which did not include a punctuation mark, as is common on Discord. return newSentence.deleteCharAt(newSentence.length() - 1).toString(); } else { + // No. Return the new sentence as is. return newSentence.toString(); } } diff --git a/src/main/java/dev/logal/crabstero/utils/MarkovChainMessages.java b/src/main/java/dev/logal/crabstero/utils/MarkovChainMessages.java index 6fda468..2fadbf4 100644 --- a/src/main/java/dev/logal/crabstero/utils/MarkovChainMessages.java +++ b/src/main/java/dev/logal/crabstero/utils/MarkovChainMessages.java @@ -24,15 +24,18 @@ import java.security.SecureRandom; import java.util.List; import java.util.Random; +/** + * Assists with generating Discord messages in response to other users and ingesting raw messages. + */ public class MarkovChainMessages { private final Crabstero crabstero; - private final AllowedMentions allowedMentions; private final Random rng = new SecureRandom(); public MarkovChainMessages(final Crabstero crabstero) { this.crabstero = crabstero; + // Set up an allowed mentions filter which blocks any mentions from generating notifications to users. final AllowedMentionsBuilder builder = new AllowedMentionsBuilder(); builder.setMentionEveryoneAndHere(false); builder.setMentionRoles(false); @@ -40,16 +43,27 @@ public class MarkovChainMessages { this.allowedMentions = builder.build(); } + /** + * Sends a new message in Discord in response to a given message. + * + * @param message The message prompting the response. + */ public void replyToMessage(final Message message) { final TextChannel channel = message.getChannel(); + + // Does Crabstero have permissions to write to the channel the message was sent in? if (!channel.canYouWrite()) { + // No. Give up. return; } final long channelID; + // Is this channel a thread? if (channel.asServerThreadChannel().isPresent()) { + // Yes. Store the ID of the parent channel for this thread. channelID = channel.asServerThreadChannel().get().getParent().getId(); } else { + // No. Store the ID of the channel the message was sent in. channelID = channel.getId(); } @@ -57,13 +71,20 @@ public class MarkovChainMessages { final MessageBuilder response = new MessageBuilder(); final MarkovChain markovChain = new MarkovChain(channelID, this.crabstero); + // Tell Discord which message Crabstero is replying to response.replyTo(message); + + // Generate a new body. response.setContent(markovChain.generate(750, 1000)); + // Was a random number greater than 0.95 (5% of the time) and does Crabstero have permission to use embeds in this channel? if (this.rng.nextDouble() >= 0.95 && channel.canYouEmbedLinks()) { + // Yes. Generate an embed with a random title and description. final EmbedBuilder embed = new EmbedBuilder(); embed.setTitle(markovChain.generate(200, 300)); embed.setDescription(markovChain.generate(300, 500)); + + // If image URLs are known for this channel, chose a random one and attach to the embed. try (final Jedis jedis = this.crabstero.getJedisPool().getResource()) { final List embedImageURLs = jedis.lrange(channelID + ":images", 0, -1); @@ -72,36 +93,61 @@ public class MarkovChainMessages { } } + // Add a watermark to the footer of the embed. embed.setFooter("Crabstero is a logal.dev project", "https://logal.dev/images/logo.png"); + + // Attach the embed to the response. response.setEmbed(embed); } + // Set the allowed mentions filter which blocks all mentions from generating notifications. response.setAllowedMentions(allowedMentions); + + // Send the response. response.send(channel).exceptionally(ExceptionLogger.get()); } + /** + * Ingests a given message into its channel's Markov chain. + * + * @param message The message to ingest. + */ public void ingestMessage(final Message message) { final MessageAuthor author = message.getAuthor(); + // Is the author of the message a bot, a webhook, or mentioning Crabstero? if (author.isBotUser() || author.isWebhook() || message.getMentionedUsers().contains(message.getApi().getYourself())) { + // Yes. Ignore it. return; } + // Get the Markov chain for the message's text channel. final long channelID = message.getChannel().getId(); final MarkovChain markovChain = new MarkovChain(channelID, this.crabstero); + // Ingest the message content into the Markov chain. markovChain.ingest(message.getContent()); + // If the message has embeds, ingest each one individually. for (final Embed embed : message.getEmbeds()) { ingestEmbed(channelID, embed); } } + /** + * Ingests a given embed into a given channel's Markov chain. + * + * @param channelID The ID of the channel to use for the Markov Chain. + * @param embed The embed to ingest. + */ public void ingestEmbed(final long channelID, final Embed embed) { + // Get the Markov chain for the given channel ID. final MarkovChain markovChain = new MarkovChain(channelID, this.crabstero); + // If the embed has a title or description, ingest each one separately. embed.getTitle().ifPresent(markovChain::ingest); embed.getDescription().ifPresent(markovChain::ingest); + // If the embed has an image, store the URL to the image. embed.getImage().ifPresent((image) -> { try (final Jedis jedis = this.crabstero.getJedisPool().getResource()) { jedis.lpush(channelID + ":images", image.getUrl().toString());