Implement SQLite database solving #1
continuous-integration/drone/push Build is passing Details

A new basic database has been laid out, with support for message expiry and disabling buttons for old messages.
This commit is contained in:
Bea 2022-11-21 00:14:13 +01:00
parent 7ffd3442c2
commit 98a162a33b
7 changed files with 569 additions and 115 deletions

BIN
db.sqlite Normal file

Binary file not shown.

View File

@ -30,6 +30,12 @@
<artifactId>slf4j-simple</artifactId>
<version>2.0.0</version>
</dependency>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.39.4.1</version>
</dependency>
</dependencies>
<build>

View File

@ -1,17 +1,23 @@
package wtf.beatrice.hidekobot;
import org.jetbrains.annotations.Nullable;
import wtf.beatrice.hidekobot.database.DatabaseManager;
import wtf.beatrice.hidekobot.listeners.MessageLogger;
public class Configuration
{
private static DatabaseManager dbManager = null;
private static boolean verbose = false;
private static MessageLogger verbosityLogger;
// todo: allow people to set their own user id
private static final long botOwnerId = 979809420714332260L;
private final static String expiryTimestampFormat = "yy/MM/dd HH:mm:ss";
private final static long expiryTimeSeconds = 60L;
private final static String defaultInviteLink =
"https://discord.com/api/oauth2/authorize?client_id=%userid%&scope=bot+applications.commands&permissions=8";
@ -95,4 +101,35 @@ public class Configuration
return defaultInviteLink.replace("%userid%", botApplicationId);
}
/**
* Set the already fully-initialized DatabaseManager instance, ready to be accessed and used.
*
* @param databaseManagerInstance the fully-initialized DatabaseManager instance.
*/
public static void setDatabaseManagerInstance(DatabaseManager databaseManagerInstance)
{
dbManager = databaseManagerInstance;
}
/**
* Get the fully-initialized DatabaseManager instance, ready to be used.
*
* @return the DatabaseManager instance.
*/
public static @Nullable DatabaseManager getDatabaseManager() { return dbManager; }
/**
* Get the DateTimeFormatter string for parsing the expired messages timestamp.
*
* @return the String of the DateTimeFormatter format.
*/
public static String getExpiryTimestampFormat(){ return expiryTimestampFormat; }
/**
* Get the amount of seconds after which a message expires.
*
* @return long value of the expiry seconds.
*/
public static long getExpiryTimeSeconds() { return expiryTimeSeconds; }
}

View File

@ -6,17 +6,21 @@ import net.dv8tion.jda.api.OnlineStatus;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.requests.GatewayIntent;
import sun.misc.Signal;
import wtf.beatrice.hidekobot.database.DatabaseManager;
import wtf.beatrice.hidekobot.listeners.ButtonInteractionListener;
import wtf.beatrice.hidekobot.listeners.MessageListener;
import wtf.beatrice.hidekobot.listeners.SlashCommandCompleter;
import wtf.beatrice.hidekobot.listeners.SlashCommandListener;
import wtf.beatrice.hidekobot.utils.ExpiredMessageRunner;
import wtf.beatrice.hidekobot.utils.Logger;
import wtf.beatrice.hidekobot.utils.SlashCommandsUtil;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class HidekoBot
@ -93,6 +97,30 @@ public class HidekoBot
jda.getPresence().setStatus(OnlineStatus.ONLINE);
jda.getPresence().setActivity(Activity.playing("Hatsune Miku: Project DIVA"));
// connect to database
logger.log("Connecting to database...");
String dbFilePath = System.getProperty("user.dir") + File.separator + "db.sqlite"; // in current directory
DatabaseManager dbManager = new DatabaseManager(dbFilePath);
if(dbManager.connect() && dbManager.initDb())
{
logger.log("Database connection initialized!");
Configuration.setDatabaseManagerInstance(dbManager);
// load data here...
logger.log("Database data loaded into memory!");
} else {
logger.log("Error initializing database connection!");
}
// start scheduled runnables
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
ExpiredMessageRunner task = new ExpiredMessageRunner();
int initDelay = 5;
int periodicDelay = 5;
scheduler.scheduleAtFixedRate(task, initDelay, periodicDelay, TimeUnit.SECONDS);
// print the bot logo.
logger.log("\n\n" + logger.getLogo() + "\nv" + version + " - bot is ready!\n", 2);

View File

@ -10,7 +10,9 @@ import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.interactions.InteractionHook;
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
import net.dv8tion.jda.api.interactions.components.buttons.Button;
import net.dv8tion.jda.api.requests.restaction.WebhookMessageEditAction;
import org.jetbrains.annotations.NotNull;
import wtf.beatrice.hidekobot.Configuration;
import java.util.ArrayList;
import java.util.List;
@ -20,145 +22,161 @@ public class ClearChatCommand
public void runSlashCommand(@NotNull SlashCommandInteractionEvent event)
{
MessageChannel channel = event.getChannel();
if(!(channel instanceof TextChannel))
// run in a new thread, so we don't block the main one
new Thread(() ->
{
event.reply("Sorry! I can't delete messages here.").queue();
return;
}
/* get the amount from the command args.
NULL should not be possible because we specified them as mandatory,
but apparently the mobile app doesn't care and still sends the command if you omit the args. */
OptionMapping amountMapping = event.getOption("amount");
int toDeleteAmount = amountMapping == null ? 1 : amountMapping.getAsInt();
MessageChannel channel = event.getChannel();
if(toDeleteAmount <= 0)
{
event.reply("Sorry, I can't delete that amount of messages!").queue();
}
else {
// answer by saying that the operation has begun.
InteractionHook replyInteraction = event.reply("\uD83D\uDEA7 Clearing...").complete();
// int to keep track of how many messages we actually deleted.
int deleted = 0;
int limit = 95; //discord limits this method to range 2-100. we set it to 95 to be safe.
// increase the count by 1, because we technically aren't clearing the first ID ever
// which is actually the slash command's ID and not a message.
toDeleteAmount++;
// count how many times we have to iterate this to delete the full <toDeleteAmount> messages.
int iterations = toDeleteAmount / limit;
//if there are some messages left, but less than <limit>, we need one more iterations.
int remainder = toDeleteAmount % limit;
if(remainder != 0) iterations++;
// set the starting point.
long messageId = event.getInteraction().getIdLong();
// boolean to see if we're trying to delete more messages than possible.
boolean outOfBounds = false;
// do iterate.
for(int iteration = 0; iteration < iterations; iteration++)
if(!(channel instanceof TextChannel))
{
if(outOfBounds) break;
event.reply("Sorry! I can't delete messages here.").queue();
return;
}
// set how many messages to delete for this iteration (usually <limit> unless there's a remainder)
int iterationSize = limit;
/* get the amount from the command args.
NULL should not be possible because we specified them as mandatory,
but apparently the mobile app doesn't care and still sends the command if you omit the args. */
OptionMapping amountMapping = event.getOption("amount");
int toDeleteAmount = amountMapping == null ? 1 : amountMapping.getAsInt();
// if we are at the last iteration...
if(iteration+1 == iterations)
if(toDeleteAmount <= 0)
{
event.reply("Sorry, I can't delete that amount of messages!").queue();
}
else {
// answer by saying that the operation has begun.
InteractionHook replyInteraction = event.reply("\uD83D\uDEA7 Clearing...").complete();
// int to keep track of how many messages we actually deleted.
int deleted = 0;
int limit = 95; //discord limits this method to range 2-100. we set it to 95 to be safe.
// increase the count by 1, because we technically aren't clearing the first ID ever
// which is actually the slash command's ID and not a message.
toDeleteAmount++;
// count how many times we have to iterate this to delete the full <toDeleteAmount> messages.
int iterations = toDeleteAmount / limit;
//if there are some messages left, but less than <limit>, we need one more iterations.
int remainder = toDeleteAmount % limit;
if(remainder != 0) iterations++;
// set the starting point.
long messageId = event.getInteraction().getIdLong();
// boolean to see if we're trying to delete more messages than possible.
boolean outOfBounds = false;
// do iterate.
for(int iteration = 0; iteration < iterations; iteration++)
{
// check if we have <limit> or fewer messages to delete
if(remainder != 0) iterationSize = remainder;
}
if(outOfBounds) break;
if(iterationSize == 1)
{
// grab the message
Message toDelete = ((TextChannel)channel).retrieveMessageById(messageId).complete();
//only delete one message
if(toDelete != null) toDelete.delete().queue();
else outOfBounds = true;
// increase deleted counter by 1
deleted++;
} else {
// get the last <iterationSize - 1> messages.
MessageHistory.MessageRetrieveAction action = channel.getHistoryBefore(messageId, iterationSize - 1);
// note: first one is the most recent, last one is the oldest message.
List<Message> messages = new ArrayList<>();
// (we are skipping first iteration since it would return an error, given that the id is the slash command and not a message)
if(iteration!=0) messages.add(((TextChannel)channel).retrieveMessageById(messageId).complete());
messages.addAll(action.complete().getRetrievedHistory());
// set how many messages to delete for this iteration (usually <limit> unless there's a remainder)
int iterationSize = limit;
// check if we only have one or zero messages left (trying to delete more than possible)
if(messages.size() <= 1)
// if we are at the last iteration...
if(iteration+1 == iterations)
{
outOfBounds = true;
} else {
// before deleting, we need to grab the <previous to the oldest> message's id for next iteration.
action = channel.getHistoryBefore(messages.get(messages.size() - 1).getIdLong(), 1);
// check if we have <limit> or fewer messages to delete
if(remainder != 0) iterationSize = remainder;
}
List<Message> previousMessage = action.complete().getRetrievedHistory();
// if that message exists (we are not out of bounds)... store it
if(!previousMessage.isEmpty()) messageId = previousMessage.get(0).getIdLong();
if(iterationSize == 1)
{
// grab the message
Message toDelete = ((TextChannel)channel).retrieveMessageById(messageId).complete();
//only delete one message
if(toDelete != null) toDelete.delete().queue();
else outOfBounds = true;
}
// increase deleted counter by 1
deleted++;
} else {
// get the last <iterationSize - 1> messages.
MessageHistory.MessageRetrieveAction action = channel.getHistoryBefore(messageId, iterationSize - 1);
// note: first one is the most recent, last one is the oldest message.
List<Message> messages = new ArrayList<>();
// (we are skipping first iteration since it would return an error, given that the id is the slash command and not a message)
if(iteration!=0) messages.add(((TextChannel)channel).retrieveMessageById(messageId).complete());
messages.addAll(action.complete().getRetrievedHistory());
// queue messages for deletion
if(messages.size() == 1)
{
messages.get(0).delete().queue();
}
else if(!messages.isEmpty())
{
try {
((TextChannel) channel).deleteMessages(messages).complete();
/* alternatively, we could use purgeMessages, which is smarter...
however, it also tries to delete messages older than 2 weeks
which are restricted by discord, and thus has to use
a less efficient way that triggers rate-limiting very quickly. */
} catch (Exception e)
// check if we only have one or zero messages left (trying to delete more than possible)
if(messages.size() <= 1)
{
replyInteraction.editOriginal("\uD83D\uDE22 Sorry, it seems like there was an issue! " + e.getMessage()).queue();
return; // warning: this quits everything.
outOfBounds = true;
} else {
// before deleting, we need to grab the <previous to the oldest> message's id for next iteration.
action = channel.getHistoryBefore(messages.get(messages.size() - 1).getIdLong(), 1);
List<Message> previousMessage = action.complete().getRetrievedHistory();
// if that message exists (we are not out of bounds)... store it
if(!previousMessage.isEmpty()) messageId = previousMessage.get(0).getIdLong();
else outOfBounds = true;
}
// queue messages for deletion
if(messages.size() == 1)
{
messages.get(0).delete().queue();
}
else if(!messages.isEmpty())
{
try {
((TextChannel) channel).deleteMessages(messages).complete();
/* alternatively, we could use purgeMessages, which is smarter...
however, it also tries to delete messages older than 2 weeks
which are restricted by discord, and thus has to use
a less efficient way that triggers rate-limiting very quickly. */
} catch (Exception e)
{
replyInteraction.editOriginal("\uD83D\uDE22 Sorry, it seems like there was an issue! " + e.getMessage()).queue();
return; // warning: this quits everything.
}
}
// increase deleted counter by <list size>
deleted += messages.size();
}
// increase deleted counter by <list size>
deleted += messages.size();
}
Button deleteButton = Button.danger("clear_delete", "Delete").withEmoji(Emoji.fromUnicode(""));
WebhookMessageEditAction<Message> webhookMessageEditAction;
// log having deleted the messages (or not).
if(deleted < 1)
{
webhookMessageEditAction = replyInteraction.editOriginal("\uD83D\uDE22 Couldn't clear any message!");
} else if(deleted == 1)
{
webhookMessageEditAction = replyInteraction.editOriginal("✂ Cleared 1 message!");
} else {
webhookMessageEditAction = replyInteraction.editOriginal("✂ Cleared " + deleted + " messages!");
}
Message message = webhookMessageEditAction
.setActionRow(deleteButton)
.complete();
String replyMessageId = message.getId();
String replyChannelId = message.getChannel().getId();
String replyGuildId = message.getGuild().getId();
Configuration.getDatabaseManager().queueDisabling(replyGuildId, replyChannelId, replyMessageId);
}
}).start();
Button deleteButton = Button.danger("clear_delete", "Delete").withEmoji(Emoji.fromUnicode(""));
// log having deleted the messages (or not).
if(deleted < 1)
{
replyInteraction.editOriginal("\uD83D\uDE22 Couldn't clear any message!")
.setActionRow(deleteButton).queue();
} else if(deleted == 1)
{
replyInteraction.editOriginal("✂ Cleared 1 message!")
.setActionRow(deleteButton).queue();
} else {
replyInteraction.editOriginal("✂ Cleared " + deleted + " messages!")
.setActionRow(deleteButton).queue();
}
}
}
public void deleteButton(ButtonInteractionEvent event)
{
// todo: permissions check
event.getInteraction().getMessage().delete().queue();
}
}

View File

@ -0,0 +1,266 @@
package wtf.beatrice.hidekobot.database;
import wtf.beatrice.hidekobot.Configuration;
import wtf.beatrice.hidekobot.utils.Logger;
import java.sql.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
public class DatabaseManager
{
private final static String sqliteURL = "jdbc:sqlite:%path%";
private Connection dbConnection = null;
private final String dbPath;
private final Logger logger;
public DatabaseManager(String dbPath)
{
this.dbPath = dbPath;
this.logger = new Logger(getClass());
}
public boolean connect()
{
String url = sqliteURL.replace("%path%", dbPath);
if(!close()) return false;
try {
dbConnection = DriverManager.getConnection(url);
logger.log("Database connection established!");
return true;
} catch (SQLException e) {
e.printStackTrace();
return false;
}
}
public boolean close()
{
if (dbConnection != null)
{
try {
if(!dbConnection.isClosed())
{
dbConnection.close();
}
} catch (SQLException e) {
e.printStackTrace();
return false;
}
dbConnection = null;
}
return true;
}
public boolean initDb()
{
List<String> newTables = new ArrayList<>();
newTables.add("CREATE TABLE IF NOT EXISTS pending_disabled_messages (" +
"guild_id TEXT NOT NULL, " +
"channel_id TEXT NOT NULL," +
"message_id TEXT NOT NULL," +
"expiry_timestamp TEXT NOT NULL" +
");");
newTables.add("CREATE TABLE IF NOT EXISTS command_runners (" +
"guild_id TEXT NOT NULL, " +
"channel_id TEXT NOT NULL," + // channel the command was run in
"message_id TEXT NOT NULL," + // message id of the bot's response
"user_id TEXT NOT NULL" + // user who ran the command
");");
for(String sql : newTables)
{
try (Statement stmt = dbConnection.createStatement()) {
// execute the statement
stmt.execute(sql);
} catch (SQLException e) {
e.printStackTrace();
return false;
}
}
return true;
}
public boolean queueDisabling(String guildId, String channelId, String messageId)
{
LocalDateTime expiryTime = LocalDateTime.now().plusSeconds(Configuration.getExpiryTimeSeconds());
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(Configuration.getExpiryTimestampFormat());
String expiryTimeFormatted = dateTimeFormatter.format(expiryTime);
String query = "INSERT INTO pending_disabled_messages " +
"(guild_id, channel_id, message_id, expiry_timestamp) VALUES " +
" (?, ?, ?, ?);";
try(PreparedStatement preparedStatement = dbConnection.prepareStatement(query))
{
preparedStatement.setString(1, guildId);
preparedStatement.setString(2, channelId);
preparedStatement.setString(3, messageId);
preparedStatement.setString(4, expiryTimeFormatted);
preparedStatement.executeUpdate();
return true;
} catch (SQLException e)
{
e.printStackTrace();
}
return false;
}
public List<String> getQueuedExpiringMessages()
{
List<String> messages = new ArrayList<>();
String query = "SELECT message_id " +
"FROM pending_disabled_messages ";
try (Statement statement = dbConnection.createStatement())
{
ResultSet resultSet = statement.executeQuery(query);
if(resultSet.isClosed()) return messages;
while(resultSet.next())
{
messages.add(resultSet.getString("message_id"));
}
} catch (SQLException e) {
e.printStackTrace();
}
return messages;
}
public boolean untrackExpiredMessage(String messageId)
{
String query = "DELETE FROM pending_disabled_messages WHERE message_id = ?;";
try(PreparedStatement preparedStatement = dbConnection.prepareStatement(query))
{
preparedStatement.setString(1, messageId);
preparedStatement.execute();
} catch (SQLException e)
{
e.printStackTrace();
return false;
}
query = "DELETE FROM command_runners WHERE message_id = ?;";
try(PreparedStatement preparedStatement = dbConnection.prepareStatement(query))
{
preparedStatement.setString(1, messageId);
preparedStatement.execute();
} catch (SQLException e)
{
e.printStackTrace();
return false;
}
return true;
}
public String getQueuedExpiringMessageExpiryDate(String messageId)
{
String query = "SELECT expiry_timestamp " +
"FROM pending_disabled_messages " +
"WHERE message_id = ?;";
try(PreparedStatement preparedStatement = dbConnection.prepareStatement(query))
{
preparedStatement.setString(1, messageId);
ResultSet resultSet = preparedStatement.executeQuery();
if(resultSet.isClosed()) return null;
while(resultSet.next())
{
return resultSet.getString("expiry_timestamp");
}
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
public String getQueuedExpiringMessageChannel(String messageId)
{
String query = "SELECT channel_id " +
"FROM pending_disabled_messages " +
"WHERE message_id = ?;";
try(PreparedStatement preparedStatement = dbConnection.prepareStatement(query))
{
preparedStatement.setString(1, messageId);
ResultSet resultSet = preparedStatement.executeQuery();
if(resultSet.isClosed()) return null;
while(resultSet.next())
{
return resultSet.getString("channel_id");
}
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
public String getQueuedExpiringMessageGuild(String messageId)
{
String query = "SELECT guild_id " +
"FROM pending_disabled_messages " +
"WHERE message_id = ?;";
try(PreparedStatement preparedStatement = dbConnection.prepareStatement(query))
{
preparedStatement.setString(1, messageId);
ResultSet resultSet = preparedStatement.executeQuery();
if(resultSet.isClosed()) return null;
while(resultSet.next())
{
return resultSet.getString("guild_id");
}
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
/**
* DB STRUCTURE
* TABLE 1: pending_disabled_messages
* ----------------------------------------------------------------------------------
* | guild_id | channel_id | message_id | expiry_timestamp |
* ----------------------------------------------------------------------------------
* |39402849302 | 39402849302 | 39402849302 | 2022-11-20 22:45:53:300 |
* ----------------------------------------------------------------------------------
*
*
* TABLE 2: command_runners
* --------------------------------------------------------------------------
* | guild_id | channel_id | message_id | user_id |
* --------------------------------------------------------------------------
* | 39402849302 | 39402849302 | 39402849302 | 39402849302 |
* --------------------------------------------------------------------------
*
*/
}

View File

@ -0,0 +1,99 @@
package wtf.beatrice.hidekobot.utils;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.interactions.components.LayoutComponent;
import net.dv8tion.jda.api.requests.RestAction;
import wtf.beatrice.hidekobot.Configuration;
import wtf.beatrice.hidekobot.HidekoBot;
import wtf.beatrice.hidekobot.database.DatabaseManager;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
public class ExpiredMessageRunner implements Runnable {
private final DateTimeFormatter formatter;
private final Logger logger;
private DatabaseManager databaseManager;
public ExpiredMessageRunner()
{
String format = Configuration.getExpiryTimestampFormat();
formatter = DateTimeFormatter.ofPattern(format);
databaseManager = Configuration.getDatabaseManager();
logger = new Logger(getClass());
}
@Override
public void run() {
databaseManager = Configuration.getDatabaseManager();
if(databaseManager == null) return;
List<String> expiringMessages = Configuration.getDatabaseManager().getQueuedExpiringMessages();
if(expiringMessages == null || expiringMessages.isEmpty()) return;
LocalDateTime now = LocalDateTime.now();
for(String messageId : expiringMessages)
{
logger.log("ID: " + messageId);
String expiryTimestamp = databaseManager.getQueuedExpiringMessageExpiryDate(messageId);
if(expiryTimestamp == null || expiryTimestamp.equals("")) continue; //todo: idk count it as expired already?
LocalDateTime expiryDate = LocalDateTime.parse(expiryTimestamp, formatter);
if(now.isAfter(expiryDate))
{
disableExpired(messageId);
}
}
}
private void disableExpired(String messageId)
{
String guildId = databaseManager.getQueuedExpiringMessageGuild(messageId);
String channelId = databaseManager.getQueuedExpiringMessageChannel(messageId);
Guild guild = HidekoBot.getAPI().getGuildById(guildId);
if(guild == null) return; //todo count it as done/solved/removed? we probably got kicked
TextChannel textChannel = guild.getTextChannelById(channelId);
if(textChannel == null) return; //todo count it as done/solved/removed? channel was probably deleted
RestAction<Message> retrieveAction = textChannel.retrieveMessageById(messageId);
retrieveAction.queue(
message -> {
if(message == null) return; //todo count it as done/solved/removed? message was probably deleted
List<LayoutComponent> components = message.getComponents();
List<LayoutComponent> newComponents = new ArrayList<>();
for (LayoutComponent component : components)
{
component = component.asDisabled();
newComponents.add(component);
}
message.editMessageComponents(newComponents).queue();
databaseManager.untrackExpiredMessage(messageId);
},
(error) -> {
databaseManager.untrackExpiredMessage(messageId);
});
}
}