diff --git a/app/src/main/java/bot/App.java b/app/src/main/java/bot/App.java index d597cea..fbc8df4 100644 --- a/app/src/main/java/bot/App.java +++ b/app/src/main/java/bot/App.java @@ -2,6 +2,8 @@ import bot.cmd.*; import bot.listener.ReadyListener; +import bot.service.UserService; +import bot.task.StreakResetTask; import com.deepl.api.Translator; import com.mongodb.BasicDBObject; import io.github.cdimascio.dotenv.Dotenv; @@ -12,6 +14,7 @@ import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; +import lombok.Getter; import lombok.extern.log4j.Log4j2; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDABuilder; @@ -43,6 +46,8 @@ public class App implements ApplicationRunner { public static List listeners = new ArrayList<>(); public static List commands; + @Getter private static JDA jda; + @Autowired private MongoTemplate mongoTemplate; @Autowired private WOTDHandler wotdHandler; @@ -92,7 +97,7 @@ public void launch() { listeners.forEach(jdaBuilder::addEventListeners); commands.forEach(jdaBuilder::addEventListeners); - JDA jda = jdaBuilder.build(); + jda = jdaBuilder.build(); jda.awaitReady(); Optional guildOptional = jda.getGuilds().stream().findFirst(); @@ -146,6 +151,11 @@ public void launch() { wotdHandler.executeCron(guild); }); + // Streak reset handling + scheduler.schedule( + Constants.CRON_HOURLY, + new StreakResetTask(SpringContext.getBean(UserService.class))); + } catch (Exception e) { e.printStackTrace(); } diff --git a/app/src/main/java/bot/Constants.java b/app/src/main/java/bot/Constants.java index 8240584..f29520f 100644 --- a/app/src/main/java/bot/Constants.java +++ b/app/src/main/java/bot/Constants.java @@ -7,6 +7,7 @@ public class Constants { public static final String CRON_DAILY_MORNING = "0 7 * * *"; public static final String CRON_DAILY_MIDDLE = "0 15 * * *"; + public static final String CRON_HOURLY = "0 * * * *"; public static final String CRON_TEST = "* * * * *"; public static final String TOTD_API_URL = "https://conversation-starter1.p.rapidapi.com/"; @@ -18,5 +19,7 @@ public class Constants { public static final int MAX_JOURNAL_WORD_QUALITY = 4; + public static final int MIN_POINTS_FOR_STREAK = 20; + public static final String DATABASE_NAME = "learn_english"; } diff --git a/app/src/main/java/bot/cmd/JournalCommand.java b/app/src/main/java/bot/cmd/JournalCommand.java index 76bab6d..0e75b65 100644 --- a/app/src/main/java/bot/cmd/JournalCommand.java +++ b/app/src/main/java/bot/cmd/JournalCommand.java @@ -131,7 +131,7 @@ public void onButtonInteraction(ButtonInteractionEvent event) { } if (id.contains("flashcard-quit")) { - FlashcardQuiz.getInstance(user.getId()).ifPresent(quiz -> quiz.finish(true)); + FlashcardQuiz.getInstance(user.getId()).ifPresent(quiz -> quiz.finish(true, false)); return; } diff --git a/app/src/main/java/bot/cmd/StreakCommand.java b/app/src/main/java/bot/cmd/StreakCommand.java new file mode 100644 index 0000000..1c3b769 --- /dev/null +++ b/app/src/main/java/bot/cmd/StreakCommand.java @@ -0,0 +1,28 @@ +package bot.cmd; + +import bot.service.UserService; +import bot.view.StreakView; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import org.springframework.stereotype.Component; + +@Component +public class StreakCommand extends BotCommand { + + private final UserService userService; + + public StreakCommand(UserService userService) { + super("streak", "Check out your streak", true); + this.userService = userService; + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + String userId = event.getUser().getId(); + StreakView view = new StreakView(); + + event.reply("") + .setEphemeral(true) + .setEmbeds(view.getStreak(userService.getUser(userId))) + .queue(); + } +} diff --git a/app/src/main/java/bot/entity/User.java b/app/src/main/java/bot/entity/User.java index ab546d2..2ffd5e9 100644 --- a/app/src/main/java/bot/entity/User.java +++ b/app/src/main/java/bot/entity/User.java @@ -1,8 +1,12 @@ package bot.entity; +import bot.App; +import bot.entity.session.Session; import bot.entity.word.JournalWord; -import java.util.List; +import java.util.*; +import java.util.concurrent.TimeUnit; import lombok.*; +import net.dv8tion.jda.api.entities.Message; import org.bson.types.ObjectId; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; @@ -19,4 +23,33 @@ public class User { /** A list of all the words the user has saved. */ private List words; + + /** Holds an object with points accumulated for each day of the week. */ + @Builder.Default private List weeklyPoints = generateWeeklyPoints(); + + private List sessions; + + private Map lastActivity; + + /** Holds information about the user's streaks. */ + private int currentStreak; + + private int maximumStreak; + + private static List generateWeeklyPoints() { + return new ArrayList<>(Collections.nCopies(7, 0)); + } + + public void sendPrivateTemporaryMessage(String content) { + App.getJda() + .retrieveUserById(discordId) + .queue( + user -> { + user.openPrivateChannel() + .flatMap(channel -> channel.sendMessage(content)) + .delay(30, TimeUnit.SECONDS) + .flatMap(Message::delete) + .queue(); + }); + } } diff --git a/app/src/main/java/bot/entity/session/Session.java b/app/src/main/java/bot/entity/session/Session.java new file mode 100644 index 0000000..3792e37 --- /dev/null +++ b/app/src/main/java/bot/entity/session/Session.java @@ -0,0 +1,29 @@ +package bot.entity.session; + +import lombok.Builder; +import lombok.Getter; + +/** + * Represents a session. + * + *

This is mostly used to record past sessions, primarily to keep track of user streaks and for + * future metric aggregation. + */ +@Getter +public class Session { + private int index; + + private final Type type; + + private final long timestamp; + + @Builder + public Session(Type type, long timestamp) { + this.type = type; + this.timestamp = timestamp; + } + + public enum Type { + JOURNAL_QUIZ + } +} diff --git a/app/src/main/java/bot/quiz/FlashcardQuiz.java b/app/src/main/java/bot/quiz/FlashcardQuiz.java index 208ab0e..ad68399 100644 --- a/app/src/main/java/bot/quiz/FlashcardQuiz.java +++ b/app/src/main/java/bot/quiz/FlashcardQuiz.java @@ -1,9 +1,11 @@ package bot.quiz; import bot.Constants; +import bot.entity.session.Session; import bot.quiz.question.FlashcardQuestion; import bot.quiz.question.Question; import bot.service.UserService; +import bot.view.StreakView; import java.awt.*; import java.util.*; import java.util.List; @@ -80,8 +82,16 @@ public void showQuestion() { channel.deleteMessageById(success.getId()) .queueAfter(10L, TimeUnit.SECONDS); }); - finish(false); - } else finish(true); + finish(false, false); + } else { + Session session = + Session.builder() + .timestamp(System.currentTimeMillis()) + .type(Session.Type.JOURNAL_QUIZ) + .build(); + userService.saveSession(getUser().getId(), session); + finish(true, true); + } return; } @@ -114,20 +124,25 @@ public void start() { showQuestion(); } - public void finish(boolean announce) { + public void finish(boolean announce, boolean complete) { if (announce) { EmbedBuilder embed = new EmbedBuilder(); + StreakView streakView = new StreakView(); embed.setTitle("End of exercise"); embed.setDescription("You reached the end of your exercise! 💪"); embed.setColor(Constants.EMBED_COLOR); embed.setImage("https://media.tenor.com/MDTYbqilAxgAAAAC/ogvhs-high-five.gif"); - channel.sendMessageEmbeds(embed.build()) + if (complete) userService.addDayPoints(user.getId(), 20); + + bot.entity.User savedUser = userService.getUser(user.getId()); + + channel.sendMessageEmbeds(List.of(embed.build(), streakView.getStreak(savedUser))) .queue( success -> { channel.deleteMessageById(success.getId()) - .queueAfter(10L, TimeUnit.SECONDS); + .queueAfter(30L, TimeUnit.SECONDS); }); } diff --git a/app/src/main/java/bot/service/UserService.java b/app/src/main/java/bot/service/UserService.java index c90da57..5955799 100644 --- a/app/src/main/java/bot/service/UserService.java +++ b/app/src/main/java/bot/service/UserService.java @@ -1,9 +1,13 @@ package bot.service; +import bot.Constants; import bot.entity.User; +import bot.entity.session.Session; import bot.entity.word.JournalWord; import bot.entity.word.Word; import bot.repository.UserRepository; +import java.time.DayOfWeek; +import java.time.LocalDate; import java.util.*; import java.util.stream.Collectors; import lombok.NonNull; @@ -34,6 +38,10 @@ public User getUser(@NonNull String discordId) { return userRepository.findUserByDiscordId(discordId); } + public List getAllUsers() { + return userRepository.findAll(); + } + /** * @param discordId The Discord ID of the user. * @return True if the user exists, false if not @@ -55,6 +63,173 @@ public List getJournalWords(@NonNull String discordId) { return Collections.emptyList(); } + /** + * @param discordId The Discord ID of the user. + * @return A list of all the user's sessions + */ + public List getSessions(@NonNull String discordId) { + User user = userRepository.findUserByDiscordId(discordId); + + if (user != null) { + return user.getSessions(); + } + return Collections.emptyList(); + } + + /** + * @param discordId The Discord ID of the user. + * @return A list of all the user's sessions by index + */ + public Optional getSessionByIndex(@NonNull String discordId, int index) { + User user = userRepository.findUserByDiscordId(discordId); + + if (user != null) { + return user.getSessions().stream().filter(s -> s.getIndex() == index).findFirst(); + } + return Optional.empty(); + } + + /** + * @param discordId The Discord ID of the user. + * @return A list of the user's journal words by type + */ + public List getSessionsByType(@NonNull String discordId, Session.Type type) { + User user = userRepository.findUserByDiscordId(discordId); + + if (user != null) { + return user.getSessions().stream() + .filter(s -> s.getType().equals(type)) + .collect(Collectors.toList()); + } + return Collections.emptyList(); + } + + /** + * Saves a session to a user. + * + *

This also updates the corresponding last activity of the user. + * + * @param discordId The Discord ID of the user. + * @param session The session to save. + */ + public void saveSession(@NonNull String discordId, Session session) { + User user = userRepository.findUserByDiscordId(discordId); + long now = System.currentTimeMillis(); + + if (user != null && session != null) { + if (user.getSessions() == null) user.setSessions(new ArrayList<>()); + + if (user.getLastActivity() == null) user.setLastActivity(new HashMap<>()); + + user.getSessions().add(session); + user.getLastActivity().put(session.getType().name(), now); + userRepository.save(user); + } + } + + /** + * Returns the last activity timestamp by type. + * + * @param discordId The Discord ID of the user. + * @param type The type of the session to look for. + * @return The timestamp that this activity was last done. + */ + public long getLastActivityByType(@NonNull String discordId, Session.Type type) { + User user = userRepository.findUserByDiscordId(discordId); + + if (user != null) { + return user.getLastActivity().get(type.name()); + } + return 0L; + } + + /** + * Gets the day points in a specified day. + * + * @param discordId The Discord ID of the user. + * @param day The day to get + * @return The amount of points accumulated in the day + */ + public int getDayPoints(@NonNull String discordId, DayOfWeek day) { + User user = userRepository.findUserByDiscordId(discordId); + + if (user != null) { + return user.getWeeklyPoints().get(day.getValue() - 1); + } + return 0; + } + + /** + * Gets the day points in a specified day. + * + * @param discordId The Discord ID of the user. + * @param points The amount of points to add. + */ + public void addDayPoints(@NonNull String discordId, int points) { + User user = userRepository.findUserByDiscordId(discordId); + int today = LocalDate.now().getDayOfWeek().getValue() - 1; + + if (user != null) { + int initialPoints = user.getWeeklyPoints().get(today); + user.getWeeklyPoints().set(today, user.getWeeklyPoints().get(today) + points); + + boolean surpassedPts = + user.getWeeklyPoints().get(today) >= Constants.MIN_POINTS_FOR_STREAK; + boolean justAchievedStreak = initialPoints >= Constants.MIN_POINTS_FOR_STREAK; + + if (surpassedPts && !justAchievedStreak) countStreak(user); + + userRepository.save(user); + } + } + + /** + * Counts a user's streak. Handles the maximumStreak as well. + * + * @param user An entity instance of the user + */ + public void countStreak(@NonNull User user) { + int newStreak = user.getCurrentStreak() + 1; + + user.setCurrentStreak(newStreak); + user.setMaximumStreak(Math.max(newStreak, user.getMaximumStreak())); + userRepository.save(user); + } + + /** + * Counts a user's streak. Handles the maximumStreak as well. + * + * @param discordId The Discord ID of the user. + */ + public void countStreak(@NonNull String discordId) { + User user = userRepository.findUserByDiscordId(discordId); + + if (user != null) countStreak(user); + } + + /** + * Resets a user's streak. + * + * @param discordId the Discord ID of the user. + */ + public void resetStreak(@NonNull String discordId) { + User user = userRepository.findUserByDiscordId(discordId); + + if (user != null) { + resetStreak(user); + } + } + + /** + * Resets a user's streak. + * + * @param user The entity instance of the user. + */ + public void resetStreak(@NonNull User user) { + user.setCurrentStreak(0); + userRepository.save(user); + } + /** * Gets the most recent journal words of a user. * diff --git a/app/src/main/java/bot/task/StreakResetTask.java b/app/src/main/java/bot/task/StreakResetTask.java new file mode 100644 index 0000000..572d685 --- /dev/null +++ b/app/src/main/java/bot/task/StreakResetTask.java @@ -0,0 +1,45 @@ +package bot.task; + +import bot.entity.User; +import bot.entity.session.Session; +import bot.service.UserService; +import java.util.List; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +public class StreakResetTask implements Runnable { + + private final UserService userService; + + private final long DAY_AMOUNT = 1000 * 60 * 60 * 24; + private final long HOUR_AMOUNT = 1000 * 60 * 60; + private final String STREAK_RESET_MSG = + "Your streak is going to be reset! " + "Collect a few points to maintain it! 🔥"; + + public StreakResetTask(UserService userService) { + this.userService = userService; + } + + @Override + public void run() { + List users = userService.getAllUsers(); + long now = System.currentTimeMillis(); + + log.info("Executing task"); + + users.forEach( + user -> { + long lastActivity = + user.getLastActivity().get(Session.Type.JOURNAL_QUIZ.name()); + long lastRelative = now - lastActivity; + boolean hasStreak = user.getCurrentStreak() != 0; + + if (lastRelative >= DAY_AMOUNT) { + userService.resetStreak(user); + } else if (lastRelative >= DAY_AMOUNT - HOUR_AMOUNT && hasStreak) { + // Notify user an hour before the streak is about to get reset + user.sendPrivateTemporaryMessage(STREAK_RESET_MSG); + } + }); + } +} diff --git a/app/src/main/java/bot/view/StreakView.java b/app/src/main/java/bot/view/StreakView.java new file mode 100644 index 0000000..e0930a5 --- /dev/null +++ b/app/src/main/java/bot/view/StreakView.java @@ -0,0 +1,45 @@ +package bot.view; + +import bot.Constants; +import bot.entity.User; +import java.awt.*; +import java.time.DayOfWeek; +import java.util.concurrent.atomic.AtomicInteger; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.emoji.Emoji; +import org.apache.commons.lang3.StringUtils; + +public class StreakView { + + private static final String emojiYes = "<:streakyes:1128942923631824906>"; + private static final String emojiNo = "<:streakno:1128943616908337182>"; + + public MessageEmbed getStreak(User user) { + EmbedBuilder embedBuilder = new EmbedBuilder(); + embedBuilder.setTitle("Your streak status: " + user.getCurrentStreak() + " 🔥"); + embedBuilder.setColor(Color.ORANGE); + embedBuilder.setDescription("Maximum streak: " + user.getMaximumStreak()); + AtomicInteger counter = new AtomicInteger(1); + + user.getWeeklyPoints() + .forEach( + (points -> { + Emoji emoji = + Emoji.fromFormatted( + (points >= Constants.MIN_POINTS_FOR_STREAK) + ? emojiYes + : emojiNo); + String value = emoji.getFormatted() + " (" + points + " pts)"; + String day = + StringUtils.capitalize( + DayOfWeek.of(counter.getAndIncrement()) + .name() + .toLowerCase()); + + embedBuilder.addField(day, value, true); + })); + + return embedBuilder.build(); + } +}