diff --git a/src/main/java/net/javadiscord/javabot/systems/help/commands/HelpAccountSubcommand.java b/src/main/java/net/javadiscord/javabot/systems/help/commands/HelpAccountSubcommand.java index 92114955c..5042ad1a7 100644 --- a/src/main/java/net/javadiscord/javabot/systems/help/commands/HelpAccountSubcommand.java +++ b/src/main/java/net/javadiscord/javabot/systems/help/commands/HelpAccountSubcommand.java @@ -2,6 +2,7 @@ import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.User; @@ -9,13 +10,29 @@ import net.dv8tion.jda.api.interactions.commands.OptionMapping; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.requests.restaction.WebhookMessageCreateAction; +import net.dv8tion.jda.api.utils.FileUpload; +import net.javadiscord.javabot.data.config.BotConfig; import net.javadiscord.javabot.data.h2db.DbActions; import net.javadiscord.javabot.systems.help.HelpExperienceService; +import net.javadiscord.javabot.systems.help.dao.HelpTransactionRepository; import net.javadiscord.javabot.systems.help.model.HelpAccount; +import net.javadiscord.javabot.util.Checks; import net.javadiscord.javabot.util.ExceptionLogger; import net.javadiscord.javabot.util.Pair; +import net.javadiscord.javabot.util.Plotter; import net.javadiscord.javabot.util.Responses; import net.javadiscord.javabot.util.StringUtils; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import javax.imageio.ImageIO; + import org.jetbrains.annotations.NotNull; import org.springframework.dao.DataAccessException; import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; @@ -27,26 +44,40 @@ */ public class HelpAccountSubcommand extends SlashCommand.Subcommand { + private final BotConfig botConfig; private final DbActions dbActions; private final HelpExperienceService helpExperienceService; + private final HelpTransactionRepository transactionRepository; /** * The constructor of this class, which sets the corresponding {@link SubcommandData}. * + * @param botConfig The bot configuration * @param dbActions An object responsible for various database actions * @param helpExperienceService Service object that handles Help Experience Transactions. + * @param transactionRepository DAO for help XP transactions */ - public HelpAccountSubcommand(DbActions dbActions, HelpExperienceService helpExperienceService) { + public HelpAccountSubcommand(BotConfig botConfig, DbActions dbActions, HelpExperienceService helpExperienceService, HelpTransactionRepository transactionRepository) { this.dbActions = dbActions; this.helpExperienceService = helpExperienceService; + this.botConfig = botConfig; + this.transactionRepository = transactionRepository; setCommandData(new SubcommandData("account", "Shows an overview of your Help Account.") .addOption(OptionType.USER, "user", "If set, show the Help Account of the specified user instead.", false) + .addOption(OptionType.BOOLEAN, "plot", "generate a plot of help XP history", false) ); } @Override public void execute(@NotNull SlashCommandInteractionEvent event) { User user = event.getOption("user", event::getUser, OptionMapping::getAsUser); + boolean plot = event.getOption("plot", false, OptionMapping::getAsBoolean); + + if (plot && user.getIdLong()!=event.getUser().getIdLong() && !Checks.hasStaffRole(botConfig, event.getMember())) { + Responses.error(event, "You can only plot your own help XP history.").queue(); + return; + } + long totalThanks = dbActions.count( "SELECT COUNT(id) FROM help_channel_thanks WHERE helper_id = ?", s -> s.setLong(1, user.getIdLong()) @@ -55,15 +86,59 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { "SELECT COUNT(id) FROM help_channel_thanks WHERE helper_id = ? AND thanked_at > DATEADD('week', -1, CURRENT_TIMESTAMP(0))", s -> s.setLong(1, user.getIdLong()) ); + + event.deferReply().queue(); + + FileUpload upload = null; + if (plot) { + upload = generatePlot(user); + } + try { HelpAccount account = helpExperienceService.getOrCreateAccount(user.getIdLong()); - event.replyEmbeds(buildHelpAccountEmbed(account, user, event.getGuild(), totalThanks, weekThanks)).queue(); + WebhookMessageCreateAction reply = event.getHook().sendMessageEmbeds(buildHelpAccountEmbed(account, user, event.getGuild(), totalThanks, weekThanks)); + if (upload!=null) { + reply.addFiles(upload); + } + reply.queue(); } catch (DataAccessException e) { ExceptionLogger.capture(e, getClass().getSimpleName()); Responses.error(event, e.getMessage()).queue(); } } + private FileUpload generatePlot(User user) { + List,Double>> xpData = transactionRepository.getTotalTransactionWeightByMonth(user.getIdLong(), LocalDate.now().withDayOfMonth(1).minusYears(1).atStartOfDay()); + + if (xpData.isEmpty()) { + return null; + } + + List> plotData = new ArrayList<>(); + + int i = 0; + for(LocalDate position = LocalDate.now().minusYears(1); position.isBefore(LocalDate.now().plusDays(1)); position=position.plusMonths(1)) { + double value = 0.0; + if(i, Double> entry = xpData.get(i); + if(entry.first().first() == position.getMonthValue() && entry.first().second() == position.getYear()) { + value = Math.round(entry.second()*100)/100.0; + i++; + } + } + plotData.add(new Pair<>(position.getMonth() + " " + position.getYear(), value)); + } + + BufferedImage plt = new Plotter(plotData).plot(); + try(ByteArrayOutputStream os = new ByteArrayOutputStream()){ + ImageIO.write(plt, "png", os); + return FileUpload.fromData(os.toByteArray(), "image.png"); + } catch (IOException e) { + ExceptionLogger.capture(e, "Cannot create XP plot"); + } + return null; + } + private @NotNull MessageEmbed buildHelpAccountEmbed(HelpAccount account, @NotNull User user, Guild guild, long totalThanks, long weekThanks) { return new EmbedBuilder() .setAuthor(user.getAsTag(), null, user.getEffectiveAvatarUrl()) diff --git a/src/main/java/net/javadiscord/javabot/systems/help/dao/HelpTransactionRepository.java b/src/main/java/net/javadiscord/javabot/systems/help/dao/HelpTransactionRepository.java index 429d0e515..edeb1bd6a 100644 --- a/src/main/java/net/javadiscord/javabot/systems/help/dao/HelpTransactionRepository.java +++ b/src/main/java/net/javadiscord/javabot/systems/help/dao/HelpTransactionRepository.java @@ -3,6 +3,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.javadiscord.javabot.systems.help.model.HelpTransaction; +import net.javadiscord.javabot.util.Pair; + import org.springframework.dao.DataAccessException; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; @@ -11,6 +13,7 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Optional; @@ -85,6 +88,18 @@ private HelpTransaction read(ResultSet rs) throws SQLException { return transaction; } + /** + * Gets the total earned XP of a user since a specific timestamp grouped by months. + * @param userId the user to get XP from + * @param start the start timestamp + * @return a list consisting of month, year and the total XP earned that month + */ + public List, Double>> getTotalTransactionWeightByMonth(long userId, LocalDateTime start) { + return jdbcTemplate.query("SELECT SUM(weight) AS total, EXTRACT(MONTH FROM created_at) AS m, EXTRACT(YEAR FROM created_at) AS y FROM help_transaction WHERE recipient = ? AND created_at >= ? GROUP BY m, y ORDER BY y ASC, m ASC", + (rs, row)-> new Pair<>(new Pair<>(rs.getInt("m"), rs.getInt("y")), rs.getDouble("total")), + userId, start); + } + /** * Checks whether a transaction with a specific recipient exists in a specific channel. * @param recipient The ID of the recipient diff --git a/src/main/java/net/javadiscord/javabot/util/Plotter.java b/src/main/java/net/javadiscord/javabot/util/Plotter.java new file mode 100644 index 000000000..84295bd00 --- /dev/null +++ b/src/main/java/net/javadiscord/javabot/util/Plotter.java @@ -0,0 +1,83 @@ +package net.javadiscord.javabot.util; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.util.List; + +/** + * Creates diagrams. + */ +public class Plotter { + private final List> entries; + private int width=3000; + private int height=1500; + + /** + * Creates the plotter. + * @param entries a list of all data points to plot, each represented as a {@link Pair} consisting of the name and value of the data point + */ + public Plotter(List> entries) { + this.entries=entries; + } + + /** + * Create a diagram from the data supplied to the constructor. + * @return the diagram as a {@link BufferedImage} + */ + public BufferedImage plot() { + BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = img.createGraphics(); + + g2d.setFont(ImageGenerationUtils.getResourceFont("assets/fonts/Uni-Sans-Heavy.ttf", 30).orElseThrow()); + + g2d.setBackground(Color.WHITE); + g2d.fillRect(0, 0, width, height); + g2d.setColor(Color.BLACK); + + centeredText(g2d, "gained help XP per month", width/2, 50); + + plotEntries(g2d, 100, 100, width-200, height-200); + + return img; + } + + private void plotEntries(Graphics2D g2d, int x, int y, int width, int height) { + double maxValue = entries.stream().mapToDouble(Pair::second).sum(); + int stepSize = 2*(int)Math.pow(10,(int)Math.log10(maxValue)-1); + if (stepSize==0) { + stepSize=1; + } + maxValue += stepSize; + + int numEntries = entries.size(); + + int currentX = x; + + g2d.drawLine(x, y, x, y+height); + + if(maxValue>0) { + for (int current = 0; current < maxValue; current += stepSize) { + g2d.drawString(String.valueOf(current), 95-g2d.getFontMetrics().stringWidth(String.valueOf(current)), this.height-(y+(height*current)/(int)maxValue)+g2d.getFontMetrics().getHeight()/3); + } + } + + boolean shift=false; + for (Pair entry : entries) { + int shiftNum = shift ? g2d.getFontMetrics().getHeight() : 0; + centeredText(g2d, entry.first(), currentX+(width/(2*numEntries)), this.height-y/2+shiftNum); + int entryHeight = (int)(height*entry.second()/maxValue); + g2d.setColor(Color.GRAY); + g2d.fillRect(currentX, this.height-y-entryHeight, width/numEntries, entryHeight); + g2d.setColor(Color.BLACK); + g2d.drawRect(currentX, this.height-y-entryHeight, width/numEntries, entryHeight); + centeredText(g2d, String.valueOf(entry.second()), currentX+(width/(2*numEntries)), this.height-y-entryHeight-10); + shift=!shift; + currentX += width/numEntries; + } + } + + private void centeredText(Graphics2D g2d, String text, int x, int y) { + g2d.drawString(text, x-g2d.getFontMetrics().stringWidth(text)/2, y); + } +}