diff --git a/build.gradle.kts b/build.gradle.kts index 8fa76369..fad04b1d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -55,8 +55,7 @@ dependencies { testImplementation(group = "org.springframework.boot", name = "spring-boot-starter-test") testImplementation(group = "org.mockito.kotlin", name = "mockito-kotlin", version = mockitoKotlinVersion) - annotationProcessor(group = "org.springframework.boot", name = "spring-boot-configuration-processor") - kapt(group = "org.springframework.boot", name = "spring-boot-configuration-processor") + developmentOnly("org.springframework.boot:spring-boot-docker-compose") } configurations { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..6c19af7f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3' + +services: + database: + image: mariadb:11.5.2@sha256:4b812bbd9a025569fbe5a7a70e4a3cd3af53aa36621fecb1c2e108af2113450a + ports: + - "3306:3306" + volumes: + - .:/srv/jekyll + environment: + MARIADB_USER: spring + MARIADB_PASSWORD: test + MARIADB_DATABASE: discordmodbot + MARIADB_ROOT_PASSWORD: test + redis: + image: redis:7.4.0@sha256:878983f8f5045b28384fc300268cec62bca3b14d5e1a448bec21f28cfcc7bf78 + ports: + - "6379:6379" diff --git a/src/main/kotlin/be/duncanc/discordmodbot/bot/commands/Command.kt b/src/main/kotlin/be/duncanc/discordmodbot/bot/commands/Command.kt deleted file mode 100644 index b148407d..00000000 --- a/src/main/kotlin/be/duncanc/discordmodbot/bot/commands/Command.kt +++ /dev/null @@ -1,7 +0,0 @@ -package be.duncanc.discordmodbot.bot.commands - -import net.dv8tion.jda.api.interactions.commands.build.CommandData - -interface Command { - fun getCommandsData(): List -} diff --git a/src/main/kotlin/be/duncanc/discordmodbot/bot/commands/Ping.kt b/src/main/kotlin/be/duncanc/discordmodbot/bot/commands/Ping.kt index 24202bfb..23108bd7 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/bot/commands/Ping.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/bot/commands/Ping.kt @@ -17,39 +17,36 @@ package be.duncanc.discordmodbot.bot.commands import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent -import net.dv8tion.jda.api.events.session.ReadyEvent import net.dv8tion.jda.api.hooks.ListenerAdapter -import net.dv8tion.jda.api.interactions.commands.build.CommandData +import net.dv8tion.jda.api.interactions.commands.build.Commands +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData import org.springframework.stereotype.Component @Component -class Ping : ListenerAdapter(), Command { +class Ping : ListenerAdapter(), SlashCommand { companion object { private const val COMMAND = "ping" private const val DESCRIPTION = "responds with \"pong!\"." } - override fun onReady(event: ReadyEvent) { - event.jda.upsertCommand("ping", DESCRIPTION).queue() - } - override fun onSlashCommandInteraction(event: SlashCommandInteractionEvent) { - if (event.name == "ping") { - event.deferReply().queue { hook -> - event.jda.restPing.queue { ping -> - hook.editOriginal( - """pong! + if (event.name != "ping") return + + event.deferReply().queue { hook -> + event.jda.restPing.queue { ping -> + hook.editOriginal( + """pong! It took Discord ${event.jda.gatewayPing} milliseconds to respond to our last heartbeat (gateway). The REST API responded within $ping milliseconds""" - ).queue() - } - + ).queue() } + } + } - override fun getCommandsData(): List { - return listOf() // TODO rework + override fun getCommandsData(): List { + return listOf(Commands.slash("ping", DESCRIPTION)) } diff --git a/src/main/kotlin/be/duncanc/discordmodbot/bot/commands/SlashCommand.kt b/src/main/kotlin/be/duncanc/discordmodbot/bot/commands/SlashCommand.kt new file mode 100644 index 00000000..c245c22e --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/bot/commands/SlashCommand.kt @@ -0,0 +1,10 @@ +package be.duncanc.discordmodbot.bot.commands + +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData + +interface SlashCommand { + fun getCommandsData(): List + + fun onSlashCommandInteraction(event: SlashCommandInteractionEvent) +} diff --git a/src/main/kotlin/be/duncanc/discordmodbot/bot/config/BotConfiguration.kt b/src/main/kotlin/be/duncanc/discordmodbot/bot/config/BotConfiguration.kt index c9daa72d..00909570 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/bot/config/BotConfiguration.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/bot/config/BotConfiguration.kt @@ -1,6 +1,6 @@ package be.duncanc.discordmodbot.bot.config -import be.duncanc.discordmodbot.bot.commands.Command +import be.duncanc.discordmodbot.bot.commands.SlashCommand import be.duncanc.discordmodbot.data.configs.properties.DiscordModBotConfig import net.dv8tion.jda.api.JDA import net.dv8tion.jda.api.JDABuilder @@ -28,7 +28,7 @@ class BotConfiguration { @Bean(destroyMethod = "shutdown") fun jda( listenerAdapters: Array, - commands: Array, + slashCommands: Array, discordModBotConfig: DiscordModBotConfig ): JDA { val jda = JDABuilder.create(discordModBotConfig.botToken, INTENTS) @@ -39,7 +39,7 @@ class BotConfiguration { .build() val updateCommands = jda.updateCommands() - commands.forEach { + slashCommands.forEach { updateCommands.addCommands(it.getCommandsData()) } updateCommands.queue() diff --git a/src/main/kotlin/be/duncanc/discordmodbot/bot/services/IAmRoles.kt b/src/main/kotlin/be/duncanc/discordmodbot/bot/services/IAmRoles.kt index 49f7d951..7e415015 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/bot/services/IAmRoles.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/bot/services/IAmRoles.kt @@ -37,6 +37,13 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Component import java.util.concurrent.TimeUnit +@Deprecated( + message = """ + This is being replaced with a new modern system Roles class slowly. + + Only security and bug fixes should be applied to this class, slowly parts will be removed or disabled from this class. +""" +) @Component class IAmRoles @Autowired constructor( diff --git a/src/main/kotlin/be/duncanc/discordmodbot/bot/services/Roles.kt b/src/main/kotlin/be/duncanc/discordmodbot/bot/services/Roles.kt new file mode 100644 index 00000000..cc1336ef --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/bot/services/Roles.kt @@ -0,0 +1,166 @@ +package be.duncanc.discordmodbot.bot.services + +import be.duncanc.discordmodbot.bot.commands.SlashCommand +import be.duncanc.discordmodbot.data.configs.properties.DiscordModBotConfig +import be.duncanc.discordmodbot.data.services.IAmRolesService +import net.dv8tion.jda.api.entities.Guild +import net.dv8tion.jda.api.entities.Role +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent +import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent +import net.dv8tion.jda.api.hooks.ListenerAdapter +import net.dv8tion.jda.api.interactions.InteractionHook +import net.dv8tion.jda.api.interactions.commands.build.Commands +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData +import net.dv8tion.jda.api.interactions.components.buttons.Button +import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu +import org.springframework.stereotype.Component + +private const val CATEGORY_COMPONENT_ID = "role-choose-category" +private const val ROLE_COMPONENT_ID = "role-choose-role" +private const val MORE_BUTTON_ID_PREFIX = "role-more-" + +@Component +class Roles( + private val iAmRolesService: IAmRolesService, + private val discordModBotConfig: DiscordModBotConfig +) : ListenerAdapter(), SlashCommand { + override fun getCommandsData(): List { + return listOf( + Commands.slash("role", "Allows you to select roles that you can freely assign to yourself") + .addSubcommands( + SubcommandData("assign", "assign yourself a role") + ) + .setGuildOnly(true) + ) + } + + + override fun onSlashCommandInteraction(event: SlashCommandInteractionEvent) { + val guild = event.guild + + if (event.name != "role" || guild == null) return + + when (event.subcommandName) { + "assign" -> { + event.deferReply(true).queue { + val categoryMenu = StringSelectMenu.create(CATEGORY_COMPONENT_ID) + + iAmRolesService.getAllCategoriesForGuild(guild.idLong).forEach { category -> + categoryMenu.addOption(category.categoryName, category.categoryId.toString()) + } + + it.sendMessage("Select the category you would like a role from") + .addActionRow(categoryMenu.build()) + .queue() + } + } + + else -> { + event.deferReply(true).queue { reply -> + reply.sendMessage("That's not a valid subcommand for this command.").queue() + } + } + } + } + + override fun onStringSelectInteraction(event: StringSelectInteractionEvent) { + val guild = event.guild + val componentId = event.componentId + + if ((componentId != CATEGORY_COMPONENT_ID && componentId != ROLE_COMPONENT_ID) || guild == null) return + + if (componentId == CATEGORY_COMPONENT_ID) { + event.deferReply(true).queue { reply -> + val categoryId = event.values.first().toLong() + + replyWithRoleMenu(reply, guild, categoryId, 0) + } + } + + if (componentId == ROLE_COMPONENT_ID) { + val ephemeral = event.user.idLong != discordModBotConfig.ownerId + + event.deferReply(ephemeral).queue { reply -> + val roleId = event.values.first().toLong() + + val roleCategory = iAmRolesService.getCategoryByRoleId(guild.idLong, roleId) + + val member = event.member ?: return@queue + + val assignedRolesFromCategory = member.roles.count { role: Role? -> + if (role == null) { + return@count false + } + + roleCategory.roles.contains(role.idLong) + } + + if (assignedRolesFromCategory >= roleCategory.allowedRoles) { + reply.sendMessage("You already have the max amount of roles from this category.").queue() + return@queue + } + + + guild.getRoleById(roleId)?.let { + guild.addRoleToMember(event.user, it) + .reason("User request role through \"/role assign\" slash command.").queue { + reply.sendMessage("You're role was assigned.").queue() + } + } + } + + } + } + + override fun onButtonInteraction(event: ButtonInteractionEvent) { + val guild = event.guild + val componentId = event.componentId + + if (!componentId.startsWith(MORE_BUTTON_ID_PREFIX) || guild == null) return + + val ephemeral = event.user.idLong != discordModBotConfig.ownerId + + event.deferReply(ephemeral).queue { reply -> + val metaData = componentId.removePrefix(MORE_BUTTON_ID_PREFIX).split("-") + + val categoryId = metaData[0].toLong() + val pageNumber = metaData[1].toInt() + 1 + + replyWithRoleMenu(reply, guild, categoryId, pageNumber) + } + + + } + + private fun replyWithRoleMenu( + reply: InteractionHook, + guild: Guild, + categoryId: Long, + page: Int + ) { + val chunkedRoles = getChunkedRoles(guild, categoryId) + + val roleSelectMenu = StringSelectMenu.create(ROLE_COMPONENT_ID) + + chunkedRoles[page].forEach { + roleSelectMenu.addOption(it.name, it.id) + } + + reply.sendMessage("Select the role you would like to get assigned.") + .addActionRow(roleSelectMenu.build()) + .addActionRow( + Button.primary("$MORE_BUTTON_ID_PREFIX$categoryId-$page", "More roles") + .withDisabled(chunkedRoles.size <= page + 1) + ) + .queue() + } + + private fun getChunkedRoles( + guild: Guild, + categoryId: Long + ) = iAmRolesService.getRoleIds(guild.idLong, categoryId) + .mapNotNull { guild.getRoleById(it) } + .chunked(StringSelectMenu.OPTIONS_MAX_AMOUNT) +} diff --git a/src/main/kotlin/be/duncanc/discordmodbot/data/entities/IAmRolesCategory.kt b/src/main/kotlin/be/duncanc/discordmodbot/data/entities/IAmRolesCategory.kt index 04bd6f62..c9a01f93 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/data/entities/IAmRolesCategory.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/data/entities/IAmRolesCategory.kt @@ -19,13 +19,11 @@ package be.duncanc.discordmodbot.data.entities import jakarta.persistence.* import jakarta.validation.constraints.NotNull import java.io.Serializable -import java.util.* @Entity @Table(name = "i_am_roles_categories") @IdClass(IAmRolesCategory.IAmRoleId::class) -data class IAmRolesCategory -constructor( +data class IAmRolesCategory( @Id @Column(updatable = false) val guildId: Long, @@ -46,7 +44,7 @@ constructor( @Column(nullable = false) @field:NotNull @CollectionTable(name = "i_am_roles_category_roles") - @ElementCollection + @ElementCollection(fetch = FetchType.EAGER) val roles: MutableSet = HashSet() ) { diff --git a/src/main/kotlin/be/duncanc/discordmodbot/data/repositories/jpa/IAmRolesRepository.kt b/src/main/kotlin/be/duncanc/discordmodbot/data/repositories/jpa/IAmRolesRepository.kt index f344655f..cbc5b832 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/data/repositories/jpa/IAmRolesRepository.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/data/repositories/jpa/IAmRolesRepository.kt @@ -25,4 +25,7 @@ import org.springframework.transaction.annotation.Transactional interface IAmRolesRepository : JpaRepository { @Transactional(readOnly = true) fun findByGuildId(guildId: Long): Iterable + + @Transactional(readOnly = true) + fun findByRolesContainsAndGuildId(roles: MutableSet, guildId: Long): Iterable } diff --git a/src/main/kotlin/be/duncanc/discordmodbot/data/services/IAmRolesService.kt b/src/main/kotlin/be/duncanc/discordmodbot/data/services/IAmRolesService.kt index 25c9312d..9acaa77c 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/data/services/IAmRolesService.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/data/services/IAmRolesService.kt @@ -21,7 +21,8 @@ import be.duncanc.discordmodbot.data.repositories.jpa.IAmRolesRepository import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.util.* + +private const val ENTITY_DOES_NOT_EXIST_MESSAGE = "The entity does not exist within the database." @Transactional(readOnly = true) @Service @@ -29,10 +30,6 @@ class IAmRolesService @Autowired constructor( private val iAmRolesRepository: IAmRolesRepository ) { - companion object { - private val illegalArgumentException = - IllegalArgumentException("The entity does not exist within the database.") - } fun getAllCategoriesForGuild(guildId: Long): List { return iAmRolesRepository.findByGuildId(guildId).toList() @@ -70,7 +67,7 @@ class IAmRolesService @Transactional fun changeCategoryName(guildId: Long, categoryId: Long, newName: String) { val iAmRolesCategory = iAmRolesRepository.findById(IAmRolesCategory.IAmRoleId(guildId, categoryId)) - .orElseThrow { illegalArgumentException } + .orElseThrow { IllegalArgumentException(ENTITY_DOES_NOT_EXIST_MESSAGE) } iAmRolesCategory.categoryName = newName iAmRolesRepository.save(iAmRolesCategory) } @@ -81,7 +78,7 @@ class IAmRolesService @Transactional fun addOrRemoveRole(guildId: Long, categoryId: Long, roleId: Long): Boolean { val iAmRolesCategory = iAmRolesRepository.findById(IAmRolesCategory.IAmRoleId(guildId, categoryId)) - .orElseThrow { illegalArgumentException } + .orElseThrow { IllegalArgumentException(ENTITY_DOES_NOT_EXIST_MESSAGE) } return if (iAmRolesCategory.roles.contains(roleId)) { iAmRolesCategory.roles.remove(roleId) false @@ -94,14 +91,19 @@ class IAmRolesService @Transactional fun changeAllowedRoles(guildId: Long, categoryId: Long, newAmount: Int) { val iAmRolesCategory = iAmRolesRepository.findById(IAmRolesCategory.IAmRoleId(guildId, categoryId)) - .orElseThrow { illegalArgumentException } + .orElseThrow { IllegalArgumentException(ENTITY_DOES_NOT_EXIST_MESSAGE) } iAmRolesCategory.allowedRoles = newAmount iAmRolesRepository.save(iAmRolesCategory) } fun getRoleIds(guildId: Long, categoryId: Long): Set { val iAmRolesCategory = iAmRolesRepository.findById(IAmRolesCategory.IAmRoleId(guildId, categoryId)) - .orElseThrow { illegalArgumentException } + .orElseThrow { IllegalArgumentException(ENTITY_DOES_NOT_EXIST_MESSAGE) } return HashSet(iAmRolesCategory.roles) } + + fun getCategoryByRoleId(guildId: Long, role: Long): IAmRolesCategory { + return iAmRolesRepository.findByRolesContainsAndGuildId(mutableSetOf(role), guildId).firstOrNull() + ?: throw IllegalArgumentException(ENTITY_DOES_NOT_EXIST_MESSAGE) + } } diff --git a/src/test/kotlin/be/duncanc/discordmodbot/bot/commands/CommandModuleTest.kt b/src/test/kotlin/be/duncanc/discordmodbot/bot/commands/SlashCommandModuleTest.kt similarity index 88% rename from src/test/kotlin/be/duncanc/discordmodbot/bot/commands/CommandModuleTest.kt rename to src/test/kotlin/be/duncanc/discordmodbot/bot/commands/SlashCommandModuleTest.kt index 8304c144..ed18d815 100644 --- a/src/test/kotlin/be/duncanc/discordmodbot/bot/commands/CommandModuleTest.kt +++ b/src/test/kotlin/be/duncanc/discordmodbot/bot/commands/SlashCommandModuleTest.kt @@ -1,7 +1,9 @@ package be.duncanc.discordmodbot.bot.commands import net.dv8tion.jda.api.JDA -import net.dv8tion.jda.api.entities.* +import net.dv8tion.jda.api.entities.Message +import net.dv8tion.jda.api.entities.SelfUser +import net.dv8tion.jda.api.entities.User import net.dv8tion.jda.api.entities.channel.ChannelType import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion import net.dv8tion.jda.api.events.message.MessageReceivedEvent @@ -16,9 +18,9 @@ import org.mockito.kotlin.* @ExtendWith(MockitoExtension::class) @TestInstance(Lifecycle.PER_CLASS) -internal open class CommandModuleTest : CommandModule(arrayOf("test"), null, null) { +internal open class SlashCommandModuleTest : CommandModule(arrayOf("test"), null, null) { - private lateinit var commandModule: CommandModuleTest + private lateinit var commandModule: SlashCommandModuleTest @BeforeAll fun `Spy ourselves`() { diff --git a/src/test/kotlin/be/duncanc/discordmodbot/bot/sequences/PlanUnmuteSequenceTest.kt b/src/test/kotlin/be/duncanc/discordmodbot/bot/sequences/PlanUnmuteSequenceTest.kt index f41ae153..8daa9b66 100644 --- a/src/test/kotlin/be/duncanc/discordmodbot/bot/sequences/PlanUnmuteSequenceTest.kt +++ b/src/test/kotlin/be/duncanc/discordmodbot/bot/sequences/PlanUnmuteSequenceTest.kt @@ -176,7 +176,7 @@ internal class PlanUnmuteSequenceTest { @SpringBootTest(classes = [PlanUnmuteCommand::class]) @ExtendWith(MockitoExtension::class) -internal class PlanUnmuteCommandTest { +internal class PlanUnmuteSlashCommandTest { @MockBean private lateinit var scheduledUnmuteService: ScheduledUnmuteService