Initial commit.

This commit is contained in:
2022-06-30 10:45:55 -04:00
commit 03549bf5b5
18 changed files with 1118 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
/*
* Copyright 2022 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
package dev.logal.crabstero;
import dev.logal.crabstero.listeners.*;
import org.javacord.api.AccountType;
import org.javacord.api.DiscordApi;
import org.javacord.api.DiscordApiBuilder;
import org.javacord.api.Javacord;
import org.javacord.api.entity.intent.Intent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import java.util.concurrent.*;
public final class Crabstero {
public static final int maximumMessagesPerChannel = 50000;
private static final Logger logger = LoggerFactory.getLogger(Crabstero.class);
private static final String token = System.getenv("TOKEN");
private static final JedisPool jedisPool = new JedisPool(new JedisPoolConfig(), System.getenv("REDIS_HOST"));
private static final ScheduledExecutorService workerPool = Executors.newScheduledThreadPool(4, new CrabsteroThreadFactory());
private Crabstero(){
throw new UnsupportedOperationException();
}
public static void main(final String[] arguments){
logger.info(Javacord.USER_AGENT);
final DiscordApiBuilder builder = new DiscordApiBuilder();
builder.setAccountType(AccountType.BOT);
builder.setToken(token);
builder.setTrustAllCertificates(false);
builder.setWaitForServersOnStartup(false);
builder.setWaitForUsersOnStartup(false);
builder.setIntents(Intent.GUILDS, Intent.GUILD_MESSAGES);
builder.addListener(new MessageCreate());
builder.addListener(new RoleChangePermissions());
builder.addListener(new ServerBecomesAvailable());
builder.addListener(new ServerChannelChangeOverwrittenPermissions());
builder.addListener(new ServerJoin());
builder.addListener(new UserRoleAdd());
builder.setRecommendedTotalShards();
final DiscordApi api = builder.login().join();
api.setMessageCacheSize(0, 1);
}
public static Future<?> submitTask(final Runnable task){
return workerPool.submit(task);
}
public static ScheduledFuture<?> scheduleTask(final Runnable task, final long delay, final TimeUnit unit){
return workerPool.schedule(task, delay, unit);
}
public static Jedis getJedis(){
return jedisPool.getResource();
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright 2022 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
package dev.logal.crabstero;
import java.util.concurrent.ThreadFactory;
public final class CrabsteroThreadFactory implements ThreadFactory {
private static int threadNumber = 1;
@Override
public Thread newThread(final Runnable runnable){
final Thread thread = new Thread(runnable);
thread.setName("Crabstero Worker Thread - " + threadNumber++);
return thread;
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2022 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
package dev.logal.crabstero.listeners;
import dev.logal.crabstero.utils.MarkovChainMessages;
import org.javacord.api.entity.channel.ServerTextChannel;
import org.javacord.api.entity.message.Message;
import org.javacord.api.entity.message.MessageAuthor;
import org.javacord.api.entity.message.MessageType;
import org.javacord.api.event.message.MessageCreateEvent;
import org.javacord.api.listener.message.MessageCreateListener;
import java.util.Optional;
public final class MessageCreate implements MessageCreateListener {
@Override
public void onMessageCreate(final MessageCreateEvent event){
final MessageAuthor author = event.getMessageAuthor();
final Optional<ServerTextChannel> serverTextChannel = event.getChannel().asServerTextChannel();
if (!serverTextChannel.isPresent() || author.isBotUser() || author.isWebhook() || author.isYourself()){
return;
}
final Message message = event.getMessage();
if (message.getMentionedUsers().contains(event.getApi().getYourself())){
MarkovChainMessages.replyToMessage(message);
return;
}
if (message.getType() == MessageType.NORMAL){
MarkovChainMessages.ingestMessage(message);
}
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2022 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
package dev.logal.crabstero.listeners;
import dev.logal.crabstero.Crabstero;
import dev.logal.crabstero.tasks.ChannelHistoryIngestionTask;
import org.javacord.api.event.server.role.RoleChangePermissionsEvent;
import org.javacord.api.listener.server.role.RoleChangePermissionsListener;
public final class RoleChangePermissions implements RoleChangePermissionsListener {
@Override
public void onRoleChangePermissions(final RoleChangePermissionsEvent event){
if (event.getRole().hasUser(event.getApi().getYourself())){
event.getServer().getTextChannels().forEach((channel) -> {
Crabstero.submitTask(new ChannelHistoryIngestionTask(channel));
});
}
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2022 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
package dev.logal.crabstero.listeners;
import dev.logal.crabstero.Crabstero;
import dev.logal.crabstero.tasks.ChannelHistoryIngestionTask;
import org.javacord.api.event.server.ServerBecomesAvailableEvent;
import org.javacord.api.listener.server.ServerBecomesAvailableListener;
public final class ServerBecomesAvailable implements ServerBecomesAvailableListener {
@Override
public void onServerBecomesAvailable(final ServerBecomesAvailableEvent event){
event.getServer().getTextChannels().forEach((channel) -> {
Crabstero.submitTask(new ChannelHistoryIngestionTask(channel));
});
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2022 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
package dev.logal.crabstero.listeners;
import dev.logal.crabstero.Crabstero;
import dev.logal.crabstero.tasks.ChannelHistoryIngestionTask;
import org.javacord.api.event.channel.server.ServerChannelChangeOverwrittenPermissionsEvent;
import org.javacord.api.listener.channel.server.ServerChannelChangeOverwrittenPermissionsListener;
public final class ServerChannelChangeOverwrittenPermissions implements ServerChannelChangeOverwrittenPermissionsListener {
@Override
public void onServerChannelChangeOverwrittenPermissions(final ServerChannelChangeOverwrittenPermissionsEvent event){
event.getChannel().asServerTextChannel().ifPresent((channel) -> {
Crabstero.submitTask(new ChannelHistoryIngestionTask(channel));
});
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2022 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
package dev.logal.crabstero.listeners;
import dev.logal.crabstero.Crabstero;
import dev.logal.crabstero.tasks.ChannelHistoryIngestionTask;
import org.javacord.api.entity.message.embed.EmbedBuilder;
import org.javacord.api.entity.server.Server;
import org.javacord.api.event.server.ServerJoinEvent;
import org.javacord.api.listener.server.ServerJoinListener;
import org.javacord.api.util.logging.ExceptionLogger;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.*;
public final class ServerJoin implements ServerJoinListener {
private static final Logger logger = LoggerFactory.getLogger(ServerJoin.class);
@Override
public void onServerJoin(final ServerJoinEvent event){
final Server server = event.getServer();
logger.info("Joined new server! (Name: \"" + server.getName() + "\" | ID: " + server.getIdAsString() + ")");
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.getTextChannels().forEach((channel) -> {
Crabstero.submitTask(new ChannelHistoryIngestionTask(channel));
});
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2022 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
package dev.logal.crabstero.listeners;
import dev.logal.crabstero.Crabstero;
import dev.logal.crabstero.tasks.ChannelHistoryIngestionTask;
import org.javacord.api.event.server.role.UserRoleAddEvent;
import org.javacord.api.listener.server.role.UserRoleAddListener;
public final class UserRoleAdd implements UserRoleAddListener {
@Override
public void onUserRoleAdd(final UserRoleAddEvent event){
if (event.getUser().isYourself()){
event.getServer().getTextChannels().forEach((channel) -> {
Crabstero.submitTask(new ChannelHistoryIngestionTask(channel));
});
}
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright 2022 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
package dev.logal.crabstero.tasks;
import dev.logal.crabstero.Crabstero;
import dev.logal.crabstero.utils.MarkovChainMessages;
import org.javacord.api.entity.channel.ServerTextChannel;
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;
public final class ChannelHistoryIngestionTask implements Runnable {
private static final String INGESTED_CHANNELS_KEY = "ingestedChannels";
private static final Logger logger = LoggerFactory.getLogger(ChannelHistoryIngestionTask.class);
private final ServerTextChannel channel;
public ChannelHistoryIngestionTask(final ServerTextChannel channel){
this.channel = channel;
}
@Override
public void run(){
try {
if (!this.channel.canYouReadMessageHistory()){
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 = Crabstero.getJedis()){
if (jedis.lrange(INGESTED_CHANNELS_KEY, 0, -1).contains(this.channel.getIdAsString())){
return;
} else {
final Pipeline pipeline = jedis.pipelined();
pipeline.lpush(INGESTED_CHANNELS_KEY, this.channel.getIdAsString());
logger.info("Starting ingestion of text channel history. (Name: \"" + this.channel.getName() + "\" | ID: " + this.channel.getIdAsString() + " | Server ID: " + this.channel.getServer().getIdAsString() + ")");
}
}
try (final Stream<Message> history = this.channel.getMessagesAsStream()){
final Iterator<Message> iterator = history.iterator();
int i = 0;
while (iterator.hasNext()){
i++;
MarkovChainMessages.ingestMessage(iterator.next());
if (i == Crabstero.maximumMessagesPerChannel){
break;
}
}
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);
}
}
}

View File

@@ -0,0 +1,123 @@
/*
* Copyright 2022 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
package dev.logal.crabstero.utils;
import dev.logal.crabstero.Crabstero;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
import java.security.SecureRandom;
import java.util.List;
import java.util.Random;
public final class MarkovChain {
private static final char DEFAULT_SENTENCE_END = '§';
private static final Random rng = new SecureRandom();
private final long id;
public MarkovChain(final long id){
this.id = id;
}
private static final boolean isCompleteSentence(final String sentence){
if (sentence.isEmpty()){
return false;
}
final char lastChar = sentence.charAt(sentence.length() - 1);
return (lastChar == DEFAULT_SENTENCE_END || lastChar == '.' || lastChar == '!' || lastChar == '?');
}
public void ingest(String paragraph){
if (!isCompleteSentence(paragraph)){
paragraph += DEFAULT_SENTENCE_END;
}
final String[] sentences = paragraph.trim().replaceAll(" +", " ").replaceAll("\n", " ").split("(?<=[.!?]) ");
for (int i = 0; i < sentences.length; i++){
this.ingestSentence(sentences[i]);
}
}
private void ingestSentence(String sentence){
if (!isCompleteSentence(sentence)){
sentence += DEFAULT_SENTENCE_END;
}
String[] words = sentence.trim().replaceAll(" +", " ").split(" ");
try (final Jedis jedis = Crabstero.getJedis()){
final Pipeline pipeline = jedis.pipelined();
for (int i = 0; i < words.length - 1; i++){
if (i == 0){
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]);
}
}
}
}
public String generate(final int softCharacterLimit, final int hardCharacterLimit){
final StringBuilder newSentence = new StringBuilder();
try (final Jedis jedis = Crabstero.getJedis()){
if (!jedis.exists(this.id + ":start")){
this.ingestSentence("Hello world!");
}
String word = "";
final List<String> startingWords = jedis.lrange(this.id + ":start", 0, -1);
int index = rng.nextInt(startingWords.size());
word = startingWords.get(index);
newSentence.append(word);
while (!isCompleteSentence(word)){
final List<String> wordChoices = jedis.lrange(this.id + "::" + word, 0, -1);
index = -1;
if (newSentence.length() >= softCharacterLimit){
for (int i = 0; i < wordChoices.size(); i++){
final String candidate = wordChoices.get(i);
if (isCompleteSentence(candidate)){
index = i;
break;
}
}
if (index == -1){
index = rng.nextInt(wordChoices.size());
}
} else {
index = rng.nextInt(wordChoices.size());
}
word = wordChoices.get(index);
newSentence.append(" " + word);
final int sentenceLength = newSentence.length();
if (sentenceLength >= hardCharacterLimit){
newSentence.delete(hardCharacterLimit, sentenceLength);
break;
}
}
}
if (newSentence.charAt(newSentence.length() - 1) == DEFAULT_SENTENCE_END){
return newSentence.deleteCharAt(newSentence.length() - 1).toString();
} else {
return newSentence.toString();
}
}
}

View File

@@ -0,0 +1,110 @@
/*
* Copyright 2022 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
package dev.logal.crabstero.utils;
import dev.logal.crabstero.Crabstero;
import org.javacord.api.entity.channel.TextChannel;
import org.javacord.api.entity.message.Message;
import org.javacord.api.entity.message.MessageAuthor;
import org.javacord.api.entity.message.MessageBuilder;
import org.javacord.api.entity.message.embed.Embed;
import org.javacord.api.entity.message.embed.EmbedBuilder;
import org.javacord.api.entity.message.mention.AllowedMentions;
import org.javacord.api.entity.message.mention.AllowedMentionsBuilder;
import org.javacord.api.util.logging.ExceptionLogger;
import redis.clients.jedis.Jedis;
import java.security.SecureRandom;
import java.util.List;
import java.util.Random;
public class MarkovChainMessages {
private static final AllowedMentions allowedMentions;
private static final Random rng = new SecureRandom();
static {
final AllowedMentionsBuilder builder = new AllowedMentionsBuilder();
builder.setMentionEveryoneAndHere(false);
builder.setMentionRoles(false);
builder.setMentionUsers(false);
allowedMentions = builder.build();
}
private MarkovChainMessages(){
throw new UnsupportedOperationException();
}
public static void replyToMessage(final Message message){
final TextChannel channel = message.getChannel();
if (!channel.canYouWrite()){
return;
}
final long channelID = channel.getId();
final MessageBuilder response = new MessageBuilder();
final MarkovChain markovChain = new MarkovChain(channelID);
response.replyTo(message);
response.setContent(markovChain.generate(750, 1000));
if (rng.nextDouble() >= 0.95 && channel.canYouEmbedLinks()){
final EmbedBuilder embed = new EmbedBuilder();
embed.setTitle(markovChain.generate(200, 300));
embed.setDescription(markovChain.generate(300, 500));
try (final Jedis jedis = Crabstero.getJedis()){
final List<String> embedImageURLs = jedis.lrange(channelID + ":images", 0, -1);
if (embedImageURLs.size() > 0){
embed.setImage(embedImageURLs.get(rng.nextInt(embedImageURLs.size())));
}
}
embed.setFooter("Crabstero is a logal.dev project", "https://logal.dev/images/logo.png");
response.setEmbed(embed);
}
response.setAllowedMentions(allowedMentions);
response.send(channel).exceptionally(ExceptionLogger.get());
}
public static void ingestMessage(final Message message){
final MessageAuthor author = message.getAuthor();
if (author.isBotUser() || author.isWebhook() || message.getMentionedUsers().contains(message.getApi().getYourself())){
return;
}
final long channelID = message.getChannel().getId();
final MarkovChain markovChain = new MarkovChain(channelID);
markovChain.ingest(message.getContent());
for (final Embed embed : message.getEmbeds()){
ingestEmbed(channelID, embed);
}
}
public static void ingestEmbed(final long channelID, final Embed embed){
final MarkovChain markovChain = new MarkovChain(channelID);
embed.getTitle().ifPresent((title) -> {
markovChain.ingest(title);
});
embed.getDescription().ifPresent((description) -> {
markovChain.ingest(description);
});
embed.getImage().ifPresent((image) -> {
try (final Jedis jedis = Crabstero.getJedis()){
jedis.lpush(channelID + ":images", image.getUrl().toString());
}
});
}
}