From 206ddf2523c8d9509f94e7761b85e5534dae6dcb Mon Sep 17 00:00:00 2001 From: christolis Date: Sun, 3 Mar 2024 01:59:49 +0200 Subject: [PATCH 01/15] feat(cake-day): implement batch insert cake days routine --- application/config.json.template | 5 +- .../org/togetherjava/tjbot/Application.java | 4 + .../tjbot/config/CakeDayConfig.java | 7 + .../org/togetherjava/tjbot/config/Config.java | 10 +- .../togetherjava/tjbot/features/Features.java | 2 + .../tjbot/features/basic/CakeDayRoutine.java | 133 ++++++++++++++++++ .../main/resources/db/V16__Add_Cake_Days.sql | 10 ++ 7 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/config/CakeDayConfig.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/basic/CakeDayRoutine.java create mode 100644 application/src/main/resources/db/V16__Add_Cake_Days.sql diff --git a/application/config.json.template b/application/config.json.template index a1aec8f470..fb16e59ef2 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": "cakeDayRolePattern" + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/Application.java b/application/src/main/java/org/togetherjava/tjbot/Application.java index 4c228cb02a..f5135d665d 100644 --- a/application/src/main/java/org/togetherjava/tjbot/Application.java +++ b/application/src/main/java/org/togetherjava/tjbot/Application.java @@ -5,6 +5,8 @@ import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.exceptions.InvalidTokenException; import net.dv8tion.jda.api.requests.GatewayIntent; +import net.dv8tion.jda.api.utils.ChunkingFilter; +import net.dv8tion.jda.api.utils.MemberCachePolicy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -83,6 +85,8 @@ public static void runBot(Config config) { Database database = new Database("jdbc:sqlite:" + databasePath.toAbsolutePath()); JDA jda = JDABuilder.createDefault(config.getToken()) + .setChunkingFilter(ChunkingFilter.ALL) + .setMemberCachePolicy(MemberCachePolicy.ALL) .enableIntents(GatewayIntent.GUILD_MEMBERS, GatewayIntent.MESSAGE_CONTENT) .build(); 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..4596841055 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/CakeDayConfig.java @@ -0,0 +1,7 @@ +package org.togetherjava.tjbot.config; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record CakeDayConfig( + @JsonProperty(value = "rolePattern", required = true) String 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..86c3b7d1f4 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 = cakeDayConfig; } /** @@ -401,6 +404,11 @@ public String getSelectRolesChannelPattern() { return selectRolesChannelPattern; } + // TODO: Add JavaDoc + 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..a51fc3f4a3 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -6,6 +6,7 @@ import org.togetherjava.tjbot.config.FeatureBlacklist; import org.togetherjava.tjbot.config.FeatureBlacklistConfig; import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.features.basic.CakeDayRoutine; import org.togetherjava.tjbot.features.basic.MemberCountDisplayRoutine; import org.togetherjava.tjbot.features.basic.PingCommand; import org.togetherjava.tjbot.features.basic.RoleSelectCommand; @@ -135,6 +136,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(config, database)); features.add(new RSSHandlerRoutine(config, database)); // Message receivers diff --git a/application/src/main/java/org/togetherjava/tjbot/features/basic/CakeDayRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/basic/CakeDayRoutine.java new file mode 100644 index 0000000000..52617123a9 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/basic/CakeDayRoutine.java @@ -0,0 +1,133 @@ +package org.togetherjava.tjbot.features.basic; + +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +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 org.togetherjava.tjbot.features.Routine; + +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.togetherjava.tjbot.db.generated.tables.CakeDays.CAKE_DAYS; + +public class CakeDayRoutine implements Routine { + + private static final Logger logger = LoggerFactory.getLogger(CakeDayRoutine.class); + private static final DateTimeFormatter MONTH_DAY_FORMATTER = + DateTimeFormatter.ofPattern("MM-dd"); + private static final int BULK_INSERT_SIZE = 500; + private final CakeDayConfig config; + private final Database database; + + public CakeDayRoutine(Config config, Database database) { + this.config = config.getCakeDayConfig(); + this.database = database; + } + + /** + * Retrieves the schedule of this routine. Called by the core system once during the startup in + * order to execute the routine accordingly. + *

+ * Changes on the schedule returned by this method afterwards will not be picked up. + * + * @return the schedule of this routine + */ + @Override + public Schedule createSchedule() { + return new Schedule(ScheduleMode.FIXED_RATE, 0, 1, TimeUnit.DAYS); + } + + /** + * Triggered by the core system on the schedule defined by {@link #createSchedule()}. + * + * @param jda the JDA instance the bot is operating with + */ + @Override + public void runRoutine(JDA jda) { + if (getCakeDayCount(this.database) == 0) { + int guildsCount = jda.getGuilds().size(); + + logger.info("Found empty cake_days table. Populating from guild count: {}", + guildsCount); + CompletableFuture.runAsync(() -> populateAllGuildCakeDays(jda)) + .handle((result, exception) -> { + if (exception != null) { + logger.error("populateAllGuildCakeDays failed. Message: {}", + exception.getMessage()); + } else { + logger.info("populateAllGuildCakeDays completed."); + } + + return result; + }); + } + } + + private int getCakeDayCount(Database database) { + return database.read(context -> context.fetchCount(CAKE_DAYS)); + } + + private void populateAllGuildCakeDays(JDA jda) { + jda.getGuilds().forEach(this::batchPopulateGuildCakeDays); + } + + private void batchPopulateGuildCakeDays(Guild guild) { + final List queriesBuffer = new ArrayList<>(); + + guild.getMembers().stream().filter(Member::hasTimeJoined).forEach(member -> { + if (queriesBuffer.size() == BULK_INSERT_SIZE) { + database.write(context -> context.batch(queriesBuffer).execute()); + queriesBuffer.clear(); + return; + } + + Optional query = createMemberCakeDayQuery(member, guild.getIdLong()); + query.ifPresent(queriesBuffer::add); + }); + + // Flush the queries buffer so that the remaining ones get written + if (!queriesBuffer.isEmpty()) { + database.write(context -> context.batch(queriesBuffer).execute()); + } + } + + 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())); + } + + private List findCakeDaysTodayFromDatabase() { + String todayMonthDay = OffsetDateTime.now().format(MONTH_DAY_FORMATTER); + + return database + .read(context -> context.selectFrom(CAKE_DAYS) + .where(CAKE_DAYS.JOINED_MONTH_DAY.eq(todayMonthDay)) + .fetch()) + .collect(Collectors.toList()); + } +} 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 From 767ee85b770f6e5981cc1abedfb9d313e2d7b0fa Mon Sep 17 00:00:00 2001 From: christolis Date: Sun, 3 Mar 2024 14:50:13 +0200 Subject: [PATCH 02/15] feat(cake-day): implement actual routine logic --- .../tjbot/features/basic/CakeDayRoutine.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/basic/CakeDayRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/basic/CakeDayRoutine.java index 52617123a9..86f91a1ef8 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/basic/CakeDayRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/basic/CakeDayRoutine.java @@ -3,6 +3,8 @@ import net.dv8tion.jda.api.JDA; 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.UserSnowflake; import org.jooq.Query; import org.jooq.impl.DSL; import org.slf4j.Logger; @@ -21,6 +23,8 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +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; @@ -31,12 +35,15 @@ public class CakeDayRoutine implements Routine { private static final DateTimeFormatter MONTH_DAY_FORMATTER = DateTimeFormatter.ofPattern("MM-dd"); private static final int BULK_INSERT_SIZE = 500; + private final Predicate cakeDayRolePredicate; private final CakeDayConfig config; private final Database database; public CakeDayRoutine(Config config, Database database) { this.config = config.getCakeDayConfig(); this.database = database; + + this.cakeDayRolePredicate = Pattern.compile(this.config.rolePattern()).asPredicate(); } /** @@ -75,7 +82,49 @@ public void runRoutine(JDA jda) { return result; }); + + return; } + + jda.getGuilds().forEach(this::reassignCakeDayRole); + } + + private void reassignCakeDayRole(Guild guild) { + Role cakeDayRole = getCakeDayRoleFromGuild(guild).orElse(null); + + if (cakeDayRole == null) { + logger.warn("Cake day role with pattern {} not found for guild: {}", + config.rolePattern(), guild.getName()); + return; + } + + removeMembersCakeDayRole(cakeDayRole, guild) + .thenCompose(result -> addTodayMembersCakeDayRole(cakeDayRole, guild)) + .join(); + } + + private CompletableFuture addTodayMembersCakeDayRole(Role cakeDayRole, Guild guild) { + return CompletableFuture + .runAsync(() -> findCakeDaysTodayFromDatabase().forEach(cakeDayRecord -> { + UserSnowflake snowflake = UserSnowflake.fromId(cakeDayRecord.getUserId()); + + int anniversary = OffsetDateTime.now().getYear() - cakeDayRecord.getJoinedYear(); + if (anniversary > 0) { + guild.addRoleToMember(snowflake, cakeDayRole).complete(); + } + })); + } + + private CompletableFuture removeMembersCakeDayRole(Role cakeDayRole, Guild guild) { + return CompletableFuture.runAsync(() -> guild.findMembersWithRoles(cakeDayRole) + .onSuccess(members -> removeRoleFromMembers(guild, cakeDayRole, members))); + } + + private void removeRoleFromMembers(Guild guild, Role role, List members) { + members.forEach(member -> { + UserSnowflake snowflake = UserSnowflake.fromId(member.getIdLong()); + guild.removeRoleFromMember(snowflake, role).complete(); + }); } private int getCakeDayCount(Database database) { @@ -121,6 +170,13 @@ private Optional createMemberCakeDayQuery(Member member, long guildId) { .set(CAKE_DAYS.USER_ID, member.getIdLong())); } + private Optional getCakeDayRoleFromGuild(Guild guild) { + return guild.getRoles() + .stream() + .filter(role -> cakeDayRolePredicate.test(role.getName())) + .findFirst(); + } + private List findCakeDaysTodayFromDatabase() { String todayMonthDay = OffsetDateTime.now().format(MONTH_DAY_FORMATTER); From 401f1e6abb7f4591bb4be986e536e80eed9057ab Mon Sep 17 00:00:00 2001 From: christolis Date: Sun, 3 Mar 2024 15:21:10 +0200 Subject: [PATCH 03/15] docs: add JavaDocs --- .../tjbot/config/CakeDayConfig.java | 3 + .../org/togetherjava/tjbot/config/Config.java | 6 +- .../tjbot/features/basic/CakeDayRoutine.java | 107 +++++++++++++++--- 3 files changed, 102 insertions(+), 14 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/config/CakeDayConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/CakeDayConfig.java index 4596841055..e3f35818c7 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/CakeDayConfig.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/CakeDayConfig.java @@ -2,6 +2,9 @@ import com.fasterxml.jackson.annotation.JsonProperty; +/** + * Configuration record for the Cake Day feature. + */ public record CakeDayConfig( @JsonProperty(value = "rolePattern", required = true) String 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 86c3b7d1f4..7743c589c5 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -404,7 +404,11 @@ public String getSelectRolesChannelPattern() { return selectRolesChannelPattern; } - // TODO: Add JavaDoc + /** + * Retrieves the Cake Day configuration. + * + * @return the cake-day feature configuration + */ public CakeDayConfig getCakeDayConfig() { return cakeDayConfig; } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/basic/CakeDayRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/basic/CakeDayRoutine.java index 86f91a1ef8..5702a0e821 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/basic/CakeDayRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/basic/CakeDayRoutine.java @@ -29,6 +29,12 @@ import static org.togetherjava.tjbot.db.generated.tables.CakeDays.CAKE_DAYS; +/** + * 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 static final Logger logger = LoggerFactory.getLogger(CakeDayRoutine.class); @@ -39,6 +45,12 @@ public class CakeDayRoutine implements Routine { private final CakeDayConfig config; private final Database database; + /** + * Constructs a new {@link CakeDayRoutine} instance. + * + * @param config the configuration for cake day routines + * @param database the database for accessing cake day data + */ public CakeDayRoutine(Config config, Database database) { this.config = config.getCakeDayConfig(); this.database = database; @@ -46,24 +58,11 @@ public CakeDayRoutine(Config config, Database database) { this.cakeDayRolePredicate = Pattern.compile(this.config.rolePattern()).asPredicate(); } - /** - * Retrieves the schedule of this routine. Called by the core system once during the startup in - * order to execute the routine accordingly. - *

- * Changes on the schedule returned by this method afterwards will not be picked up. - * - * @return the schedule of this routine - */ @Override public Schedule createSchedule() { return new Schedule(ScheduleMode.FIXED_RATE, 0, 1, TimeUnit.DAYS); } - /** - * Triggered by the core system on the schedule defined by {@link #createSchedule()}. - * - * @param jda the JDA instance the bot is operating with - */ @Override public void runRoutine(JDA jda) { if (getCakeDayCount(this.database) == 0) { @@ -89,6 +88,14 @@ public void runRoutine(JDA jda) { jda.getGuilds().forEach(this::reassignCakeDayRole); } + /** + * 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 + */ private void reassignCakeDayRole(Guild guild) { Role cakeDayRole = getCakeDayRoleFromGuild(guild).orElse(null); @@ -103,6 +110,16 @@ private void reassignCakeDayRole(Guild guild) { .join(); } + /** + * Asynchronously adds the specified cake day role to guild members who are celebrating their + * cake day today. + *

+ * The cake day role is added to members who have been in the guild for at least one year. + * + * @param cakeDayRole the cake day role to add to qualifying members + * @param guild the guild in which to add the cake day role to members + * @return a {@link CompletableFuture} representing the asynchronous operation + */ private CompletableFuture addTodayMembersCakeDayRole(Role cakeDayRole, Guild guild) { return CompletableFuture .runAsync(() -> findCakeDaysTodayFromDatabase().forEach(cakeDayRecord -> { @@ -115,11 +132,27 @@ private CompletableFuture addTodayMembersCakeDayRole(Role cakeDayRole, Gui })); } + /** + * Removes the specified cake day role from all members who possess it in the given guild + * asynchronously. + * + * @param cakeDayRole the cake day role to be removed from members + * @param guild the guild from which to remove the cake day role + * @return a {@link CompletableFuture} representing the asynchronous operation + */ private CompletableFuture removeMembersCakeDayRole(Role cakeDayRole, Guild guild) { return CompletableFuture.runAsync(() -> guild.findMembersWithRoles(cakeDayRole) .onSuccess(members -> removeRoleFromMembers(guild, cakeDayRole, members))); } + + /** + * Removes a specified role from a list of members in a guild. + * + * @param guild the guild from which to remove the role from members + * @param role the role to be removed from the members + * @param members the list of members from which the role will be removed + */ private void removeRoleFromMembers(Guild guild, Role role, List members) { members.forEach(member -> { UserSnowflake snowflake = UserSnowflake.fromId(member.getIdLong()); @@ -127,14 +160,39 @@ private void removeRoleFromMembers(Guild guild, Role role, List members) }); } + /** + * Retrieves the count of cake days from the provided database. + *

+ * This uses the table cake_days to find the answer. + * + * @param database the database from which to retrieve the count of cake days + * @return the count of cake days stored in the database + */ private int getCakeDayCount(Database database) { return database.read(context -> context.fetchCount(CAKE_DAYS)); } + /** + * Populates cake days for all guilds in the provided JDA instance. + *

+ * This method iterates through all guilds in the provided JDA instance and populates cake days + * for each guild. It is primarily used for batch populating the cake_days table once it + * is found to be empty. + * + * @param jda the JDA instance containing the guilds to populate cake days for + */ private void populateAllGuildCakeDays(JDA jda) { jda.getGuilds().forEach(this::batchPopulateGuildCakeDays); } + /** + * Batch populates guild cake days for the given guild. + *

+ * Uses a buffer for all the queries it makes and its size is determined by the + * {@code BULK_INSERT_SIZE} option. + * + * @param guild the guild for which to populate cake days + */ private void batchPopulateGuildCakeDays(Guild guild) { final List queriesBuffer = new ArrayList<>(); @@ -155,6 +213,18 @@ private void batchPopulateGuildCakeDays(Guild guild) { } } + /** + * Creates a query to insert a member's cake day information into the database. + *

+ * Primarily used for manually constructing queries for members' cake days which are called from + * {@link CakeDayRoutine#batchPopulateGuildCakeDays(Guild)} and added in a batch to be sent to + * 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(); @@ -170,6 +240,12 @@ private Optional createMemberCakeDayQuery(Member member, long guildId) { .set(CAKE_DAYS.USER_ID, member.getIdLong())); } + /** + * Retrieves the cake day {@link Role} from the specified guild. + * + * @param guild the guild from which to retrieve the cake day role + * @return an optional containing the cake day role if found, otherwise empty + */ private Optional getCakeDayRoleFromGuild(Guild guild) { return guild.getRoles() .stream() @@ -177,6 +253,11 @@ private Optional getCakeDayRoleFromGuild(Guild guild) { .findFirst(); } + /** + * Finds cake days records for today from the database. + * + * @return a list of {@link CakeDaysRecord} objects representing cake days for today + */ private List findCakeDaysTodayFromDatabase() { String todayMonthDay = OffsetDateTime.now().format(MONTH_DAY_FORMATTER); From 8192fdf660c45af7cd9c89223254db6a48a083bd Mon Sep 17 00:00:00 2001 From: christolis Date: Mon, 4 Mar 2024 13:29:48 +0200 Subject: [PATCH 04/15] refactor: move cake day feature to its own directory --- .../src/main/java/org/togetherjava/tjbot/features/Features.java | 2 +- .../tjbot/features/{basic => cakeday}/CakeDayRoutine.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename application/src/main/java/org/togetherjava/tjbot/features/{basic => cakeday}/CakeDayRoutine.java (99%) 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 a51fc3f4a3..e84990419f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -6,7 +6,6 @@ import org.togetherjava.tjbot.config.FeatureBlacklist; import org.togetherjava.tjbot.config.FeatureBlacklistConfig; import org.togetherjava.tjbot.db.Database; -import org.togetherjava.tjbot.features.basic.CakeDayRoutine; import org.togetherjava.tjbot.features.basic.MemberCountDisplayRoutine; import org.togetherjava.tjbot.features.basic.PingCommand; import org.togetherjava.tjbot.features.basic.RoleSelectCommand; @@ -16,6 +15,7 @@ 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.CakeDayRoutine; import org.togetherjava.tjbot.features.chatgpt.ChatGptCommand; import org.togetherjava.tjbot.features.chatgpt.ChatGptService; import org.togetherjava.tjbot.features.code.CodeMessageAutoDetection; diff --git a/application/src/main/java/org/togetherjava/tjbot/features/basic/CakeDayRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayRoutine.java similarity index 99% rename from application/src/main/java/org/togetherjava/tjbot/features/basic/CakeDayRoutine.java rename to application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayRoutine.java index 5702a0e821..ed13eca92f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/basic/CakeDayRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayRoutine.java @@ -1,4 +1,4 @@ -package org.togetherjava.tjbot.features.basic; +package org.togetherjava.tjbot.features.cakeday; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.Guild; From 6aee3ba7c9b3239aa53957358f4210986464e883 Mon Sep 17 00:00:00 2001 From: christolis Date: Mon, 4 Mar 2024 18:18:39 +0200 Subject: [PATCH 05/15] fix: improve role refresh handling and make it work --- .../org/togetherjava/tjbot/Application.java | 4 - .../togetherjava/tjbot/features/Features.java | 6 +- .../features/cakeday/CakeDayListener.java | 84 +++++ .../features/cakeday/CakeDayRoutine.java | 247 +-------------- .../features/cakeday/CakeDayService.java | 298 ++++++++++++++++++ 5 files changed, 395 insertions(+), 244 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayListener.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java diff --git a/application/src/main/java/org/togetherjava/tjbot/Application.java b/application/src/main/java/org/togetherjava/tjbot/Application.java index f5135d665d..4c228cb02a 100644 --- a/application/src/main/java/org/togetherjava/tjbot/Application.java +++ b/application/src/main/java/org/togetherjava/tjbot/Application.java @@ -5,8 +5,6 @@ import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.exceptions.InvalidTokenException; import net.dv8tion.jda.api.requests.GatewayIntent; -import net.dv8tion.jda.api.utils.ChunkingFilter; -import net.dv8tion.jda.api.utils.MemberCachePolicy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -85,8 +83,6 @@ public static void runBot(Config config) { Database database = new Database("jdbc:sqlite:" + databasePath.toAbsolutePath()); JDA jda = JDABuilder.createDefault(config.getToken()) - .setChunkingFilter(ChunkingFilter.ALL) - .setMemberCachePolicy(MemberCachePolicy.ALL) .enableIntents(GatewayIntent.GUILD_MEMBERS, GatewayIntent.MESSAGE_CONTENT) .build(); 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 e84990419f..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,7 +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; @@ -116,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); @@ -136,7 +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(config, database)); + features.add(new CakeDayRoutine(cakeDayService)); features.add(new RSSHandlerRoutine(config, database)); // Message receivers @@ -158,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 index ed13eca92f..6bfa1b935c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayRoutine.java @@ -1,33 +1,11 @@ package org.togetherjava.tjbot.features.cakeday; import net.dv8tion.jda.api.JDA; -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.UserSnowflake; -import org.jooq.Query; -import org.jooq.impl.DSL; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.jetbrains.annotations.NotNull; -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 org.togetherjava.tjbot.features.Routine; -import java.time.OffsetDateTime; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; -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; /** * Represents a routine for managing cake day celebrations. @@ -37,234 +15,25 @@ */ public class CakeDayRoutine implements Routine { - private static final Logger logger = LoggerFactory.getLogger(CakeDayRoutine.class); - private static final DateTimeFormatter MONTH_DAY_FORMATTER = - DateTimeFormatter.ofPattern("MM-dd"); - private static final int BULK_INSERT_SIZE = 500; - private final Predicate cakeDayRolePredicate; - private final CakeDayConfig config; - private final Database database; + private final CakeDayService cakeDayService; /** * Constructs a new {@link CakeDayRoutine} instance. * - * @param config the configuration for cake day routines - * @param database the database for accessing cake day data + * @param cakeDayService an instance of the cake day service */ - public CakeDayRoutine(Config config, Database database) { - this.config = config.getCakeDayConfig(); - this.database = database; - - this.cakeDayRolePredicate = Pattern.compile(this.config.rolePattern()).asPredicate(); + 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(JDA jda) { - if (getCakeDayCount(this.database) == 0) { - int guildsCount = jda.getGuilds().size(); - - logger.info("Found empty cake_days table. Populating from guild count: {}", - guildsCount); - CompletableFuture.runAsync(() -> populateAllGuildCakeDays(jda)) - .handle((result, exception) -> { - if (exception != null) { - logger.error("populateAllGuildCakeDays failed. Message: {}", - exception.getMessage()); - } else { - logger.info("populateAllGuildCakeDays completed."); - } - - return result; - }); - - return; - } - - jda.getGuilds().forEach(this::reassignCakeDayRole); - } - - /** - * 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 - */ - private void reassignCakeDayRole(Guild guild) { - Role cakeDayRole = getCakeDayRoleFromGuild(guild).orElse(null); - - if (cakeDayRole == null) { - logger.warn("Cake day role with pattern {} not found for guild: {}", - config.rolePattern(), guild.getName()); - return; - } - - removeMembersCakeDayRole(cakeDayRole, guild) - .thenCompose(result -> addTodayMembersCakeDayRole(cakeDayRole, guild)) - .join(); - } - - /** - * Asynchronously adds the specified cake day role to guild members who are celebrating their - * cake day today. - *

- * The cake day role is added to members who have been in the guild for at least one year. - * - * @param cakeDayRole the cake day role to add to qualifying members - * @param guild the guild in which to add the cake day role to members - * @return a {@link CompletableFuture} representing the asynchronous operation - */ - private CompletableFuture addTodayMembersCakeDayRole(Role cakeDayRole, Guild guild) { - return CompletableFuture - .runAsync(() -> findCakeDaysTodayFromDatabase().forEach(cakeDayRecord -> { - UserSnowflake snowflake = UserSnowflake.fromId(cakeDayRecord.getUserId()); - - int anniversary = OffsetDateTime.now().getYear() - cakeDayRecord.getJoinedYear(); - if (anniversary > 0) { - guild.addRoleToMember(snowflake, cakeDayRole).complete(); - } - })); - } - - /** - * Removes the specified cake day role from all members who possess it in the given guild - * asynchronously. - * - * @param cakeDayRole the cake day role to be removed from members - * @param guild the guild from which to remove the cake day role - * @return a {@link CompletableFuture} representing the asynchronous operation - */ - private CompletableFuture removeMembersCakeDayRole(Role cakeDayRole, Guild guild) { - return CompletableFuture.runAsync(() -> guild.findMembersWithRoles(cakeDayRole) - .onSuccess(members -> removeRoleFromMembers(guild, cakeDayRole, members))); - } - - - /** - * Removes a specified role from a list of members in a guild. - * - * @param guild the guild from which to remove the role from members - * @param role the role to be removed from the members - * @param members the list of members from which the role will be removed - */ - private void removeRoleFromMembers(Guild guild, Role role, List members) { - members.forEach(member -> { - UserSnowflake snowflake = UserSnowflake.fromId(member.getIdLong()); - guild.removeRoleFromMember(snowflake, role).complete(); - }); - } - - /** - * Retrieves the count of cake days from the provided database. - *

- * This uses the table cake_days to find the answer. - * - * @param database the database from which to retrieve the count of cake days - * @return the count of cake days stored in the database - */ - private int getCakeDayCount(Database database) { - return database.read(context -> context.fetchCount(CAKE_DAYS)); - } - - /** - * Populates cake days for all guilds in the provided JDA instance. - *

- * This method iterates through all guilds in the provided JDA instance and populates cake days - * for each guild. It is primarily used for batch populating the cake_days table once it - * is found to be empty. - * - * @param jda the JDA instance containing the guilds to populate cake days for - */ - private void populateAllGuildCakeDays(JDA jda) { - jda.getGuilds().forEach(this::batchPopulateGuildCakeDays); - } - - /** - * Batch populates guild cake days for the given guild. - *

- * Uses a buffer for all the queries it makes and its size is determined by the - * {@code BULK_INSERT_SIZE} option. - * - * @param guild the guild for which to populate cake days - */ - private void batchPopulateGuildCakeDays(Guild guild) { - final List queriesBuffer = new ArrayList<>(); - - guild.getMembers().stream().filter(Member::hasTimeJoined).forEach(member -> { - if (queriesBuffer.size() == BULK_INSERT_SIZE) { - database.write(context -> context.batch(queriesBuffer).execute()); - queriesBuffer.clear(); - return; - } - - Optional query = createMemberCakeDayQuery(member, guild.getIdLong()); - query.ifPresent(queriesBuffer::add); - }); - - // Flush the queries buffer so that the remaining ones get written - if (!queriesBuffer.isEmpty()) { - database.write(context -> context.batch(queriesBuffer).execute()); - } - } - - /** - * Creates a query to insert a member's cake day information into the database. - *

- * Primarily used for manually constructing queries for members' cake days which are called from - * {@link CakeDayRoutine#batchPopulateGuildCakeDays(Guild)} and added in a batch to be sent to - * 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())); - } - - /** - * Retrieves the cake day {@link Role} from the specified guild. - * - * @param guild the guild from which to retrieve the cake day role - * @return an 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(); - } - - /** - * Finds cake days records for today from the database. - * - * @return a list of {@link CakeDaysRecord} objects representing cake days for today - */ - private List findCakeDaysTodayFromDatabase() { - String todayMonthDay = OffsetDateTime.now().format(MONTH_DAY_FORMATTER); - - return database - .read(context -> context.selectFrom(CAKE_DAYS) - .where(CAKE_DAYS.JOINED_MONTH_DAY.eq(todayMonthDay)) - .fetch()) - .collect(Collectors.toList()); + 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..faa12aecde --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java @@ -0,0 +1,298 @@ +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 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) { + Role cakeDayRole = getCakeDayRoleFromGuild(guild).orElse(null); + + if (cakeDayRole == null) { + logger.warn("Cake day role with pattern {} not found for guild: {}", + config.rolePattern(), guild.getName()); + return Optional.empty(); + } + + return Optional.of(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) { + Role cakeDayRole = getCakeDayRole(guild).orElse(null); + + if (cakeDayRole == null) { + return; + } + + refreshMembersCakeDayRoles(cakeDayRole, guild); + } + + private void refreshMembersCakeDayRoles(Role cakeDayRole, Guild guild) { + guild.findMembersWithRoles(cakeDayRole).onSuccess(members -> { + removeRoleFromMembers(guild, cakeDayRole, members); + addTodayMembersCakeDayRole(guild); + }); + } + + /** + * Asynchronously adds the specified cake day role to guild members who are celebrating their + * cake day today. + *

+ * The cake day role is added to members who have been in the guild for at least one year. + * + * @param guild the guild in which to add the cake day role to members + */ + private void addTodayMembersCakeDayRole(Guild guild) { + findCakeDaysTodayFromDatabase().forEach(cakeDayRecord -> { + UserSnowflake userSnowflake = UserSnowflake.fromId(cakeDayRecord.getUserId()); + + int anniversary = OffsetDateTime.now().getYear() - cakeDayRecord.getJoinedYear(); + if (anniversary > 0) { + addCakeDayRole(userSnowflake, guild); + } + }); + } + + protected void addCakeDayRole(UserSnowflake snowflake, Guild guild) { + Role cakeDayRole = getCakeDayRole(guild).orElse(null); + + if (cakeDayRole == null) { + return; + } + + guild.addRoleToMember(snowflake, cakeDayRole).complete(); + } + + protected void addCakeDayRole(Member member) { + Guild guild = member.getGuild(); + UserSnowflake snowflake = UserSnowflake.fromId(member.getId()); + Role cakeDayRole = getCakeDayRole(guild).orElse(null); + + if (cakeDayRole == null) { + return; + } + + guild.addRoleToMember(snowflake, cakeDayRole).complete(); + } + + /** + * Removes the specified cake day role from all members who possess it in the given guild + * asynchronously. + * + * @param cakeDayRole the cake day role to be removed from members + * @param guild the guild from which to remove the cake day role + */ + private synchronized void removeMembersCakeDayRole(Role cakeDayRole, Guild guild) { + guild.findMembersWithRoles(cakeDayRole) + .onSuccess(members -> removeRoleFromMembers(guild, cakeDayRole, members)); + } + + + /** + * Removes a specified role from a list of members in a guild. + * + * @param guild the guild from which to remove the role from members + * @param role the role to be removed from the members + * @param members the list of members from which the role will be removed + */ + private void removeRoleFromMembers(Guild guild, Role role, List members) { + members.forEach(member -> { + UserSnowflake snowflake = UserSnowflake.fromId(member.getIdLong()); + guild.removeRoleFromMember(snowflake, role).complete(); + }); + } + + /** + * 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() { + String todayMonthDay = OffsetDateTime.now().format(MONTH_DAY_FORMATTER); + + return database + .read(context -> context.selectFrom(CAKE_DAYS) + .where(CAKE_DAYS.JOINED_MONTH_DAY.eq(todayMonthDay)) + .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()); + } + + protected boolean hasMemberCakeDayToday(Member member) { + OffsetDateTime now = OffsetDateTime.now(); + OffsetDateTime joinMonthDate = member.getTimeJoined(); + + return 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()); + } +} From dd479c778d806ca7fc715d0d517bb9b4a5ea4b57 Mon Sep 17 00:00:00 2001 From: christolis Date: Mon, 4 Mar 2024 23:44:59 +0200 Subject: [PATCH 06/15] refactor: remove unused method --- .../tjbot/features/cakeday/CakeDayService.java | 13 ------------- 1 file changed, 13 deletions(-) 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 index faa12aecde..e5c7cbde43 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java @@ -130,19 +130,6 @@ protected void addCakeDayRole(Member member) { guild.addRoleToMember(snowflake, cakeDayRole).complete(); } - /** - * Removes the specified cake day role from all members who possess it in the given guild - * asynchronously. - * - * @param cakeDayRole the cake day role to be removed from members - * @param guild the guild from which to remove the cake day role - */ - private synchronized void removeMembersCakeDayRole(Role cakeDayRole, Guild guild) { - guild.findMembersWithRoles(cakeDayRole) - .onSuccess(members -> removeRoleFromMembers(guild, cakeDayRole, members)); - } - - /** * Removes a specified role from a list of members in a guild. * From c1f74483e0726536f486e2ab69d2c9354a6722a3 Mon Sep 17 00:00:00 2001 From: christolis Date: Mon, 4 Mar 2024 23:45:41 +0200 Subject: [PATCH 07/15] docs: add JavaDocs --- .../features/cakeday/CakeDayService.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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 index e5c7cbde43..d1a0dd8e1d 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java @@ -82,6 +82,12 @@ protected void reassignCakeDayRole(Guild guild) { refreshMembersCakeDayRoles(cakeDayRole, 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); @@ -108,6 +114,13 @@ private void addTodayMembersCakeDayRole(Guild guild) { }); } + + /** + * Adds the cake day role to the specified user in the given guild, if available. + * + * @param snowflake the snowflake ID of the user to whom the cake day role will be added + * @param guild the guild in which the cake day role will be added to the user + */ protected void addCakeDayRole(UserSnowflake snowflake, Guild guild) { Role cakeDayRole = getCakeDayRole(guild).orElse(null); @@ -118,6 +131,11 @@ protected void addCakeDayRole(UserSnowflake snowflake, Guild guild) { guild.addRoleToMember(snowflake, cakeDayRole).complete(); } + /** + * 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()); @@ -266,6 +284,13 @@ 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(); From 425956857d705d0511c33d01f598c0843a98c7ad Mon Sep 17 00:00:00 2001 From: christolis Date: Tue, 5 Mar 2024 14:37:46 +0200 Subject: [PATCH 08/15] fix: include anniversary logic for cake day Co-authored-by: TheCodeMr <151576372+TheCodeMr@users.noreply.github.com> Co-authored-by: cab <161495905+cabagbe@users.noreply.github.com> Co-authored-by: Devansh Tiwari <65783463+devloves@users.noreply.github.com> --- .../togetherjava/tjbot/features/cakeday/CakeDayService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index d1a0dd8e1d..d0807200c5 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java @@ -294,8 +294,9 @@ protected boolean isUserCached(User user) { protected boolean hasMemberCakeDayToday(Member member) { OffsetDateTime now = OffsetDateTime.now(); OffsetDateTime joinMonthDate = member.getTimeJoined(); + int anniversary = now.getYear() - joinMonthDate.getYear(); - return now.getMonth() == joinMonthDate.getMonth() + return anniversary > 0 && now.getMonth() == joinMonthDate.getMonth() && now.getDayOfMonth() == joinMonthDate.getDayOfMonth(); } From ee4d90824adc5a239448b47d55fd4cd90b2830c7 Mon Sep 17 00:00:00 2001 From: christolis Date: Tue, 5 Mar 2024 20:04:37 +0200 Subject: [PATCH 09/15] fix: add Objects#requireNonNull() on cakeDayConfig --- .../src/main/java/org/togetherjava/tjbot/config/Config.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7743c589c5..7cb61475ed 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -129,7 +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 = cakeDayConfig; + this.cakeDayConfig = Objects.requireNonNull(cakeDayConfig); } /** From c418ba642eb36a862f34d0f2233124382afdb232 Mon Sep 17 00:00:00 2001 From: christolis Date: Tue, 5 Mar 2024 20:06:02 +0200 Subject: [PATCH 10/15] feat(config): add proper default value for key --- application/config.json.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/config.json.template b/application/config.json.template index fb16e59ef2..b5954ddd1a 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -117,6 +117,6 @@ }, "memberCountCategoryPattern": "Info", "cakeDayConfig": { - "rolePattern": "cakeDayRolePattern" + "rolePattern": "Cake Day" } } From 20f224a5814a81ea65d3c0e11ce17fc7abd791f3 Mon Sep 17 00:00:00 2001 From: christolis Date: Wed, 6 Mar 2024 23:31:29 +0200 Subject: [PATCH 11/15] feat(cake-day): improve removeRoleFromMembers() method --- .../features/cakeday/CakeDayService.java | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) 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 index d0807200c5..f5f626b515 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java @@ -5,6 +5,8 @@ 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; @@ -149,17 +151,22 @@ protected void addCakeDayRole(Member member) { } /** - * Removes a specified role from a list of members in a guild. + * Removes a specified role from a list of members in a {@link Guild}. * - * @param guild the guild from which to remove the role from members - * @param role the role to be removed from the members - * @param members the list of members from which the role will be removed + * @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) { - members.forEach(member -> { - UserSnowflake snowflake = UserSnowflake.fromId(member.getIdLong()); - guild.removeRoleFromMember(snowflake, role).complete(); - }); + List>> chain = members.stream() + .map(member -> guild.removeRoleFromMember(member, role).mapToResult()) + .toList(); + + if (chain.isEmpty()) { + return; + } + + RestAction.allOf(chain).queue(); } /** From dc3382a71b4a385cd5d95740265079e5a73c09c2 Mon Sep 17 00:00:00 2001 From: christolis Date: Thu, 7 Mar 2024 00:24:28 +0200 Subject: [PATCH 12/15] feat: add CakeDayConfig record constructor --- .../org/togetherjava/tjbot/config/CakeDayConfig.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/application/src/main/java/org/togetherjava/tjbot/config/CakeDayConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/CakeDayConfig.java index e3f35818c7..859578c921 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/CakeDayConfig.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/CakeDayConfig.java @@ -2,9 +2,18 @@ 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); + } } From 2f06eb44c03735be8b392edf1b233c1c43dac8bd Mon Sep 17 00:00:00 2001 From: christolis Date: Thu, 7 Mar 2024 00:33:28 +0200 Subject: [PATCH 13/15] feat: use Optional properly --- .../togetherjava/tjbot/features/cakeday/CakeDayService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index f5f626b515..b796500c2d 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java @@ -75,13 +75,13 @@ private Optional getCakeDayRole(Guild guild) { * @param guild the guild for which to reassign the cake day role */ protected void reassignCakeDayRole(Guild guild) { - Role cakeDayRole = getCakeDayRole(guild).orElse(null); + Optional cakeDayRole = getCakeDayRole(guild); - if (cakeDayRole == null) { + if (cakeDayRole.isEmpty()) { return; } - refreshMembersCakeDayRoles(cakeDayRole, guild); + refreshMembersCakeDayRoles(cakeDayRole.get(), guild); } /** From 72156b841cbbddcbdca2f9dcdc60008ea0617ae3 Mon Sep 17 00:00:00 2001 From: christolis Date: Thu, 7 Mar 2024 01:13:25 +0200 Subject: [PATCH 14/15] fix(CakeDayService): consider month and day for cake day role This commit aims to fix a bug where the CakeDayService#addTodayMembersCakeDayRole() method would add the cake day role to all members who have been at least one year into the server, disregarding the month and date in which they joined. The documentation has also been made more clean and concise, while the CakeDayService#addCakeDayRole() which required a UserSnowflake as one of its inputs has been removed and now the other version of this function is used which only requires a Member instance. Passing the Guild would be unnecessary as it could be easily acquired from the Member instance, and additionally it helps make sure that the right Member and Guild are used to call this method. Finally, this commit adds an extra condition in the select-from query found in CakeDayService#findCakeDaysTodayFromDatabase() which makes sure that we get all the cake days for the right guild, instead of getting them all unconditionally. --- .../features/cakeday/CakeDayService.java | 44 ++++++++----------- 1 file changed, 18 insertions(+), 26 deletions(-) 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 index b796500c2d..adf93fa860 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java @@ -98,39 +98,30 @@ private void refreshMembersCakeDayRoles(Role cakeDayRole, Guild guild) { } /** - * Asynchronously adds the specified cake day role to guild members who are celebrating their - * cake day today. + * 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. *

- * The cake day role is added to members who have been in the guild 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 in which to add the cake day role to members + * @param guild the guild to check for members celebrating their cake day today */ private void addTodayMembersCakeDayRole(Guild guild) { - findCakeDaysTodayFromDatabase().forEach(cakeDayRecord -> { - UserSnowflake userSnowflake = UserSnowflake.fromId(cakeDayRecord.getUserId()); + findCakeDaysTodayFromDatabase(guild).forEach(cakeDayRecord -> { + Member member = guild.getMemberById(cakeDayRecord.getUserId()); - int anniversary = OffsetDateTime.now().getYear() - cakeDayRecord.getJoinedYear(); - if (anniversary > 0) { - addCakeDayRole(userSnowflake, guild); + if (member == null) { + return; } - }); - } - - - /** - * Adds the cake day role to the specified user in the given guild, if available. - * - * @param snowflake the snowflake ID of the user to whom the cake day role will be added - * @param guild the guild in which the cake day role will be added to the user - */ - protected void addCakeDayRole(UserSnowflake snowflake, Guild guild) { - Role cakeDayRole = getCakeDayRole(guild).orElse(null); - if (cakeDayRole == null) { - return; - } + boolean isAnniversaryDay = hasMemberCakeDayToday(member); + int yearsSinceJoin = OffsetDateTime.now().getYear() - cakeDayRecord.getJoinedYear(); - guild.addRoleToMember(snowflake, cakeDayRole).complete(); + if (yearsSinceJoin > 0 && isAnniversaryDay) { + addCakeDayRole(member); + } + }); } /** @@ -254,12 +245,13 @@ protected void handleUserLeft(User user, Guild guild) { * * @return a list of {@link CakeDaysRecord} objects representing cake days for today */ - private List findCakeDaysTodayFromDatabase() { + 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()); } From 47b17b80047a0aee1b938636d2450e4e9065345f Mon Sep 17 00:00:00 2001 From: christolis Date: Thu, 7 Mar 2024 01:24:28 +0200 Subject: [PATCH 15/15] refactor: better use of Optional --- .../tjbot/features/cakeday/CakeDayService.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 index adf93fa860..5cc4c829ec 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/cakeday/CakeDayService.java @@ -55,15 +55,14 @@ public CakeDayService(Config config, Database database) { } private Optional getCakeDayRole(Guild guild) { - Role cakeDayRole = getCakeDayRoleFromGuild(guild).orElse(null); + Optional cakeDayRole = getCakeDayRoleFromGuild(guild); - if (cakeDayRole == null) { + if (cakeDayRole.isEmpty()) { logger.warn("Cake day role with pattern {} not found for guild: {}", config.rolePattern(), guild.getName()); - return Optional.empty(); } - return Optional.of(cakeDayRole); + return cakeDayRole; } /** @@ -132,13 +131,13 @@ private void addTodayMembersCakeDayRole(Guild guild) { protected void addCakeDayRole(Member member) { Guild guild = member.getGuild(); UserSnowflake snowflake = UserSnowflake.fromId(member.getId()); - Role cakeDayRole = getCakeDayRole(guild).orElse(null); + Optional cakeDayRole = getCakeDayRole(guild); - if (cakeDayRole == null) { + if (cakeDayRole.isEmpty()) { return; } - guild.addRoleToMember(snowflake, cakeDayRole).complete(); + guild.addRoleToMember(snowflake, cakeDayRole.get()).complete(); } /**