diff --git a/application/config.json.template b/application/config.json.template index a1aec8f470..b5954ddd1a 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -115,5 +115,8 @@ "fallbackChannelPattern": "java-news-and-changes", "pollIntervalInMinutes": 10 }, - "memberCountCategoryPattern": "Info" + "memberCountCategoryPattern": "Info", + "cakeDayConfig": { + "rolePattern": "Cake Day" + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/CakeDayConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/CakeDayConfig.java new file mode 100644 index 0000000000..859578c921 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/CakeDayConfig.java @@ -0,0 +1,19 @@ +package org.togetherjava.tjbot.config; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Objects; + +/** + * Configuration record for the Cake Day feature. + */ +public record CakeDayConfig( + @JsonProperty(value = "rolePattern", required = true) String rolePattern) { + + /** + * Configuration constructor for the Cake Day feature. + */ + public CakeDayConfig { + Objects.requireNonNull(rolePattern); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/config/Config.java b/application/src/main/java/org/togetherjava/tjbot/config/Config.java index e819f8e7d1..7cb61475ed 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -46,6 +46,7 @@ public final class Config { private final RSSFeedsConfig rssFeedsConfig; private final String selectRolesChannelPattern; private final String memberCountCategoryPattern; + private final CakeDayConfig cakeDayConfig; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -94,7 +95,8 @@ private Config(@JsonProperty(value = "token", required = true) String token, required = true) FeatureBlacklistConfig featureBlacklistConfig, @JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig, @JsonProperty(value = "selectRolesChannelPattern", - required = true) String selectRolesChannelPattern) { + required = true) String selectRolesChannelPattern, + @JsonProperty(value = "cakeDayConfig", required = true) CakeDayConfig cakeDayConfig) { this.token = Objects.requireNonNull(token); this.githubApiKey = Objects.requireNonNull(githubApiKey); this.databasePath = Objects.requireNonNull(databasePath); @@ -127,6 +129,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, this.featureBlacklistConfig = Objects.requireNonNull(featureBlacklistConfig); this.rssFeedsConfig = Objects.requireNonNull(rssFeedsConfig); this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern); + this.cakeDayConfig = Objects.requireNonNull(cakeDayConfig); } /** @@ -401,6 +404,15 @@ public String getSelectRolesChannelPattern() { return selectRolesChannelPattern; } + /** + * Retrieves the Cake Day configuration. + * + * @return the cake-day feature configuration + */ + public CakeDayConfig getCakeDayConfig() { + return cakeDayConfig; + } + /** * Gets the pattern matching the category that is used to display the total member count. * diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index 893adbc00f..7a4f5b1691 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -15,6 +15,9 @@ import org.togetherjava.tjbot.features.bookmarks.BookmarksSystem; import org.togetherjava.tjbot.features.bookmarks.LeftoverBookmarksCleanupRoutine; import org.togetherjava.tjbot.features.bookmarks.LeftoverBookmarksListener; +import org.togetherjava.tjbot.features.cakeday.CakeDayListener; +import org.togetherjava.tjbot.features.cakeday.CakeDayRoutine; +import org.togetherjava.tjbot.features.cakeday.CakeDayService; import org.togetherjava.tjbot.features.chatgpt.ChatGptCommand; import org.togetherjava.tjbot.features.chatgpt.ChatGptService; import org.togetherjava.tjbot.features.code.CodeMessageAutoDetection; @@ -115,6 +118,7 @@ public static Collection createFeatures(JDA jda, Database database, Con new CodeMessageHandler(blacklistConfig.special(), jshellEval); ChatGptService chatGptService = new ChatGptService(config); HelpSystemHelper helpSystemHelper = new HelpSystemHelper(config, database, chatGptService); + CakeDayService cakeDayService = new CakeDayService(config, database); HelpThreadLifecycleListener helpThreadLifecycleListener = new HelpThreadLifecycleListener(helpSystemHelper, database); @@ -135,6 +139,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new LeftoverBookmarksCleanupRoutine(bookmarksSystem)); features.add(new MarkHelpThreadCloseInDBRoutine(database, helpThreadLifecycleListener)); features.add(new MemberCountDisplayRoutine(config)); + features.add(new CakeDayRoutine(cakeDayService)); features.add(new RSSHandlerRoutine(config, database)); // Message receivers @@ -156,6 +161,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new GuildLeaveCloseThreadListener(config)); features.add(new LeftoverBookmarksListener(bookmarksSystem)); features.add(new HelpThreadCreatedListener(helpSystemHelper)); + features.add(new CakeDayListener(cakeDayService)); features.add(new HelpThreadLifecycleListener(helpSystemHelper, database)); // Message context commands diff --git a/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayListener.java b/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayListener.java new file mode 100644 index 0000000000..3dd0d5d43a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayListener.java @@ -0,0 +1,84 @@ +package org.togetherjava.tjbot.features.cakeday; + +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.events.guild.member.GuildMemberRemoveEvent; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.jetbrains.annotations.NotNull; + +import org.togetherjava.tjbot.db.generated.tables.records.CakeDaysRecord; +import org.togetherjava.tjbot.features.EventReceiver; + +import java.util.Optional; + +/** + * A listener class responsible for handling cake day related events. + */ +public class CakeDayListener extends ListenerAdapter implements EventReceiver { + + private final CakeDayService cakeDayService; + + /** + * Constructs a new CakeDayListener with the given {@link CakeDayService}. + * + * @param cakeDayService the {@link CakeDayService} to be used by this listener + */ + public CakeDayListener(CakeDayService cakeDayService) { + this.cakeDayService = cakeDayService; + } + + /** + * Handles the event of a message being received in a guild. + *

+ * It caches the user's cake day and inserts the member's cake day into the database if not + * already present. + * + * @param event the {@link MessageReceivedEvent} representing the message received + */ + @Override + public void onMessageReceived(@NotNull MessageReceivedEvent event) { + User author = event.getAuthor(); + Member member = event.getMember(); + long authorId = author.getIdLong(); + long guildId = event.getGuild().getIdLong(); + + if (member == null || author.isBot() || author.isSystem()) { + return; + } + + + if (cakeDayService.hasMemberCakeDayToday(member)) { + cakeDayService.addCakeDayRole(member); + return; + } + + if (cakeDayService.isUserCached(author)) { + return; + } + + cakeDayService.addToCache(author); + Optional cakeDaysRecord = + cakeDayService.findUserCakeDayFromDatabase(authorId); + if (cakeDaysRecord.isPresent()) { + return; + } + + cakeDayService.insertMemberCakeDayToDatabase(member, guildId); + } + + /** + * Handles the event of a guild member being removed from the guild. It removes the user's cake + * day information from the database if present. + * + * @param event the {@link GuildMemberRemoveEvent} representing the member removal event + */ + @Override + public void onGuildMemberRemove(GuildMemberRemoveEvent event) { + User user = event.getUser(); + Guild guild = event.getGuild(); + + cakeDayService.handleUserLeft(user, guild); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayRoutine.java new file mode 100644 index 0000000000..6bfa1b935c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayRoutine.java @@ -0,0 +1,39 @@ +package org.togetherjava.tjbot.features.cakeday; + +import net.dv8tion.jda.api.JDA; +import org.jetbrains.annotations.NotNull; + +import org.togetherjava.tjbot.features.Routine; + +import java.util.concurrent.TimeUnit; + +/** + * Represents a routine for managing cake day celebrations. + *

+ * This routine handles the assignment and removal of a designated cake day role to guild members + * based on their anniversary of joining the guild. + */ +public class CakeDayRoutine implements Routine { + + private final CakeDayService cakeDayService; + + /** + * Constructs a new {@link CakeDayRoutine} instance. + * + * @param cakeDayService an instance of the cake day service + */ + public CakeDayRoutine(CakeDayService cakeDayService) { + this.cakeDayService = cakeDayService; + } + + @Override + @NotNull + public Schedule createSchedule() { + return new Schedule(ScheduleMode.FIXED_RATE, 0, 1, TimeUnit.DAYS); + } + + @Override + public void runRoutine(@NotNull JDA jda) { + jda.getGuilds().forEach(cakeDayService::reassignCakeDayRole); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java b/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java new file mode 100644 index 0000000000..5cc4c829ec --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java @@ -0,0 +1,309 @@ +package org.togetherjava.tjbot.features.cakeday; + +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.UserSnowflake; +import net.dv8tion.jda.api.requests.RestAction; +import net.dv8tion.jda.api.utils.Result; +import org.jooq.Query; +import org.jooq.impl.DSL; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.config.CakeDayConfig; +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.tables.records.CakeDaysRecord; + +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.togetherjava.tjbot.db.generated.tables.CakeDays.CAKE_DAYS; + +/** + * Service for managing the Cake Day feature. + */ +public class CakeDayService { + private static final Logger logger = LoggerFactory.getLogger(CakeDayService.class); + private static final DateTimeFormatter MONTH_DAY_FORMATTER = + DateTimeFormatter.ofPattern("MM-dd"); + private final Set cakeDaysCache = new HashSet<>(); + private final Predicate cakeDayRolePredicate; + private final CakeDayConfig config; + private final Database database; + + /** + * Constructs a {@link CakeDayService} with the given configuration and database. + * + * @param config the configuration for cake day management + * @param database the database for storing cake day information + */ + public CakeDayService(Config config, Database database) { + this.config = config.getCakeDayConfig(); + this.database = database; + + this.cakeDayRolePredicate = Pattern.compile(this.config.rolePattern()).asPredicate(); + } + + private Optional getCakeDayRole(Guild guild) { + Optional cakeDayRole = getCakeDayRoleFromGuild(guild); + + if (cakeDayRole.isEmpty()) { + logger.warn("Cake day role with pattern {} not found for guild: {}", + config.rolePattern(), guild.getName()); + } + + return cakeDayRole; + } + + /** + * Reassigns the cake day role for all members of the given guild. + *

+ * If the cake day role is not found based on the configured pattern, a warning message is + * logged, and no action is taken. + * + * @param guild the guild for which to reassign the cake day role + */ + protected void reassignCakeDayRole(Guild guild) { + Optional cakeDayRole = getCakeDayRole(guild); + + if (cakeDayRole.isEmpty()) { + return; + } + + refreshMembersCakeDayRoles(cakeDayRole.get(), guild); + } + + /** + * Refreshes the Cake Day roles for members in the specified guild. + * + * @param cakeDayRole the Cake Day role to refresh + * @param guild the guild in which to refresh Cake Day roles + */ + private void refreshMembersCakeDayRoles(Role cakeDayRole, Guild guild) { + guild.findMembersWithRoles(cakeDayRole).onSuccess(members -> { + removeRoleFromMembers(guild, cakeDayRole, members); + addTodayMembersCakeDayRole(guild); + }); + } + + /** + * Assigns a special role to members whose cake day (anniversary of joining) is today, but only + * if they have been a member for at least one year. + *

+ * This method checks the current date against the cake day records in the database for each + * member of the given guild. If the member's cake day is today, and they have been a member for + * at least one year, the method assigns them a special role. + * + * @param guild the guild to check for members celebrating their cake day today + */ + private void addTodayMembersCakeDayRole(Guild guild) { + findCakeDaysTodayFromDatabase(guild).forEach(cakeDayRecord -> { + Member member = guild.getMemberById(cakeDayRecord.getUserId()); + + if (member == null) { + return; + } + + boolean isAnniversaryDay = hasMemberCakeDayToday(member); + int yearsSinceJoin = OffsetDateTime.now().getYear() - cakeDayRecord.getJoinedYear(); + + if (yearsSinceJoin > 0 && isAnniversaryDay) { + addCakeDayRole(member); + } + }); + } + + /** + * Adds the cake day role to the specified member if the cake day role exists in the guild. + * + * @param member the {@link Member} to whom the cake day role will be added + */ + protected void addCakeDayRole(Member member) { + Guild guild = member.getGuild(); + UserSnowflake snowflake = UserSnowflake.fromId(member.getId()); + Optional cakeDayRole = getCakeDayRole(guild); + + if (cakeDayRole.isEmpty()) { + return; + } + + guild.addRoleToMember(snowflake, cakeDayRole.get()).complete(); + } + + /** + * Removes a specified role from a list of members in a {@link Guild}. + * + * @param guild the {@link Guild} from which to remove the role from members + * @param role the {@link Role} to be removed from the members + * @param members the {@link List} of members from which the {@link Role} will be removed + */ + private void removeRoleFromMembers(Guild guild, Role role, List members) { + List>> chain = members.stream() + .map(member -> guild.removeRoleFromMember(member, role).mapToResult()) + .toList(); + + if (chain.isEmpty()) { + return; + } + + RestAction.allOf(chain).queue(); + } + + /** + * Creates a query to insert a member's cake day information into the database. + * + * @param member the member whose cake day information is to be inserted + * @param guildId the ID of the guild to which the member belongs + * @return an Optional containing the query to insert cake day information if the member has a + * join time; empty Optional otherwise + */ + private Optional createMemberCakeDayQuery(Member member, long guildId) { + if (!member.hasTimeJoined()) { + return Optional.empty(); + } + + OffsetDateTime cakeDay = member.getTimeJoined(); + String joinedMonthDay = cakeDay.format(MONTH_DAY_FORMATTER); + + return Optional.of(DSL.insertInto(CAKE_DAYS) + .set(CAKE_DAYS.JOINED_MONTH_DAY, joinedMonthDay) + .set(CAKE_DAYS.JOINED_YEAR, cakeDay.getYear()) + .set(CAKE_DAYS.GUILD_ID, guildId) + .set(CAKE_DAYS.USER_ID, member.getIdLong())); + } + + /** + * Inserts the cake day of a member into the database. + *

+ * If the member has no join date, nothing happens. + * + * @param member the member whose cake day is to be inserted into the database + * @param guildId the ID of the guild to which the member belongs + */ + protected void insertMemberCakeDayToDatabase(Member member, long guildId) { + Query insertQuery = createMemberCakeDayQuery(member, guildId).orElse(null); + + if (insertQuery == null) { + logger.warn("Tried to add member {} to database but found no time joined", + member.getId()); + } + + database.write(context -> context.batch(insertQuery).execute()); + } + + /** + * Removes the member's cake day record from the database. + * + * @param userId the ID of the user whose cake day information is to be removed + * @param guildId the ID of the guild where the user belongs + */ + protected void removeMemberCakeDayFromDatabase(long userId, long guildId) { + database.write(context -> context.deleteFrom(CAKE_DAYS) + .where(CAKE_DAYS.USER_ID.eq(userId)) + .and(CAKE_DAYS.GUILD_ID.eq(guildId)) + .execute()); + } + + /** + * Retrieves the cake day {@link Role} from the specified guild. + * + * @param guild the {@link Guild} from which to retrieve the cake day role + * @return an {@link Optional} containing the cake day role if found, otherwise empty + */ + private Optional getCakeDayRoleFromGuild(Guild guild) { + return guild.getRoles() + .stream() + .filter(role -> cakeDayRolePredicate.test(role.getName())) + .findFirst(); + } + + /** + * Removes the cake day information of the specified user from the database and clears the cache + * for the guild. + * + * @param user the {@link User} who left the guild + * @param guild the {@link Guild} from which the user left + */ + protected void handleUserLeft(User user, Guild guild) { + removeMemberCakeDayFromDatabase(user.getIdLong(), guild.getIdLong()); + cakeDaysCache.remove(guild.getId()); + } + + /** + * Finds cake days records for today from the database. + * + * @return a list of {@link CakeDaysRecord} objects representing cake days for today + */ + private List findCakeDaysTodayFromDatabase(Guild guild) { + String todayMonthDay = OffsetDateTime.now().format(MONTH_DAY_FORMATTER); + + return database + .read(context -> context.selectFrom(CAKE_DAYS) + .where(CAKE_DAYS.JOINED_MONTH_DAY.eq(todayMonthDay)) + .and(CAKE_DAYS.GUILD_ID.eq(guild.getIdLong())) + .fetch()) + .collect(Collectors.toList()); + } + + /** + * Searches for the {@link CakeDaysRecord} of a user in the database. + * + * @param userId the user ID of the user whose cake day record is to be retrieved + * @return an {@link Optional} containing the cake day record of the user, or an empty + * {@link Optional} if no record is found + */ + protected Optional findUserCakeDayFromDatabase(long userId) { + return database + .read(context -> context.selectFrom(CAKE_DAYS) + .where(CAKE_DAYS.USER_ID.eq(userId)) + .fetch()) + .collect(Collectors.toList()) + .stream() + .findFirst(); + } + + /** + * Checks if the provided user is cached in the cake day stores cache. + * + * @param user the user to check if cached + * @return true if the user is cached, false otherwise + */ + protected boolean isUserCached(User user) { + return cakeDaysCache.contains(user.getId()); + } + + + /** + * Checks if the provided {@link Member} has their "cake day" today. + * + * @param member the {@link Member} whose cake day is to be checked + * @return true if the member has their cake day today; otherwise, false + */ + protected boolean hasMemberCakeDayToday(Member member) { + OffsetDateTime now = OffsetDateTime.now(); + OffsetDateTime joinMonthDate = member.getTimeJoined(); + int anniversary = now.getYear() - joinMonthDate.getYear(); + + return anniversary > 0 && now.getMonth() == joinMonthDate.getMonth() + && now.getDayOfMonth() == joinMonthDate.getDayOfMonth(); + } + + /** + * Adds the provided user to the cake day stores cache. + * + * @param user the user to add to the cache + */ + protected void addToCache(User user) { + cakeDaysCache.add(user.getId()); + } +} diff --git a/application/src/main/resources/db/V16__Add_Cake_Days.sql b/application/src/main/resources/db/V16__Add_Cake_Days.sql new file mode 100644 index 0000000000..c78a39568c --- /dev/null +++ b/application/src/main/resources/db/V16__Add_Cake_Days.sql @@ -0,0 +1,10 @@ +CREATE TABLE cake_days +( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + joined_month_day TEXT NOT NULL, + joined_year INT NOT NULL, + guild_id BIGINT NOT NULL, + user_id BIGINT NOT NULL +); + +CREATE INDEX cake_day_idx ON cake_days(joined_month_day); \ No newline at end of file