Added code comments.
This commit is contained in:
parent
3aa3b64e1c
commit
e4c4be429c
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
|
|
|
@ -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<Message> history = this.channel.getMessagesAsStream()) {
|
||||
final Iterator<Message> 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<String> 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<String> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<String> 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());
|
||||
|
|
Loading…
Reference in New Issue