diff --git a/abstracto-application/abstracto-modules/pom.xml b/abstracto-application/abstracto-modules/pom.xml
index a51754428..142d428bd 100644
--- a/abstracto-application/abstracto-modules/pom.xml
+++ b/abstracto-application/abstracto-modules/pom.xml
@@ -23,6 +23,7 @@
remind
suggestion
repost-detection
+ sticky-roles
webservices
logging
invite-filter
diff --git a/abstracto-application/abstracto-modules/remind/remind-impl/pom.xml b/abstracto-application/abstracto-modules/remind/remind-impl/pom.xml
index c4a74d405..5665c814f 100644
--- a/abstracto-application/abstracto-modules/remind/remind-impl/pom.xml
+++ b/abstracto-application/abstracto-modules/remind/remind-impl/pom.xml
@@ -9,11 +9,6 @@
remind-impl
-
- 8
- 8
-
-
diff --git a/abstracto-application/abstracto-modules/repost-detection/pom.xml b/abstracto-application/abstracto-modules/repost-detection/pom.xml
index 09680b667..660bf306b 100644
--- a/abstracto-application/abstracto-modules/repost-detection/pom.xml
+++ b/abstracto-application/abstracto-modules/repost-detection/pom.xml
@@ -14,11 +14,6 @@
repost-detection-impl
-
- 8
- 8
-
-
dev.sheldan.abstracto.core
diff --git a/abstracto-application/abstracto-modules/repost-detection/repost-detection-int/pom.xml b/abstracto-application/abstracto-modules/repost-detection/repost-detection-int/pom.xml
index d7e023542..f9dfa823e 100644
--- a/abstracto-application/abstracto-modules/repost-detection/repost-detection-int/pom.xml
+++ b/abstracto-application/abstracto-modules/repost-detection/repost-detection-int/pom.xml
@@ -9,9 +9,4 @@
repost-detection-int
-
- 8
- 8
-
-
\ No newline at end of file
diff --git a/abstracto-application/abstracto-modules/sticky-roles/pom.xml b/abstracto-application/abstracto-modules/sticky-roles/pom.xml
new file mode 100644
index 000000000..5f9441af2
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/pom.xml
@@ -0,0 +1,26 @@
+
+
+
+ abstracto-modules
+ dev.sheldan.abstracto.modules
+ 1.5.25-SNAPSHOT
+
+ 4.0.0
+
+ sticky-roles
+ pom
+
+ sticky-roles-int
+ sticky-roles-impl
+
+
+
+
+ dev.sheldan.abstracto.core
+ core-int
+ ${project.version}
+ compile
+
+
+
+
\ No newline at end of file
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/pom.xml b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/pom.xml
new file mode 100644
index 000000000..98841adca
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/pom.xml
@@ -0,0 +1,43 @@
+
+
+ 4.0.0
+
+ dev.sheldan.abstracto.modules
+ sticky-roles
+ 1.5.25-SNAPSHOT
+
+
+ sticky-roles-impl
+
+
+
+
+ maven-assembly-plugin
+
+
+ src/main/assembly/liquibase.xml
+
+
+
+
+ make-assembly
+ package
+
+ single
+
+
+
+
+
+
+
+
+
+ dev.sheldan.abstracto.modules
+ sticky-roles-int
+ ${project.version}
+
+
+
\ No newline at end of file
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/assembly/liquibase.xml b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/assembly/liquibase.xml
new file mode 100644
index 000000000..8b4774fa0
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/assembly/liquibase.xml
@@ -0,0 +1,18 @@
+
+ liquibase
+
+ zip
+
+ false
+
+
+ .
+ ${project.basedir}/src/main/resources/migrations
+
+ **/*
+
+
+
+
\ No newline at end of file
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/command/ConfigureStickyRole.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/command/ConfigureStickyRole.java
new file mode 100644
index 000000000..ecc905112
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/command/ConfigureStickyRole.java
@@ -0,0 +1,103 @@
+package dev.sheldan.abstracto.stickyroles.command;
+
+import dev.sheldan.abstracto.core.command.UtilityModuleDefinition;
+import dev.sheldan.abstracto.core.command.condition.AbstractConditionableCommand;
+import dev.sheldan.abstracto.core.command.config.CommandConfiguration;
+import dev.sheldan.abstracto.core.command.config.HelpInfo;
+import dev.sheldan.abstracto.core.command.config.Parameter;
+import dev.sheldan.abstracto.core.command.execution.CommandResult;
+import dev.sheldan.abstracto.core.config.FeatureDefinition;
+import dev.sheldan.abstracto.core.interaction.InteractionService;
+import dev.sheldan.abstracto.core.interaction.slash.SlashCommandConfig;
+import dev.sheldan.abstracto.core.interaction.slash.parameter.SlashCommandParameterService;
+import dev.sheldan.abstracto.stickyroles.config.StickyRolesFeatureDefinition;
+import dev.sheldan.abstracto.stickyroles.config.StickyRolesSlashCommandNames;
+import dev.sheldan.abstracto.stickyroles.service.StickyRoleService;
+import lombok.extern.slf4j.Slf4j;
+import net.dv8tion.jda.api.entities.Role;
+import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+@Component
+@Slf4j
+public class ConfigureStickyRole extends AbstractConditionableCommand {
+
+ private static final String COMMAND_NAME = "configureStickyRole";
+ private static final String STICKY_PARAMETER_NAME = "sticky";
+ private static final String ROLE_PARAMETER_NAME = "role";
+ private static final String RESPONSE_TEMPLATE = "configureStickyRole_response";
+
+ @Autowired
+ private SlashCommandParameterService slashCommandParameterService;
+
+ @Autowired
+ private InteractionService interactionService;
+
+ @Autowired
+ private StickyRoleService stickyRoleService;
+
+ @Override
+ public CompletableFuture executeSlash(SlashCommandInteractionEvent event) {
+ Boolean newState = slashCommandParameterService.getCommandOption(STICKY_PARAMETER_NAME, event, Boolean.class);
+ Role role = slashCommandParameterService.getCommandOption(ROLE_PARAMETER_NAME, event, Role.class);
+ stickyRoleService.setRoleStickiness(role, newState);
+ return interactionService.replyEmbed(RESPONSE_TEMPLATE, event)
+ .thenApply(interactionHook -> CommandResult.fromSuccess());
+ }
+
+ @Override
+ public CommandConfiguration getConfiguration() {
+
+ Parameter roleParameter = Parameter
+ .builder()
+ .name(ROLE_PARAMETER_NAME)
+ .type(Role.class)
+ .optional(false)
+ .templated(true)
+ .build();
+
+ Parameter stateParameter = Parameter
+ .builder()
+ .name(STICKY_PARAMETER_NAME)
+ .type(Boolean.class)
+ .optional(false)
+ .templated(true)
+ .build();
+
+ List parameters = Arrays.asList(roleParameter, stateParameter);
+
+ SlashCommandConfig slashCommandConfig = SlashCommandConfig
+ .builder()
+ .enabled(true)
+ .rootCommandName(StickyRolesSlashCommandNames.STICKY_ROLES)
+ .commandName("configure")
+ .build();
+
+ HelpInfo helpInfo = HelpInfo
+ .builder()
+ .templated(true)
+ .build();
+ return CommandConfiguration.builder()
+ .name(COMMAND_NAME)
+ .module(UtilityModuleDefinition.UTILITY)
+ .parameters(parameters)
+ .templated(true)
+ .slashCommandConfig(slashCommandConfig)
+ .async(true)
+ .slashCommandOnly(true)
+ .supportsEmbedException(true)
+ .help(helpInfo)
+ .causesReaction(true)
+ .build();
+ }
+
+ @Override
+ public FeatureDefinition getFeature() {
+ return StickyRolesFeatureDefinition.STICKY_ROLES;
+ }
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/command/ShowStickyRoles.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/command/ShowStickyRoles.java
new file mode 100644
index 000000000..0ffaf6730
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/command/ShowStickyRoles.java
@@ -0,0 +1,94 @@
+package dev.sheldan.abstracto.stickyroles.command;
+
+import dev.sheldan.abstracto.core.command.UtilityModuleDefinition;
+import dev.sheldan.abstracto.core.command.condition.AbstractConditionableCommand;
+import dev.sheldan.abstracto.core.command.config.CommandConfiguration;
+import dev.sheldan.abstracto.core.command.config.HelpInfo;
+import dev.sheldan.abstracto.core.command.execution.CommandResult;
+import dev.sheldan.abstracto.core.config.FeatureDefinition;
+import dev.sheldan.abstracto.core.interaction.InteractionService;
+import dev.sheldan.abstracto.core.interaction.slash.SlashCommandConfig;
+import dev.sheldan.abstracto.core.models.template.display.RoleDisplay;
+import dev.sheldan.abstracto.stickyroles.config.StickyRolesFeatureDefinition;
+import dev.sheldan.abstracto.stickyroles.config.StickyRolesSlashCommandNames;
+import dev.sheldan.abstracto.stickyroles.model.database.StickyRole;
+import dev.sheldan.abstracto.stickyroles.model.template.StickyRoleDisplayModel;
+import dev.sheldan.abstracto.stickyroles.model.template.StickyRolesDisplayModel;
+import dev.sheldan.abstracto.stickyroles.service.StickyRoleService;
+import lombok.extern.slf4j.Slf4j;
+import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+@Component
+@Slf4j
+public class ShowStickyRoles extends AbstractConditionableCommand {
+
+ private static final String COMMAND_NAME = "showStickyRoles";
+ private static final String RESPONSE_TEMPLATE = "showStickyRoles_response";
+
+ @Autowired
+ private StickyRoleService stickyRoleService;
+
+ @Autowired
+ private InteractionService interactionService;
+
+ @Override
+ public CompletableFuture executeSlash(SlashCommandInteractionEvent event) {
+ List stickyRoles = stickyRoleService.getStickyRolesForServer(event.getGuild());
+ log.info("Showing sticky role config for {} roles in server {}.", stickyRoles.size(), event.getGuild().getIdLong());
+ StickyRolesDisplayModel model = getModel(stickyRoles);
+ return interactionService.replyEmbed(RESPONSE_TEMPLATE, model, event)
+ .thenApply(interactionHook -> CommandResult.fromSuccess());
+ }
+
+ private StickyRolesDisplayModel getModel(List stickyRoles) {
+ List displayRoles = stickyRoles
+ .stream()
+ .map(stickyRole -> StickyRoleDisplayModel
+ .builder()
+ .roleDisplay(RoleDisplay.fromARole(stickyRole.getRole()))
+ .sticky(stickyRole.getSticky())
+ .build())
+ .toList();
+ return StickyRolesDisplayModel
+ .builder()
+ .roles(displayRoles)
+ .build();
+ }
+
+ @Override
+ public CommandConfiguration getConfiguration() {
+
+ SlashCommandConfig slashCommandConfig = SlashCommandConfig
+ .builder()
+ .enabled(true)
+ .rootCommandName(StickyRolesSlashCommandNames.STICKY_ROLES)
+ .commandName("show")
+ .build();
+
+ HelpInfo helpInfo = HelpInfo
+ .builder()
+ .templated(true)
+ .build();
+ return CommandConfiguration.builder()
+ .name(COMMAND_NAME)
+ .module(UtilityModuleDefinition.UTILITY)
+ .templated(true)
+ .slashCommandConfig(slashCommandConfig)
+ .async(true)
+ .slashCommandOnly(true)
+ .supportsEmbedException(true)
+ .help(helpInfo)
+ .causesReaction(true)
+ .build();
+ }
+
+ @Override
+ public FeatureDefinition getFeature() {
+ return StickyRolesFeatureDefinition.STICKY_ROLES;
+ }
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/command/ToggleStickiness.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/command/ToggleStickiness.java
new file mode 100644
index 000000000..aa0076fe6
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/command/ToggleStickiness.java
@@ -0,0 +1,103 @@
+package dev.sheldan.abstracto.stickyroles.command;
+
+import dev.sheldan.abstracto.core.command.UtilityModuleDefinition;
+import dev.sheldan.abstracto.core.command.condition.AbstractConditionableCommand;
+import dev.sheldan.abstracto.core.command.config.CommandConfiguration;
+import dev.sheldan.abstracto.core.command.config.HelpInfo;
+import dev.sheldan.abstracto.core.command.config.Parameter;
+import dev.sheldan.abstracto.core.command.execution.CommandResult;
+import dev.sheldan.abstracto.core.config.FeatureDefinition;
+import dev.sheldan.abstracto.core.config.FeatureMode;
+import dev.sheldan.abstracto.core.interaction.InteractionService;
+import dev.sheldan.abstracto.core.interaction.slash.SlashCommandConfig;
+import dev.sheldan.abstracto.core.interaction.slash.parameter.SlashCommandParameterService;
+import dev.sheldan.abstracto.stickyroles.config.StickyRoleFeatureMode;
+import dev.sheldan.abstracto.stickyroles.config.StickyRolesFeatureDefinition;
+import dev.sheldan.abstracto.stickyroles.config.StickyRolesSlashCommandNames;
+import dev.sheldan.abstracto.stickyroles.service.StickyRoleService;
+import lombok.extern.slf4j.Slf4j;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+@Component
+@Slf4j
+public class ToggleStickiness extends AbstractConditionableCommand {
+
+ private static final String COMMAND_NAME = "toggleStickiness";
+ private static final String RESPONSE_TEMPLATE = "toggleStickiness_response";
+
+ private static final String STICKY_PARAMETER_NAME = "sticky";
+
+ @Autowired
+ private StickyRoleService stickyRoleService;
+
+ @Autowired
+ private InteractionService interactionService;
+
+ @Autowired
+ private SlashCommandParameterService slashCommandParameterService;
+
+
+ @Override
+ public CompletableFuture executeSlash(SlashCommandInteractionEvent event) {
+ Member targetMember = event.getMember();
+ Boolean newState = slashCommandParameterService.getCommandOption(STICKY_PARAMETER_NAME, event, Boolean.class);
+ stickyRoleService.setStickiness(targetMember, newState);
+ return interactionService.replyEmbed(RESPONSE_TEMPLATE, event)
+ .thenApply(interactionHook -> CommandResult.fromSuccess());
+ }
+
+ @Override
+ public CommandConfiguration getConfiguration() {
+
+ Parameter stateParameter = Parameter
+ .builder()
+ .name(STICKY_PARAMETER_NAME)
+ .type(Boolean.class)
+ .optional(false)
+ .templated(true)
+ .build();
+
+ SlashCommandConfig slashCommandConfig = SlashCommandConfig
+ .builder()
+ .enabled(true)
+ .rootCommandName(StickyRolesSlashCommandNames.STICKY_ROLES_PUBLIC)
+ .commandName("toggle")
+ .build();
+
+ List parameters = Arrays.asList(stateParameter);
+
+ HelpInfo helpInfo = HelpInfo
+ .builder()
+ .templated(true)
+ .build();
+ return CommandConfiguration.builder()
+ .name(COMMAND_NAME)
+ .module(UtilityModuleDefinition.UTILITY)
+ .templated(true)
+ .slashCommandConfig(slashCommandConfig)
+ .async(true)
+ .parameters(parameters)
+ .slashCommandOnly(true)
+ .supportsEmbedException(true)
+ .help(helpInfo)
+ .causesReaction(true)
+ .build();
+ }
+
+ @Override
+ public FeatureDefinition getFeature() {
+ return StickyRolesFeatureDefinition.STICKY_ROLES;
+ }
+
+ @Override
+ public List getFeatureModeLimitations() {
+ return Arrays.asList(StickyRoleFeatureMode.ALLOW_SELF_MANAGEMENT);
+ }
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/command/ToggleStickinessManagement.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/command/ToggleStickinessManagement.java
new file mode 100644
index 000000000..fee6c6b31
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/command/ToggleStickinessManagement.java
@@ -0,0 +1,129 @@
+package dev.sheldan.abstracto.stickyroles.command;
+
+import dev.sheldan.abstracto.core.command.UtilityModuleDefinition;
+import dev.sheldan.abstracto.core.command.condition.AbstractConditionableCommand;
+import dev.sheldan.abstracto.core.command.config.CommandConfiguration;
+import dev.sheldan.abstracto.core.command.config.HelpInfo;
+import dev.sheldan.abstracto.core.command.config.Parameter;
+import dev.sheldan.abstracto.core.command.execution.CommandResult;
+import dev.sheldan.abstracto.core.config.FeatureDefinition;
+import dev.sheldan.abstracto.core.interaction.InteractionService;
+import dev.sheldan.abstracto.core.interaction.slash.SlashCommandConfig;
+import dev.sheldan.abstracto.core.interaction.slash.parameter.SlashCommandParameterService;
+import dev.sheldan.abstracto.core.service.UserService;
+import dev.sheldan.abstracto.stickyroles.config.StickyRolesFeatureDefinition;
+import dev.sheldan.abstracto.stickyroles.config.StickyRolesSlashCommandNames;
+import dev.sheldan.abstracto.stickyroles.service.StickyRoleService;
+import lombok.extern.slf4j.Slf4j;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.User;
+import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
+import net.dv8tion.jda.api.interactions.commands.OptionType;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+@Component
+@Slf4j
+public class ToggleStickinessManagement extends AbstractConditionableCommand {
+
+ private static final String COMMAND_NAME = "toggleStickinessManagement";
+ private static final String RESPONSE_TEMPLATE = "toggleStickinessManagement_response";
+
+ private static final String MEMBER_PARAMETER_NAME = "member";
+ private static final String STICKY_PARAMETER_NAME = "sticky";
+
+ @Autowired
+ private SlashCommandParameterService slashCommandParameterService;
+
+ @Autowired
+ private InteractionService interactionService;
+
+ @Autowired
+ private StickyRoleService stickyRoleService;
+
+ @Autowired
+ private UserService userService;
+
+ @Autowired
+ private ToggleStickinessManagement self;
+
+ @Override
+ public CompletableFuture executeSlash(SlashCommandInteractionEvent event) {
+ Boolean newState = slashCommandParameterService.getCommandOption(STICKY_PARAMETER_NAME, event, Boolean.class);
+ if(slashCommandParameterService.hasCommandOptionWithFullType(MEMBER_PARAMETER_NAME, event, OptionType.USER)) {
+ Member targetMember = slashCommandParameterService.getCommandOption(MEMBER_PARAMETER_NAME, event, User.class, Member.class);
+ stickyRoleService.setStickiness(targetMember, newState);
+ return interactionService.replyEmbed(RESPONSE_TEMPLATE, event)
+ .thenApply(interactionHook -> CommandResult.fromSuccess());
+ } else {
+ String userIdStr = slashCommandParameterService.getCommandOption(MEMBER_PARAMETER_NAME, event, User.class, String.class);
+ Long userId = Long.parseLong(userIdStr);
+ return userService.retrieveUserForId(userId).thenCompose(user -> {
+ self.callService(event, user, newState);
+ return interactionService.replyEmbed(RESPONSE_TEMPLATE, event);
+ }).thenApply(interactionHook -> CommandResult.fromSuccess());
+ }
+ }
+
+
+ @Transactional
+ public void callService(SlashCommandInteractionEvent event, User user, Boolean newState) {
+ stickyRoleService.setStickiness(user, event.getGuild(), newState);
+ }
+
+ @Override
+ public CommandConfiguration getConfiguration() {
+ Parameter memberParameter = Parameter
+ .builder()
+ .name(MEMBER_PARAMETER_NAME)
+ .type(User.class)
+ .optional(false)
+ .templated(true)
+ .build();
+
+
+ Parameter stateParameter = Parameter
+ .builder()
+ .name(STICKY_PARAMETER_NAME)
+ .type(Boolean.class)
+ .optional(false)
+ .templated(true)
+ .build();
+
+ SlashCommandConfig slashCommandConfig = SlashCommandConfig
+ .builder()
+ .enabled(true)
+ .rootCommandName(StickyRolesSlashCommandNames.STICKY_ROLES)
+ .commandName("manage")
+ .build();
+
+ List parameters = Arrays.asList(memberParameter, stateParameter);
+
+ HelpInfo helpInfo = HelpInfo
+ .builder()
+ .templated(true)
+ .build();
+ return CommandConfiguration.builder()
+ .name(COMMAND_NAME)
+ .module(UtilityModuleDefinition.UTILITY)
+ .templated(true)
+ .parameters(parameters)
+ .slashCommandConfig(slashCommandConfig)
+ .async(true)
+ .slashCommandOnly(true)
+ .supportsEmbedException(true)
+ .help(helpInfo)
+ .causesReaction(true)
+ .build();
+ }
+
+ @Override
+ public FeatureDefinition getFeature() {
+ return StickyRolesFeatureDefinition.STICKY_ROLES;
+ }
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/config/StickyRolesConfig.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/config/StickyRolesConfig.java
new file mode 100644
index 000000000..f6b960c53
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/config/StickyRolesConfig.java
@@ -0,0 +1,9 @@
+package dev.sheldan.abstracto.stickyroles.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.PropertySource;
+
+@Configuration
+@PropertySource("classpath:stickyRoles-config.properties")
+public class StickyRolesConfig {
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/listener/StickyRolesJoinListener.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/listener/StickyRolesJoinListener.java
new file mode 100644
index 000000000..a1e4355af
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/listener/StickyRolesJoinListener.java
@@ -0,0 +1,28 @@
+package dev.sheldan.abstracto.stickyroles.listener;
+
+import dev.sheldan.abstracto.core.config.FeatureDefinition;
+import dev.sheldan.abstracto.core.listener.DefaultListenerResult;
+import dev.sheldan.abstracto.core.listener.async.jda.AsyncUpdatePendingListener;
+import dev.sheldan.abstracto.core.models.listener.MemberUpdatePendingModel;
+import dev.sheldan.abstracto.stickyroles.config.StickyRolesFeatureDefinition;
+import dev.sheldan.abstracto.stickyroles.service.StickyRoleService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+@Component
+public class StickyRolesJoinListener implements AsyncUpdatePendingListener {
+
+ @Autowired
+ private StickyRoleService stickyRoleService;
+
+ @Override
+ public DefaultListenerResult execute(MemberUpdatePendingModel model) {
+ stickyRoleService.handleJoin(model.getMember());
+ return DefaultListenerResult.PROCESSED;
+ }
+
+ @Override
+ public FeatureDefinition getFeature() {
+ return StickyRolesFeatureDefinition.STICKY_ROLES;
+ }
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/listener/StickyRolesLeaveListener.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/listener/StickyRolesLeaveListener.java
new file mode 100644
index 000000000..760088644
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/listener/StickyRolesLeaveListener.java
@@ -0,0 +1,36 @@
+package dev.sheldan.abstracto.stickyroles.listener;
+
+import dev.sheldan.abstracto.core.config.FeatureDefinition;
+import dev.sheldan.abstracto.core.listener.DefaultListenerResult;
+import dev.sheldan.abstracto.core.listener.async.jda.AsyncLeaveListener;
+import dev.sheldan.abstracto.core.models.listener.MemberLeaveModel;
+import dev.sheldan.abstracto.stickyroles.config.StickyRolesFeatureDefinition;
+import dev.sheldan.abstracto.stickyroles.service.StickyRoleService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+@Component
+@Slf4j
+public class StickyRolesLeaveListener implements AsyncLeaveListener {
+
+ @Autowired
+ private StickyRoleService stickyRoleService;
+
+ @Override
+ public DefaultListenerResult execute(MemberLeaveModel model) {
+ if(model.getMember() != null) {
+ stickyRoleService.handleLeave(model.getMember());
+ return DefaultListenerResult.PROCESSED;
+ } else {
+ log.warn("Member object was not found for storing sticky roles for user {} in server {}.", model.getLeavingUser().getUserId(), model.getServerId());
+ }
+
+ return DefaultListenerResult.IGNORED;
+ }
+
+ @Override
+ public FeatureDefinition getFeature() {
+ return StickyRolesFeatureDefinition.STICKY_ROLES;
+ }
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/repository/StickyRoleRepository.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/repository/StickyRoleRepository.java
new file mode 100644
index 000000000..1d9cf55df
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/repository/StickyRoleRepository.java
@@ -0,0 +1,13 @@
+package dev.sheldan.abstracto.stickyroles.repository;
+
+import dev.sheldan.abstracto.core.models.database.AServer;
+import dev.sheldan.abstracto.stickyroles.model.database.StickyRole;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface StickyRoleRepository extends JpaRepository {
+ List findStickyRoleByServer(AServer server);
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/repository/StickyRoleUserRepository.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/repository/StickyRoleUserRepository.java
new file mode 100644
index 000000000..5b9179606
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/repository/StickyRoleUserRepository.java
@@ -0,0 +1,9 @@
+package dev.sheldan.abstracto.stickyroles.repository;
+
+import dev.sheldan.abstracto.stickyroles.model.database.StickyRoleUser;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface StickyRoleUserRepository extends JpaRepository {
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/service/StickyRoleServiceBean.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/service/StickyRoleServiceBean.java
new file mode 100644
index 000000000..d5f6ea70a
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/service/StickyRoleServiceBean.java
@@ -0,0 +1,132 @@
+package dev.sheldan.abstracto.stickyroles.service;
+
+import dev.sheldan.abstracto.core.models.database.AServer;
+import dev.sheldan.abstracto.core.service.RoleService;
+import dev.sheldan.abstracto.core.service.management.ServerManagementService;
+import dev.sheldan.abstracto.stickyroles.model.database.StickyRole;
+import dev.sheldan.abstracto.stickyroles.model.database.StickyRoleUser;
+import dev.sheldan.abstracto.stickyroles.service.management.StickyRoleManagementService;
+import dev.sheldan.abstracto.stickyroles.service.management.StickyRoleUserManagementService;
+import lombok.extern.slf4j.Slf4j;
+import net.dv8tion.jda.api.entities.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Component
+@Slf4j
+public class StickyRoleServiceBean implements StickyRoleService {
+
+ @Autowired
+ private StickyRoleManagementService stickyRoleManagementService;
+
+ @Autowired
+ private StickyRoleUserManagementService stickyRoleUserManagementService;
+
+ @Autowired
+ private RoleService roleService;
+
+ @Autowired
+ private ServerManagementService serverManagementService;
+
+ @Override
+ public void setRoleStickiness(Role role, Boolean stickiness) {
+ StickyRole stickyRole = stickyRoleManagementService.getOrCreateStickyRole(role);
+ log.info("Setting stickiness of role {} in server {} to {}", role.getIdLong(), role.getGuild().getIdLong(), stickiness);
+ stickyRole.setSticky(stickiness);
+ }
+
+ @Override
+ public void setStickiness(Member member, Boolean stickiness) {
+ StickyRoleUser user = stickyRoleUserManagementService.getOrCreateStickyRoleUser(member);
+ user.setSticky(stickiness);
+ log.info("Setting stickiness of member {} in server {} to {}", member.getIdLong(), member.getGuild().getIdLong(), stickiness);
+ if(!stickiness) {
+ clearStickyRolesForUser(user);
+ }
+ }
+
+ @Override
+ public void setStickiness(User user, Guild guild, Boolean stickiness) {
+ StickyRoleUser stickyUser = stickyRoleUserManagementService.getOrCreateStickyRoleUser(guild.getIdLong(), user.getIdLong());
+ stickyUser.setSticky(stickiness);
+ log.info("Setting stickiness of member {} in server {} to {}", user.getIdLong(), guild.getIdLong(), stickiness);
+ if(!stickiness) {
+ clearStickyRolesForUser(stickyUser);
+ }
+ }
+
+ @Override
+ public void handleLeave(Member member) {
+ log.info("Handling user leave of member {} from server {} regarding sticky roles.", member.getIdLong(), member.getGuild().getIdLong());
+ StickyRoleUser user = stickyRoleUserManagementService.getOrCreateStickyRoleUser(member);
+ clearStickyRolesForUser(user);
+ if(user.getSticky()) {
+ List memberRoles = member.getRoles();
+ log.info("Member was marked as sticky - storing {} roles.", memberRoles.size());
+ Set memberRoleIds = memberRoles
+ .stream()
+ .map(ISnowflake::getIdLong)
+ .collect(Collectors.toSet());
+ List existingStickyRolesOfUser = stickyRoleManagementService.getRoles(new ArrayList<>(memberRoleIds));
+ Set existingStickyRoleIdsOfUser = existingStickyRolesOfUser
+ .stream()
+ .map(StickyRole::getId)
+ .collect(Collectors.toSet());
+ memberRoleIds.removeAll(existingStickyRoleIdsOfUser);
+ List newStickyRoles = memberRoleIds
+ .stream()
+ .map(rid -> stickyRoleManagementService.createStickyRole(rid))
+ .toList();
+ log.debug("Creating {} new roles.", newStickyRoles.size());
+ List stickyRolesOfUser = new ArrayList<>(existingStickyRolesOfUser);
+ stickyRolesOfUser.addAll(newStickyRoles);
+ stickyRolesOfUser.forEach(stickyRole -> {
+ stickyRole.getUsers().add(user);
+ });
+ user.setRoles(stickyRolesOfUser);
+ }
+ }
+
+ private static void clearStickyRolesForUser(StickyRoleUser user) {
+ log.debug("Clearing sticky roles for user {}", user.getId());
+ user.getRoles().forEach(stickyRole -> {
+ stickyRole.getUsers().remove(user);
+ });
+ user.getRoles().clear();
+ }
+
+ @Override
+ public void handleJoin(Member member) {
+ log.info("Handling server join for member {} in server {} regarding sticky roles.", member.getIdLong(), member.getGuild().getIdLong());
+ StickyRoleUser user = stickyRoleUserManagementService.getOrCreateStickyRoleUser(member);
+ if(user.getSticky()) {
+ List rolesToAdd = user
+ .getRoles()
+ .stream()
+ .filter(StickyRole::getSticky)
+ .filter(r -> Boolean.FALSE.equals(r.getRole().getDeleted()))
+ .map(StickyRole::getId)
+ .toList();
+ log.info("Adding {} roles to user {} in server {}", rolesToAdd.size(), member.getIdLong(), member.getGuild().getIdLong());
+ roleService.updateRolesIds(member, new ArrayList<>(), rolesToAdd).thenAccept(unused -> {
+ log.info("Successfully added {} roles to user {} in server {}", rolesToAdd.size(), member.getIdLong(), member.getGuild().getIdLong());
+ }).exceptionally(throwable -> {
+ log.warn("Failed to add {} roles to user {} in server {}", rolesToAdd.size(), member.getIdLong(), member.getGuild().getIdLong(), throwable);
+ return null;
+ });
+ } else {
+ log.info("Not re-applying roles for member {} in server {} as they opted out.", member.getIdLong(), member.getGuild().getIdLong());
+ }
+ }
+
+ @Override
+ public List getStickyRolesForServer(Guild guild) {
+ AServer server = serverManagementService.loadServer(guild);
+ return stickyRoleManagementService.getStickyRolesForServer(server);
+ }
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/service/management/StickyRoleManagementServieBean.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/service/management/StickyRoleManagementServieBean.java
new file mode 100644
index 000000000..1815ef754
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/service/management/StickyRoleManagementServieBean.java
@@ -0,0 +1,60 @@
+package dev.sheldan.abstracto.stickyroles.service.management;
+
+import dev.sheldan.abstracto.core.models.database.ARole;
+import dev.sheldan.abstracto.core.models.database.AServer;
+import dev.sheldan.abstracto.core.service.management.RoleManagementService;
+import dev.sheldan.abstracto.stickyroles.model.database.StickyRole;
+import dev.sheldan.abstracto.stickyroles.repository.StickyRoleRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Optional;
+
+@Component
+public class StickyRoleManagementServieBean implements StickyRoleManagementService {
+
+ @Autowired
+ private StickyRoleRepository stickyRoleRepository;
+
+ @Autowired
+ private RoleManagementService roleManagementService;
+
+
+ @Override
+ public StickyRole getOrCreateStickyRole(Long roleId) {
+ Optional existingRole = stickyRoleRepository.findById(roleId);
+ return existingRole.orElseGet(() -> createStickyRole(roleId));
+ }
+
+ @Override
+ public StickyRole createStickyRole(Long roleId) {
+ ARole aRole = roleManagementService.findRole(roleId);
+ StickyRole role = StickyRole
+ .builder()
+ .id(roleId)
+ .sticky(StickyRoleManagementService.DEFAULT_STICKINESS)
+ .role(aRole)
+ .server(aRole.getServer())
+ .build();
+ return stickyRoleRepository.save(role);
+ }
+
+ @Override
+ public List createStickyRoles(List roleIds) {
+ return roleIds
+ .stream()
+ .map(this::createStickyRole)
+ .toList();
+ }
+
+ @Override
+ public List getRoles(List roleIds) {
+ return stickyRoleRepository.findAllById(roleIds);
+ }
+
+ @Override
+ public List getStickyRolesForServer(AServer server) {
+ return stickyRoleRepository.findStickyRoleByServer(server);
+ }
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/service/management/StickyRoleUserManagementServiceBean.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/service/management/StickyRoleUserManagementServiceBean.java
new file mode 100644
index 000000000..da9e9ca92
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/java/dev/sheldan/abstracto/stickyroles/service/management/StickyRoleUserManagementServiceBean.java
@@ -0,0 +1,53 @@
+package dev.sheldan.abstracto.stickyroles.service.management;
+
+import dev.sheldan.abstracto.core.models.ServerUser;
+import dev.sheldan.abstracto.core.models.database.AUserInAServer;
+import dev.sheldan.abstracto.core.service.management.UserInServerManagementService;
+import dev.sheldan.abstracto.stickyroles.model.database.StickyRoleUser;
+import dev.sheldan.abstracto.stickyroles.repository.StickyRoleUserRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+@Component
+public class StickyRoleUserManagementServiceBean implements StickyRoleUserManagementService {
+
+ @Autowired
+ private StickyRoleUserRepository repository;
+
+ @Autowired
+ private UserInServerManagementService userInServerManagementService;
+
+ @Override
+ public StickyRoleUser getOrCreateStickyRoleUser(Long serverId, Long userId) {
+ ServerUser serverUser = ServerUser
+ .builder()
+ .userId(userId)
+ .serverId(serverId)
+ .build();
+ AUserInAServer userInAServer = userInServerManagementService.loadOrCreateUser(serverUser);
+ return repository.findById(userInAServer.getUserInServerId()).orElseGet(() -> createStickyroleUser(userInAServer));
+ }
+
+ @Override
+ public StickyRoleUser createStickyroleUser(Long serverId, Long userId) {
+ ServerUser serverUser = ServerUser
+ .builder()
+ .userId(userId)
+ .serverId(serverId)
+ .build();
+ AUserInAServer userInAServer = userInServerManagementService.loadOrCreateUser(serverUser);
+ return createStickyroleUser(userInAServer);
+ }
+
+ @Override
+ public StickyRoleUser createStickyroleUser(AUserInAServer userInAServer) {
+ StickyRoleUser stickyRoleUser = StickyRoleUser
+ .builder()
+ .user(userInAServer)
+ .server(userInAServer.getServerReference())
+ .id(userInAServer.getUserInServerId())
+ .sticky(true)
+ .build();
+ return repository.save(stickyRoleUser);
+ }
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/collection.xml b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/collection.xml
new file mode 100644
index 000000000..81ce369db
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/collection.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/seedData/command.xml b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/seedData/command.xml
new file mode 100644
index 000000000..e5487bed2
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/seedData/command.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/seedData/data.xml b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/seedData/data.xml
new file mode 100644
index 000000000..2bf581917
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/seedData/data.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/seedData/feature.xml b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/seedData/feature.xml
new file mode 100644
index 000000000..b16cf9fe7
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/seedData/feature.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/tables/sticky_role.xml b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/tables/sticky_role.xml
new file mode 100644
index 000000000..85365fac5
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/tables/sticky_role.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DROP TRIGGER IF EXISTS sticky_role_update_trigger ON sticky_role;
+ CREATE TRIGGER sticky_role_update_trigger BEFORE UPDATE ON sticky_role FOR EACH ROW EXECUTE PROCEDURE update_trigger_procedure();
+
+
+ DROP TRIGGER IF EXISTS sticky_role_insert_trigger ON sticky_role;
+ CREATE TRIGGER sticky_role_insert_trigger BEFORE INSERT ON sticky_role FOR EACH ROW EXECUTE PROCEDURE insert_trigger_procedure();
+
+
+
\ No newline at end of file
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/tables/sticky_role_user.xml b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/tables/sticky_role_user.xml
new file mode 100644
index 000000000..457cd3f55
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/tables/sticky_role_user.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DROP TRIGGER IF EXISTS sticky_role_user_update_trigger ON sticky_role_user;
+ CREATE TRIGGER sticky_role_user_update_trigger BEFORE UPDATE ON sticky_role_user FOR EACH ROW EXECUTE PROCEDURE update_trigger_procedure();
+
+
+ DROP TRIGGER IF EXISTS sticky_role_user_insert_trigger ON sticky_role_user;
+ CREATE TRIGGER sticky_role_user_insert_trigger BEFORE INSERT ON sticky_role_user FOR EACH ROW EXECUTE PROCEDURE insert_trigger_procedure();
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DROP TRIGGER IF EXISTS sticky_role_user_mapping_update_trigger ON sticky_role_user_mapping;
+ CREATE TRIGGER sticky_role_user_mapping_update_trigger BEFORE UPDATE ON sticky_role_user_mapping FOR EACH ROW EXECUTE PROCEDURE update_trigger_procedure();
+
+
+ DROP TRIGGER IF EXISTS sticky_role_user_mapping_insert_trigger ON sticky_role_user_mapping;
+ CREATE TRIGGER sticky_role_user_mapping_insert_trigger BEFORE INSERT ON sticky_role_user_mapping FOR EACH ROW EXECUTE PROCEDURE insert_trigger_procedure();
+
+
+
\ No newline at end of file
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/tables/tables.xml b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/tables/tables.xml
new file mode 100644
index 000000000..d4bf3635b
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/1.5.25/tables/tables.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/stickyRoles-changeLog.xml b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/stickyRoles-changeLog.xml
new file mode 100644
index 000000000..392ad33ad
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/migrations/stickyRoles-changeLog.xml
@@ -0,0 +1,6 @@
+
+
+
+
\ No newline at end of file
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/stickyRoles-config.properties b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/stickyRoles-config.properties
new file mode 100644
index 000000000..ace614423
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-impl/src/main/resources/stickyRoles-config.properties
@@ -0,0 +1,6 @@
+abstracto.featureFlags.stickyRoles.featureName=stickyRoles
+abstracto.featureFlags.stickyRoles.enabled=false
+
+abstracto.featureModes.allowSelfManagement.featureName=stickyRoles
+abstracto.featureModes.allowSelfManagement.mode=allowSelfManagement
+abstracto.featureModes.allowSelfManagement.enabled=false
\ No newline at end of file
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/pom.xml b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/pom.xml
new file mode 100644
index 000000000..7799a888f
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/pom.xml
@@ -0,0 +1,15 @@
+
+
+ 4.0.0
+
+ dev.sheldan.abstracto.modules
+ sticky-roles
+ 1.5.25-SNAPSHOT
+
+
+ sticky-roles-int
+
+
+
\ No newline at end of file
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/config/StickyRoleFeatureMode.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/config/StickyRoleFeatureMode.java
new file mode 100644
index 000000000..443957a8b
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/config/StickyRoleFeatureMode.java
@@ -0,0 +1,15 @@
+package dev.sheldan.abstracto.stickyroles.config;
+
+import dev.sheldan.abstracto.core.config.FeatureMode;
+import lombok.Getter;
+
+@Getter
+public enum StickyRoleFeatureMode implements FeatureMode {
+ ALLOW_SELF_MANAGEMENT("allowSelfManagement");
+
+ private final String key;
+
+ StickyRoleFeatureMode(String key) {
+ this.key = key;
+ }
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/config/StickyRolesFeatureConfig.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/config/StickyRolesFeatureConfig.java
new file mode 100644
index 000000000..7f3fd8177
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/config/StickyRolesFeatureConfig.java
@@ -0,0 +1,23 @@
+package dev.sheldan.abstracto.stickyroles.config;
+
+import dev.sheldan.abstracto.core.config.FeatureConfig;
+import dev.sheldan.abstracto.core.config.FeatureDefinition;
+import dev.sheldan.abstracto.core.config.FeatureMode;
+import org.springframework.stereotype.Component;
+
+import java.util.Arrays;
+import java.util.List;
+
+@Component
+public class StickyRolesFeatureConfig implements FeatureConfig {
+
+ @Override
+ public FeatureDefinition getFeature() {
+ return StickyRolesFeatureDefinition.STICKY_ROLES;
+ }
+
+ @Override
+ public List getAvailableModes() {
+ return Arrays.asList(StickyRoleFeatureMode.ALLOW_SELF_MANAGEMENT);
+ }
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/config/StickyRolesFeatureDefinition.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/config/StickyRolesFeatureDefinition.java
new file mode 100644
index 000000000..d864ea1a4
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/config/StickyRolesFeatureDefinition.java
@@ -0,0 +1,15 @@
+package dev.sheldan.abstracto.stickyroles.config;
+
+import dev.sheldan.abstracto.core.config.FeatureDefinition;
+import lombok.Getter;
+
+@Getter
+public enum StickyRolesFeatureDefinition implements FeatureDefinition {
+ STICKY_ROLES("stickyRoles");
+
+ private String key;
+
+ StickyRolesFeatureDefinition(String key) {
+ this.key = key;
+ }
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/config/StickyRolesSlashCommandNames.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/config/StickyRolesSlashCommandNames.java
new file mode 100644
index 000000000..3ea1e149a
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/config/StickyRolesSlashCommandNames.java
@@ -0,0 +1,6 @@
+package dev.sheldan.abstracto.stickyroles.config;
+
+public class StickyRolesSlashCommandNames {
+ public static final String STICKY_ROLES = "stickyroles";
+ public static final String STICKY_ROLES_PUBLIC = "stickyrolespublic";
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/model/database/StickyRole.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/model/database/StickyRole.java
new file mode 100644
index 000000000..2b052ca43
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/model/database/StickyRole.java
@@ -0,0 +1,52 @@
+package dev.sheldan.abstracto.stickyroles.model.database;
+
+import dev.sheldan.abstracto.core.models.database.ARole;
+import dev.sheldan.abstracto.core.models.database.AServer;
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Table(name="sticky_role")
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+@Getter
+@Setter
+@EqualsAndHashCode
+public class StickyRole {
+ @Id
+ @Column(name = "id", nullable = false)
+ private Long id;
+
+ /**
+ * Reference to the actual {@link ARole} being maintained
+ */
+ @OneToOne(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE})
+ @PrimaryKeyJoinColumn
+ private ARole role;
+
+ @Column(name = "sticky", nullable = false)
+ private Boolean sticky;
+
+ @ManyToMany
+ @JoinTable(
+ name = "sticky_role_user_mapping",
+ joinColumns = @JoinColumn(name = "role_id"),
+ inverseJoinColumns = @JoinColumn(name = "user_id"))
+ @Builder.Default
+ private List users = new ArrayList<>();
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "server_id", nullable = false)
+ private AServer server;
+
+ @Column(name = "created", nullable = false, insertable = false, updatable = false)
+ private Instant created;
+
+ @Column(name = "updated", insertable = false, updatable = false)
+ private Instant updated;
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/model/database/StickyRoleUser.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/model/database/StickyRoleUser.java
new file mode 100644
index 000000000..3254f0c11
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/model/database/StickyRoleUser.java
@@ -0,0 +1,51 @@
+package dev.sheldan.abstracto.stickyroles.model.database;
+
+import dev.sheldan.abstracto.core.models.database.AServer;
+import dev.sheldan.abstracto.core.models.database.AUserInAServer;
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+@Builder
+@Entity
+@NoArgsConstructor
+@AllArgsConstructor
+@Table(name = "sticky_role_user")
+@Getter
+@Setter
+@EqualsAndHashCode
+public class StickyRoleUser {
+ /**
+ * The ID of the {@link AUserInAServer user} which is represented by this object
+ */
+ @Id
+ @Column(name = "id", nullable = false)
+ private Long id;
+
+ /**
+ * The {@link AUserInAServer user} which is represented by this object
+ */
+ @OneToOne(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE})
+ @PrimaryKeyJoinColumn
+ private AUserInAServer user;
+
+ @Column(name = "sticky")
+ private Boolean sticky;
+
+ @ManyToMany(mappedBy = "users")
+ @Builder.Default
+ private List roles = new ArrayList<>();
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "server_id", nullable = false)
+ private AServer server;
+
+ @Column(name = "created", nullable = false, insertable = false, updatable = false)
+ private Instant created;
+
+ @Column(name = "updated", insertable = false, updatable = false)
+ private Instant updated;
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/model/template/StickyRoleDisplayModel.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/model/template/StickyRoleDisplayModel.java
new file mode 100644
index 000000000..006d9dc55
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/model/template/StickyRoleDisplayModel.java
@@ -0,0 +1,12 @@
+package dev.sheldan.abstracto.stickyroles.model.template;
+
+import dev.sheldan.abstracto.core.models.template.display.RoleDisplay;
+import lombok.Builder;
+import lombok.Getter;
+
+@Getter
+@Builder
+public class StickyRoleDisplayModel {
+ private RoleDisplay roleDisplay;
+ private Boolean sticky;
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/model/template/StickyRolesDisplayModel.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/model/template/StickyRolesDisplayModel.java
new file mode 100644
index 000000000..2f8f29494
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/model/template/StickyRolesDisplayModel.java
@@ -0,0 +1,12 @@
+package dev.sheldan.abstracto.stickyroles.model.template;
+
+import lombok.Builder;
+import lombok.Getter;
+
+import java.util.List;
+
+@Getter
+@Builder
+public class StickyRolesDisplayModel {
+ private List roles;
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/service/StickyRoleService.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/service/StickyRoleService.java
new file mode 100644
index 000000000..965639e5a
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/service/StickyRoleService.java
@@ -0,0 +1,28 @@
+package dev.sheldan.abstracto.stickyroles.service;
+
+import dev.sheldan.abstracto.stickyroles.model.database.StickyRole;
+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 java.util.List;
+
+public interface StickyRoleService {
+ default void ignoreRoleFromStickyRoles(Role role) {
+ setRoleStickiness(role, false);
+ }
+ default void addRoleToStickyRoles(Role role) {
+ setRoleStickiness(role, true);
+ }
+ void setRoleStickiness(Role role, Boolean stickiness);
+
+ void setStickiness(Member member, Boolean newState);
+ void setStickiness(User user, Guild guild, Boolean newState);
+
+ void handleLeave(Member member);
+
+ void handleJoin(Member member);
+
+ List getStickyRolesForServer(Guild guild);
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/service/management/StickyRoleManagementService.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/service/management/StickyRoleManagementService.java
new file mode 100644
index 000000000..f9da7590f
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/service/management/StickyRoleManagementService.java
@@ -0,0 +1,22 @@
+package dev.sheldan.abstracto.stickyroles.service.management;
+
+import dev.sheldan.abstracto.core.models.database.AServer;
+import dev.sheldan.abstracto.stickyroles.model.database.StickyRole;
+import net.dv8tion.jda.api.entities.Role;
+
+import java.util.List;
+
+public interface StickyRoleManagementService {
+ default StickyRole getOrCreateStickyRole(Role role) {
+ return getOrCreateStickyRole(role.getIdLong());
+ }
+ StickyRole getOrCreateStickyRole(Long roleId);
+
+ StickyRole createStickyRole(Long roleId);
+ List createStickyRoles(List roleIds);
+ List getRoles(List roleIds);
+
+ List getStickyRolesForServer(AServer server);
+
+ Boolean DEFAULT_STICKINESS = true;
+}
diff --git a/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/service/management/StickyRoleUserManagementService.java b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/service/management/StickyRoleUserManagementService.java
new file mode 100644
index 000000000..d8dd023c0
--- /dev/null
+++ b/abstracto-application/abstracto-modules/sticky-roles/sticky-roles-int/src/main/java/dev/sheldan/abstracto/stickyroles/service/management/StickyRoleUserManagementService.java
@@ -0,0 +1,14 @@
+package dev.sheldan.abstracto.stickyroles.service.management;
+
+import dev.sheldan.abstracto.core.models.database.AUserInAServer;
+import dev.sheldan.abstracto.stickyroles.model.database.StickyRoleUser;
+import net.dv8tion.jda.api.entities.Member;
+
+public interface StickyRoleUserManagementService {
+ default StickyRoleUser getOrCreateStickyRoleUser(Member member) {
+ return getOrCreateStickyRoleUser(member.getGuild().getIdLong(), member.getIdLong());
+ }
+ StickyRoleUser getOrCreateStickyRoleUser(Long serverId, Long userId);
+ StickyRoleUser createStickyroleUser(Long serverId, Long userId);
+ StickyRoleUser createStickyroleUser(AUserInAServer userInAServer);
+}
diff --git a/abstracto-application/bundle/pom.xml b/abstracto-application/bundle/pom.xml
index 4ff3412e0..e39ac4fa1 100644
--- a/abstracto-application/bundle/pom.xml
+++ b/abstracto-application/bundle/pom.xml
@@ -30,6 +30,12 @@
${project.version}
+
+ dev.sheldan.abstracto.modules
+ sticky-roles-impl
+ ${project.version}
+
+
dev.sheldan.abstracto.modules
moderation-int
diff --git a/abstracto-application/core/core-impl/src/main/java/dev/sheldan/abstracto/core/command/service/CommandManager.java b/abstracto-application/core/core-impl/src/main/java/dev/sheldan/abstracto/core/command/service/CommandManager.java
index cc4fa4fca..dd12148af 100644
--- a/abstracto-application/core/core-impl/src/main/java/dev/sheldan/abstracto/core/command/service/CommandManager.java
+++ b/abstracto-application/core/core-impl/src/main/java/dev/sheldan/abstracto/core/command/service/CommandManager.java
@@ -24,7 +24,6 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
-import java.util.stream.Collectors;
@Service
public class CommandManager implements CommandRegistry {
@@ -54,7 +53,7 @@ public class CommandManager implements CommandRegistry {
@Override
public Optional findCommandByParameters(String name, UnParsedCommandParameter unParsedCommandParameter, Long serverId) {
Optional commandOptional = commands.stream().filter(getCommandByNameAndParameterPredicate(name, unParsedCommandParameter, serverId)).findFirst();
- if(!commandOptional.isPresent()) {
+ if(commandOptional.isEmpty()) {
commandOptional = getCommandViaAliasAndParameter(name, unParsedCommandParameter, serverId);
}
return commandOptional;
@@ -139,7 +138,7 @@ public class CommandManager implements CommandRegistry {
.getDependentFeatures()
.stream()
.map(s -> featureConfigService.getFeatureEnum(s))
- .collect(Collectors.toList());
+ .toList();
boolean required = false;
for (FeatureDefinition featureDefinition : featureDefinitions) {
if(featureFlagService.getFeatureFlagValue(featureDefinition, serverId)) {
diff --git a/abstracto-application/core/core-impl/src/main/java/dev/sheldan/abstracto/core/listener/async/jda/AsyncLeaveListenerBean.java b/abstracto-application/core/core-impl/src/main/java/dev/sheldan/abstracto/core/listener/async/jda/AsyncLeaveListenerBean.java
index 973c4fa65..28694c190 100644
--- a/abstracto-application/core/core-impl/src/main/java/dev/sheldan/abstracto/core/listener/async/jda/AsyncLeaveListenerBean.java
+++ b/abstracto-application/core/core-impl/src/main/java/dev/sheldan/abstracto/core/listener/async/jda/AsyncLeaveListenerBean.java
@@ -46,6 +46,7 @@ public class AsyncLeaveListenerBean extends ListenerAdapter {
.build();
return MemberLeaveModel
.builder()
+ .member(event.getMember())
.leavingUser(serverUser)
.user(event.getUser())
.build();
diff --git a/abstracto-application/core/core-int/src/main/java/dev/sheldan/abstracto/core/exception/FeatureNotFoundException.java b/abstracto-application/core/core-int/src/main/java/dev/sheldan/abstracto/core/exception/FeatureNotFoundException.java
index a15208cd0..4090b21ec 100644
--- a/abstracto-application/core/core-int/src/main/java/dev/sheldan/abstracto/core/exception/FeatureNotFoundException.java
+++ b/abstracto-application/core/core-int/src/main/java/dev/sheldan/abstracto/core/exception/FeatureNotFoundException.java
@@ -10,7 +10,7 @@ public class FeatureNotFoundException extends AbstractoRunTimeException implemen
private final FeatureNotFoundExceptionModel model;
public FeatureNotFoundException(String feature, List availableFeatures) {
- super("Feature not found.");
+ super(String.format("Feature %s not found.", feature));
this.model = FeatureNotFoundExceptionModel
.builder()
.featureName(feature)
diff --git a/abstracto-application/core/core-int/src/main/java/dev/sheldan/abstracto/core/models/listener/MemberLeaveModel.java b/abstracto-application/core/core-int/src/main/java/dev/sheldan/abstracto/core/models/listener/MemberLeaveModel.java
index daa4385d6..fd1628305 100644
--- a/abstracto-application/core/core-int/src/main/java/dev/sheldan/abstracto/core/models/listener/MemberLeaveModel.java
+++ b/abstracto-application/core/core-int/src/main/java/dev/sheldan/abstracto/core/models/listener/MemberLeaveModel.java
@@ -5,6 +5,7 @@ import dev.sheldan.abstracto.core.models.ServerUser;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
+import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.User;
@Getter
@@ -13,6 +14,7 @@ import net.dv8tion.jda.api.entities.User;
public class MemberLeaveModel implements FeatureAwareListenerModel {
private ServerUser leavingUser;
private User user;
+ private Member member;
@Override
public Long getServerId() {
return leavingUser.getServerId();