diff --git a/src/main/java/net/hypr/doki/Config.java b/src/main/java/net/hypr/doki/Config.java index 28bcbf8..506f47b 100644 --- a/src/main/java/net/hypr/doki/Config.java +++ b/src/main/java/net/hypr/doki/Config.java @@ -13,6 +13,7 @@ public class Config { @SuppressWarnings("unused") private String token; @SuppressWarnings("unused") private String prefix; @SuppressWarnings("unused") private DBConfig mariadb; + @SuppressWarnings("unused") private boolean levelling = false; /** * Returns the configuration object for this bot @@ -36,6 +37,7 @@ public class Config { return token; } public String getPrefix() { return prefix; } + public boolean getLevelling() { return levelling; } public DBConfig getDbConfig() { return mariadb; diff --git a/src/main/java/net/hypr/doki/Doki.java b/src/main/java/net/hypr/doki/Doki.java index c2095f5..1beb95a 100644 --- a/src/main/java/net/hypr/doki/Doki.java +++ b/src/main/java/net/hypr/doki/Doki.java @@ -6,6 +6,8 @@ import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDABuilder; import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.requests.GatewayIntent; +import net.hypr.doki.listeners.LevellingListener; +import org.apache.commons.dbcp2.BasicDataSource; import org.slf4j.Logger; import java.io.IOException; @@ -14,6 +16,7 @@ public class Doki { private static final Logger log = Logging.getLogger(); private static JDA jda; private static Config config; + private static final BasicDataSource dataSource = new BasicDataSource(); private Doki(JDA jda, Config config) { Doki.jda = jda; @@ -24,6 +27,7 @@ public class Doki { return jda; } public static String getPrefix() { return config.getPrefix(); } + public static BasicDataSource getDataSource() { return dataSource; } public static void start() throws IOException, InterruptedException { config = Config.readConfig(); @@ -34,6 +38,19 @@ public class Doki { .build() .awaitReady(); + // Connect to the DB + if (config.getLevelling()) { + Config.DBConfig dbConfig = config.getDbConfig(); + dataSource.setDriverClassName("org.mariadb.jdbc.Driver"); + dataSource.setUrl("jdbc:mariadb://" + dbConfig.getHost() + ":" + dbConfig.getPortNumber() + "/" + dbConfig.getDatabase()); + dataSource.setUsername(dbConfig.getUser()); + dataSource.setPassword(dbConfig.getPassword()); + dataSource.setMaxTotal(10); + dataSource.setMaxIdle(5); + dataSource.setMinIdle(2); + dataSource.setInitialSize(10); + } + // Print some information about the bot log.info("Bot connected as {}", jda.getSelfUser().getAsTag()); log.info("The bot is present in the following guilds:"); @@ -51,6 +68,11 @@ public class Doki { CommandsBuilder.newBuilder(437970062922612737L) .textCommandBuilder(textCommandsBuilder -> textCommandsBuilder.addPrefix(getPrefix())) .build(jda, "net.hypr.doki.commands"); //Registering listeners is taken care of by the lib + if (config.getLevelling()) { + jda.addEventListener(new LevellingListener()); + } else { + log.info("Levelling is disabled"); + } } catch (Exception e) { log.error("Failed to start the bot", e); System.exit(-1); diff --git a/src/main/java/net/hypr/doki/commands/levelling/Leaderboard.java b/src/main/java/net/hypr/doki/commands/levelling/Leaderboard.java new file mode 100644 index 0000000..36f17ed --- /dev/null +++ b/src/main/java/net/hypr/doki/commands/levelling/Leaderboard.java @@ -0,0 +1,56 @@ +package net.hypr.doki.commands.levelling; + +import com.freya02.botcommands.api.annotations.CommandMarker; +import com.freya02.botcommands.api.prefixed.CommandEvent; +import com.freya02.botcommands.api.prefixed.TextCommand; +import com.freya02.botcommands.api.prefixed.annotations.Category; +import com.freya02.botcommands.api.prefixed.annotations.Description; +import com.freya02.botcommands.api.prefixed.annotations.JDATextCommand; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.User; +import net.hypr.doki.utils.DBUtils; +import net.hypr.doki.utils.UserRecord; + +import java.awt.*; +import java.sql.SQLException; +import java.util.List; + +@CommandMarker +@Category("Levelling") +@Description("Get the global leaderboard") +public class Leaderboard extends TextCommand { + @JDATextCommand(name = "leaderboard", order = 1, aliases = { "lb" }) + public void execute(CommandEvent event) throws SQLException { + List userRecords = DBUtils.getAllUserRecords(event.getGuild().getIdLong()); + // Sort the leaderboard highest level first + userRecords.sort((o1, o2) -> o2.totalXp - o1.totalXp); + EmbedBuilder whoIsEmbed = buildLeaderboardEmbed(userRecords, event); + event.reply(whoIsEmbed.build()).queue(); + } + + private EmbedBuilder buildLeaderboardEmbed(List userRecords , CommandEvent event) { + StringBuilder leaderboard = new StringBuilder(); + JDA jda = event.getJDA(); + int idx = 1; + for (UserRecord record : userRecords) { + User user = jda.retrieveUserById(record.user_id).complete(); + leaderboard + .append("**") + .append(idx) + .append(".** ") + .append(user.getName()) + .append(" - ") + .append(record.level) + .append(" (") + .append(record.totalXp) + .append(" Total XP)\n"); + idx += 1; + if (idx > 10) break; // Break out after 10 records is reached + } + return new EmbedBuilder() + .setTitle(event.getGuild().getName() + " Leaderboard") + .setDescription(leaderboard) + .setColor(new Color(210,138,39)); + } +} diff --git a/src/main/java/net/hypr/doki/commands/levelling/Rank.java b/src/main/java/net/hypr/doki/commands/levelling/Rank.java new file mode 100644 index 0000000..a9a66be --- /dev/null +++ b/src/main/java/net/hypr/doki/commands/levelling/Rank.java @@ -0,0 +1,46 @@ +package net.hypr.doki.commands.levelling; + +import com.freya02.botcommands.api.annotations.CommandMarker; +import com.freya02.botcommands.api.prefixed.BaseCommandEvent; +import com.freya02.botcommands.api.prefixed.CommandEvent; +import com.freya02.botcommands.api.prefixed.TextCommand; +import com.freya02.botcommands.api.prefixed.annotations.Category; +import com.freya02.botcommands.api.prefixed.annotations.Description; +import com.freya02.botcommands.api.prefixed.annotations.JDATextCommand; +import com.freya02.botcommands.api.prefixed.annotations.TextOption; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Member; +import net.hypr.doki.utils.DBUtils; +import net.hypr.doki.utils.UserRecord; + +import java.sql.SQLException; + +@CommandMarker +@Category("Levelling") +@Description("Get a user's rank") +public class Rank extends TextCommand { + @JDATextCommand(name = "rank", order = 1) + public void execute(BaseCommandEvent event, @TextOption Member member) throws SQLException { + UserRecord userRecord = DBUtils.getUserRecord(member.getIdLong(), event.getGuild().getIdLong()); + EmbedBuilder rankEmbed = buildRankEmbed(member, userRecord); + event.reply(rankEmbed.build()).queue(); + } + + @JDATextCommand(name = "rank", order = 2) + public void execute(CommandEvent event) throws SQLException { + UserRecord userRecord = DBUtils.getUserRecord(event.getMember().getIdLong(), event.getGuild().getIdLong()); + EmbedBuilder whoIsEmbed = buildRankEmbed(event.getMember(), userRecord); + event.reply(whoIsEmbed.build()).queue(); + } + + private EmbedBuilder buildRankEmbed(Member user, UserRecord userRecord) { + int xpToNextLevel = (((userRecord.level + 1) * 5) * 40) - userRecord.xp; + return new EmbedBuilder() + .setAuthor(user.getUser().getName(), null, user.getEffectiveAvatarUrl()) + .setThumbnail(user.getEffectiveAvatarUrl()) + .addField("Level", String.valueOf(userRecord.level), true) + .addField("XP", String.valueOf(userRecord.xp), true) + .addField("XP Until level " + (userRecord.level + 1), String.valueOf(xpToNextLevel), true) + .setColor(user.getColor()); + } +} diff --git a/src/main/java/net/hypr/doki/commands/moderation/Mute.java b/src/main/java/net/hypr/doki/commands/moderation/Mute.java deleted file mode 100644 index 7a84854..0000000 --- a/src/main/java/net/hypr/doki/commands/moderation/Mute.java +++ /dev/null @@ -1,48 +0,0 @@ -package net.hypr.doki.commands.moderation; - -import com.freya02.botcommands.api.annotations.CommandMarker; -import com.freya02.botcommands.api.prefixed.BaseCommandEvent; -import com.freya02.botcommands.api.prefixed.CommandEvent; -import com.freya02.botcommands.api.prefixed.TextCommand; -import com.freya02.botcommands.api.prefixed.annotations.Category; -import com.freya02.botcommands.api.prefixed.annotations.Description; -import com.freya02.botcommands.api.prefixed.annotations.JDATextCommand; -import com.freya02.botcommands.api.prefixed.annotations.TextOption; -import net.dv8tion.jda.api.Permission; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.Message; -import net.dv8tion.jda.api.interactions.commands.Command; -import net.hypr.doki.utils.DurationUtils; - -import java.time.Duration; - -@CommandMarker -@Category("Moderation") -@Description("Mutes a user for a specified amount of time") -public class Mute extends TextCommand { - @JDATextCommand(name = "mute", order = 1) - public void execute(BaseCommandEvent event, @TextOption(example = "<@437970062922612737>") Member member, @TextOption(example = "30m") String duration) { - Member commandExecutor = event.getMember(); - if (!commandExecutor.hasPermission(Permission.KICK_MEMBERS)) { - return; - } - Duration timeoutDuration; - try { - timeoutDuration = DurationUtils.parseDuration(duration); - } catch (IllegalArgumentException ex) { - event.reply("Invalid duration format!").queue(); - return; - } - if (Math.abs(timeoutDuration.toDays()) > 28) { - event.reply("Duration must be less than 28 days!").queue(); - return; - } - member.timeoutFor(timeoutDuration).queue(); - event.reply("Muted " + member.getAsMention() + " (" + member.getEffectiveName() + ") for " + duration).queue(); - } - - @JDATextCommand(name = "mute", order = 2) - public void execute(CommandEvent event) { - event.reply("You must specify a user and duration!").queue(); - } -} diff --git a/src/main/java/net/hypr/doki/listeners/LevellingListener.java b/src/main/java/net/hypr/doki/listeners/LevellingListener.java new file mode 100644 index 0000000..bf8afff --- /dev/null +++ b/src/main/java/net/hypr/doki/listeners/LevellingListener.java @@ -0,0 +1,74 @@ +package net.hypr.doki.listeners; + +import com.freya02.botcommands.api.Logging; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.hypr.doki.Doki; +import net.hypr.doki.utils.DBUtils; +import net.hypr.doki.utils.LevellingUtils; +import net.hypr.doki.utils.UserRecord; +import org.slf4j.Logger; + +import java.sql.SQLException; +import java.time.Duration; +import java.time.LocalDateTime; + +public class LevellingListener extends ListenerAdapter { + @Override + public void onMessageReceived(MessageReceivedEvent event) { + Logger log = Logging.getLogger(); + + /* Ignore the message if one of the following conditions is met: + - Message is from self user + - Message is from a bot + - Message is command (starts with bot prefix) + - Message is a Direct Message to the bot + */ + try { + event.getGuild(); + } catch (IllegalStateException ex) { + log.debug("Ignoring direct message with ID {}", event.getMessageId()); + return; + } + + if ( + Doki.getJDA().getSelfUser().getId().equals(event.getAuthor().getId()) || + event.getAuthor().isBot() || + event.getMessage().getContentStripped().startsWith(Doki.getPrefix()) + ) { + log.debug("Ignoring self/bot message with ID {}", event.getMessageId()); + return; + } + + User user = event.getAuthor(); + Guild guild = event.getGuild(); + boolean userIsInDb = DBUtils.doesUserRecordExist(user.getIdLong(), guild.getIdLong()); + if (userIsInDb) { + try { + UserRecord rec = DBUtils.getUserRecord(user.getIdLong(), guild.getIdLong()); + Duration timeSinceLastMessage = Duration.between( + rec.lastMessage.toLocalDateTime(), + LocalDateTime.now() + ); + if (timeSinceLastMessage.compareTo(Duration.ofHours(1)) > 0) { + // it has been over an hour since the user last sent a message that affected XP + LevellingUtils.incrementXp(log, rec); + } else { + log.debug("Ignoring message ID {} as not enough time has passed", event.getMessageId()); + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + } else { + log.info("No record of user ID {} in server {}, creating blank record", user.getId(), guild.getId()); + try { + UserRecord rec = DBUtils.createUserRecord(user.getIdLong(), guild.getIdLong()); + LevellingUtils.incrementXp(log, rec); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/src/main/java/net/hypr/doki/utils/DBUtils.java b/src/main/java/net/hypr/doki/utils/DBUtils.java new file mode 100644 index 0000000..352ee52 --- /dev/null +++ b/src/main/java/net/hypr/doki/utils/DBUtils.java @@ -0,0 +1,154 @@ +package net.hypr.doki.utils; + +import com.freya02.botcommands.api.Logging; +import net.hypr.doki.Doki; +import org.apache.commons.dbcp2.BasicDataSource; +import org.apache.commons.dbutils.QueryRunner; +import org.apache.commons.dbutils.ResultSetHandler; +import org.slf4j.Logger; + +import java.math.BigInteger; +import java.sql.*; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class DBUtils { + /** + * Checks if the user record exists + * @param user_id User ID to search for + * @param server_id Server ID to search for + * @return Whether the record exists or not + */ + public static boolean doesUserRecordExist(long user_id, long server_id) { + Logger log = Logging.getLogger(); + BasicDataSource dataSource = Doki.getDataSource(); + QueryRunner qr = new QueryRunner(dataSource); + ResultSetHandler> resultSetHandler = rs -> { + Set rows = new HashSet<>(); + while (rs.next()) { + rows.add(BigInteger.valueOf(rs.getLong(1))); + } + return rows; + }; + + // Find out if the user is already in the DB + try { + log.debug("Searching DB for (usr{},srv:{})", user_id, server_id); + Set foundIds = qr.query("SELECT user_id FROM users WHERE user_id = " + user_id + " AND server_id = " + server_id, resultSetHandler); + log.debug("Matching records: {}", foundIds.size()); + return !foundIds.isEmpty(); + } catch (SQLException ignored) { + log.debug("An SQL error occurred"); + return false; + } + } + + /** + * Creates a new user record + * @param user_id The users ID + * @param server_id The server ID + * @throws SQLException A SQL exception + */ + public static UserRecord createUserRecord(long user_id, long server_id) throws SQLException { + Logger log = Logging.getLogger(); + log.info("Creating record (usr:{},srv:{})", user_id, server_id); + BasicDataSource dataSource = Doki.getDataSource(); + Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement( + "INSERT INTO users (user_id, server_id, last_message) VALUES (?, ?, ?)" + ); + stmt.setLong(1, user_id); + stmt.setLong(2, server_id); + stmt.setTimestamp(3, Timestamp.valueOf(LocalDateTime.now())); + stmt.execute(); + log.info("Record (usr:{},srv:{}) created!", user_id, server_id); + return new UserRecord(user_id, server_id); + } + + /** + * Updates a user record + * @param user_id User ID of the record to update + * @param server_id Server ID of the record to update + * @param now The current time + * @param xp The new XP count + * @param totalXp The new total XP count + * @param level The new level + * @throws SQLException A SQL exception + */ + public static void updateUserRecord(long user_id, long server_id, Timestamp now, int xp, int totalXp, int level) throws SQLException { + Logger log = Logging.getLogger(); + log.info("Updating record (usr:{},srv:{})", user_id, server_id); + BasicDataSource dataSource = Doki.getDataSource(); + Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement( + "UPDATE users SET last_message = ?, xp = ?, total_xp = ?, level = ? WHERE user_id = ? AND server_id = ?" + ); + stmt.setTimestamp(1, now); + stmt.setInt(2, xp); + stmt.setInt(3, totalXp); + stmt.setInt(4, level); + stmt.setLong(5, user_id); + stmt.setLong(6, server_id); + stmt.executeQuery(); + log.info("Updated record (usr:{},srv:{})!", user_id, server_id); + } + + /** + * Gets a user record + * @param user_id The User ID of the record to get + * @param server_id The Server ID of the record to get + * @return The found UserRecord + * @throws SQLException A SQL exception + */ + public static UserRecord getUserRecord(long user_id, long server_id) throws SQLException { + BasicDataSource dataSource = Doki.getDataSource(); + Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement( + "SELECT user_id, server_id, last_message, xp, total_xp, level FROM users WHERE user_id = ? AND server_id = ?" + ); + stmt.setLong(1, user_id); + stmt.setLong(2, server_id); + ResultSet rs = stmt.executeQuery(); + rs.next(); + return new UserRecord( + rs.getLong("user_id"), + rs.getLong("server_id"), + rs.getTimestamp("last_message"), + rs.getInt("xp"), + rs.getInt("total_xp"), + rs.getInt("level") + ); + } + + /** + * Get all user records for a server + * @param server_id The Server ID + * @return A List of UserRecords + * @throws SQLException A SQL exception + */ + public static List getAllUserRecords(long server_id) throws SQLException { + BasicDataSource dataSource = Doki.getDataSource(); + Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement( + "SELECT user_id, last_message, xp, total_xp, level FROM users WHERE server_id = ?" + ); + stmt.setLong(1, server_id); + ResultSet rs = stmt.executeQuery(); + List userRecordList = new ArrayList<>(); + while (rs.next()) { + userRecordList.add(new UserRecord( + rs.getLong("user_id"), + server_id, + rs.getTimestamp("last_message"), + rs.getInt("xp"), + rs.getInt("total_xp"), + rs.getInt("level") + )); + } + return userRecordList; + } +} + diff --git a/src/main/java/net/hypr/doki/utils/DurationUtils.java b/src/main/java/net/hypr/doki/utils/DurationUtils.java deleted file mode 100644 index 8fef81c..0000000 --- a/src/main/java/net/hypr/doki/utils/DurationUtils.java +++ /dev/null @@ -1,27 +0,0 @@ -package net.hypr.doki.utils; - -import java.time.Duration; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public class DurationUtils { - private static final Pattern timePattern = Pattern.compile("(\\d+)(?:([dhms]))?"); - public static Duration parseDuration(String input) throws IllegalArgumentException { - Matcher matcher = timePattern.matcher(input); - - if (!matcher.matches()) { - throw new IllegalArgumentException("Invalid duration format"); - } - - int value = Integer.parseInt(matcher.group(1)); - String unit = matcher.group(2); - - return switch (unit == null || unit.isEmpty() ? "m" : unit.toLowerCase()) { - case "d" -> Duration.ofDays(value); - case "h" -> Duration.ofHours(value); - case "m" -> Duration.ofMinutes(value); - case "s" -> Duration.ofSeconds(value); - default -> throw new IllegalArgumentException("Invalid unit"); - }; - } -} diff --git a/src/main/java/net/hypr/doki/utils/LevellingUtils.java b/src/main/java/net/hypr/doki/utils/LevellingUtils.java new file mode 100644 index 0000000..0173367 --- /dev/null +++ b/src/main/java/net/hypr/doki/utils/LevellingUtils.java @@ -0,0 +1,35 @@ +package net.hypr.doki.utils; + +import org.slf4j.Logger; + +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.Random; + +public class LevellingUtils { + public static void incrementXp(Logger logger, UserRecord userRecord) throws SQLException { + Random random = new Random(); + // Random increment between 15 and 25 + int xpInc = (random.nextInt(10) + 16) * 3; + int xpNew = userRecord.xp + xpInc; + int totalXp = userRecord.xp + xpInc; + int levelNew = userRecord.level; + if (xpNew >= ((userRecord.level + 1) * 5) * 40) { + levelNew ++; + xpNew = 0; + } + DBUtils.updateUserRecord( + userRecord.user_id, + userRecord.server_id, + Timestamp.valueOf(LocalDateTime.now()), + xpNew, + totalXp, + levelNew + ); + logger.info("Incremented {}'s XP in {} ({} -> {})", userRecord.user_id, userRecord.server_id, userRecord.xp, xpNew); + if (levelNew > userRecord.level) { + logger.info("Incremented {}'s level in {} ({} -> {})", userRecord.user_id, userRecord.server_id, userRecord.level, levelNew); + } + } +} diff --git a/src/main/java/net/hypr/doki/utils/UserRecord.java b/src/main/java/net/hypr/doki/utils/UserRecord.java new file mode 100644 index 0000000..281c5a0 --- /dev/null +++ b/src/main/java/net/hypr/doki/utils/UserRecord.java @@ -0,0 +1,44 @@ +package net.hypr.doki.utils; + + +import java.sql.Timestamp; + +public class UserRecord { + public final long user_id; + public final long server_id; + public final Timestamp lastMessage; + public final int xp; + public int totalXp; + public final int level; + + /** + * Instantiates a new UserRecord + * @param user_id The User ID + * @param server_id The Server/Guild ID + */ + public UserRecord (long user_id, long server_id) { + this.user_id = user_id; + this.server_id = server_id; + this.lastMessage = new Timestamp(0); + this.xp = 0; + this.level = 1; + } + + /** + * Instantiates a new UserRecord + * @param user_id The User ID + * @param server_id The Server/Guild ID + * @param lastMessage The timestamp of the user's last message + * @param xp The user's XP + * @param totalXp The user's total XP + * @param level The user's level + */ + public UserRecord (long user_id, long server_id, Timestamp lastMessage, int xp, int totalXp, int level) { + this.user_id = user_id; + this.server_id = server_id; + this.lastMessage = lastMessage; + this.xp = xp; + this.totalXp = totalXp; + this.level = level; + } +}