From aecbc74f6c994f4b42d5dde9d8abab1b4901520c Mon Sep 17 00:00:00 2001 From: averen Date: Thu, 22 Jan 2026 10:52:44 -0700 Subject: [PATCH 01/10] auto role configuration data --- build.gradle.kts | 11 ++++++----- .../com/sandrabot/sandra/config/GuildConfig.kt | 16 ++++++++++++++++ .../com/sandrabot/sandra/config/MemberConfig.kt | 4 ++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index d880d035..3d8c2402 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -39,18 +39,19 @@ dependencies { implementation("io.ktor:ktor-serialization-kotlinx-json") implementation("club.minnced:jda-ktx:0.14.0") - implementation("net.dv8tion:JDA:6.2.1") { + implementation("net.dv8tion:JDA:6.3.0") { exclude(module = "opus-java") + exclude(module = "tink") } - implementation("ch.qos.logback:logback-classic:1.5.23") - implementation("io.sentry:sentry-logback:8.29.0") + implementation("ch.qos.logback:logback-classic:1.5.25") + implementation("io.sentry:sentry-logback:8.31.0") implementation("net.jodah:expiringmap:0.5.11") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") implementation("org.reflections:reflections:0.10.2") // TODO Migrate to new client connection API introduced in 7.2.0 - implementation("redis.clients:jedis:7.2.0") + implementation("redis.clients:jedis:7.2.1") runtimeOnly(kotlin("scripting-jsr223")) } diff --git a/src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.kt b/src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.kt index 4d24f096..db4be227 100644 --- a/src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.kt +++ b/src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.kt @@ -18,6 +18,8 @@ package com.sandrabot.sandra.config import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import net.dv8tion.jda.api.entities.Member +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel /** * Stores Sandra-specific properties and settings for guilds. @@ -32,6 +34,17 @@ class GuildConfig(override val id: Long) : Configuration() { @Serializable(with = ConfigMapTransformer::class) val members = mutableMapOf() + @SerialName("auto_roles") + var autoRolesEnabled: Boolean = true + @SerialName("save_roles") + var saveRolesEnabled: Boolean = true + @SerialName("delay_roles") + var delayDefaultRoles: Boolean = true + @SerialName("default_roles") + val defaultRoles = mutableSetOf() + @SerialName("bot_role") + var defaultBotRole: Long = 0L + @SerialName("experience") var experienceEnabled: Boolean = true @SerialName("compounds") @@ -53,6 +66,9 @@ class GuildConfig(override val id: Long) : Configuration() { fun getChannel(id: Long): ChannelConfig = channels.getOrPut(id) { ChannelConfig(id) } fun getMember(id: Long): MemberConfig = members.getOrPut(id) { MemberConfig(id) } + operator fun get(channel: MessageChannel) = channels.getOrPut(channel.idLong) { ChannelConfig(channel.idLong) } + operator fun get(member: Member) = members.getOrPut(member.idLong) { MemberConfig(member.idLong) } + override fun toString(): String = "GuildConfig:$id" } diff --git a/src/main/kotlin/com/sandrabot/sandra/config/MemberConfig.kt b/src/main/kotlin/com/sandrabot/sandra/config/MemberConfig.kt index 66c0fecd..2e50a069 100644 --- a/src/main/kotlin/com/sandrabot/sandra/config/MemberConfig.kt +++ b/src/main/kotlin/com/sandrabot/sandra/config/MemberConfig.kt @@ -16,6 +16,7 @@ package com.sandrabot.sandra.config +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** @@ -24,6 +25,9 @@ import kotlinx.serialization.Serializable @Serializable class MemberConfig(override val id: Long) : ExperienceConfig() { + @SerialName("saved_roles") + val savedRoles = mutableSetOf() + override fun toString(): String = "MemberConfig:$id" } From 5a51f16383fa0652136f122d28e8807094a83f1f Mon Sep 17 00:00:00 2001 From: averen Date: Thu, 22 Jan 2026 23:20:17 -0700 Subject: [PATCH 02/10] implement essential auto role behaviors --- .../kotlin/com/sandrabot/sandra/Sandra.kt | 10 +- .../sandra/listeners/GuildListener.kt | 158 ++++++++++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt diff --git a/src/main/kotlin/com/sandrabot/sandra/Sandra.kt b/src/main/kotlin/com/sandrabot/sandra/Sandra.kt index 8b4b1af0..4b19a1d3 100644 --- a/src/main/kotlin/com/sandrabot/sandra/Sandra.kt +++ b/src/main/kotlin/com/sandrabot/sandra/Sandra.kt @@ -18,6 +18,7 @@ package com.sandrabot.sandra import com.sandrabot.sandra.api.ServerController import com.sandrabot.sandra.config.SandraConfig +import com.sandrabot.sandra.listeners.GuildListener import com.sandrabot.sandra.listeners.InteractionListener import com.sandrabot.sandra.listeners.MessageListener import com.sandrabot.sandra.listeners.ReadyListener @@ -28,6 +29,7 @@ import net.dv8tion.jda.api.OnlineStatus import net.dv8tion.jda.api.requests.GatewayIntent import net.dv8tion.jda.api.sharding.DefaultShardManagerBuilder import net.dv8tion.jda.api.sharding.ShardManager +import net.dv8tion.jda.api.utils.MemberCachePolicy /** * This class is the heart and soul of the bot. It provides @@ -50,11 +52,17 @@ class Sandra(val settings: SandraConfig, val redis: RedisManager) { val shards: ShardManager = DefaultShardManagerBuilder.createDefault(settings.secrets.token).apply { enableIntents(GatewayIntent.GUILD_MEMBERS, GatewayIntent.MESSAGE_CONTENT) setEventManagerProvider { CoroutineEventManager() } + setMemberCachePolicy(MemberCachePolicy.ALL) setShardsTotal(settings.shardsTotal) setStatus(OnlineStatus.IDLE) setBulkDeleteSplittingEnabled(false) setEnableShutdownHook(false) - addEventListeners(InteractionListener(this@Sandra), MessageListener(this@Sandra), ReadyListener(this@Sandra)) + addEventListeners( + GuildListener(this@Sandra), + InteractionListener(this@Sandra), + MessageListener(this@Sandra), + ReadyListener(this@Sandra), + ) }.build() } diff --git a/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt new file mode 100644 index 00000000..c9a776e1 --- /dev/null +++ b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt @@ -0,0 +1,158 @@ +/* + * Copyright 2017-2026 Avery Carroll and Logan Devecka + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.sandrabot.sandra.listeners + +import com.sandrabot.sandra.Sandra +import com.sandrabot.sandra.config.GuildConfig +import dev.minn.jda.ktx.events.CoroutineEventListener +import net.dv8tion.jda.api.Permission +import net.dv8tion.jda.api.entities.Role +import net.dv8tion.jda.api.events.GenericEvent +import net.dv8tion.jda.api.events.guild.GuildJoinEvent +import net.dv8tion.jda.api.events.guild.GuildLeaveEvent +import net.dv8tion.jda.api.events.guild.member.GenericGuildMemberEvent +import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent +import net.dv8tion.jda.api.events.guild.member.GuildMemberRemoveEvent +import net.dv8tion.jda.api.events.guild.member.update.GuildMemberUpdatePendingEvent +import net.dv8tion.jda.api.exceptions.ErrorHandler +import net.dv8tion.jda.api.requests.ErrorResponse +import org.slf4j.LoggerFactory + +/** + * Event listener that processes join and leave events for guilds and their members. + */ +class GuildListener(private val sandra: Sandra) : CoroutineEventListener { + + override suspend fun onEvent(event: GenericEvent) { + when (event) { + is GuildJoinEvent -> onGuildJoin(event) + is GuildLeaveEvent -> onGuildLeave(event) + + is GuildMemberJoinEvent -> onMemberJoin(event) + is GuildMemberRemoveEvent -> onMemberRemove(event) + is GuildMemberUpdatePendingEvent -> onMemberUpdatePending(event) + } + } + + private fun onGuildJoin(event: GuildJoinEvent) { + LOGGER.info("Joined guild: ${event.guild.name} [${event.guild.id}] | Owned by ${event.guild.ownerId}") + } + + private fun onGuildLeave(event: GuildLeaveEvent) { + LOGGER.info("Left guild: ${event.guild.name} [${event.guild.id}] | Owned by ${event.guild.ownerId}") + } + + private fun onMemberJoin(event: GuildMemberJoinEvent) { + val guildConfig = sandra.config[event.guild] + val selfMember = event.guild.selfMember + + // FEATURE: Default Bot Role + if (event.user.isBot) { + if (guildConfig.autoRolesEnabled && guildConfig.defaultBotRole != 0L) { + if (!selfMember.hasPermission(Permission.MANAGE_ROLES) || !selfMember.canInteract(event.member)) return + val defaultBotRole = event.guild.getRoleById(guildConfig.defaultBotRole) ?: run { + // TODO role data is stale, schedule data cleanup + return + } + if (selfMember.canInteract(defaultBotRole)) { + // TODO localised reasons for auditable actions + event.guild.addRoleToMember(event.member, defaultBotRole).reason("default bot role").queue() + LOGGER.debug("Default Bot Role: Giving {} to {}", defaultBotRole, event.member) + } + } + // stop here for bot accounts, nothing else applies to them + return + } + + // TODO Feature: Auto Kick + + // TODO Feature: Welcome Messages + + // FEATURE: Auto Roles + handleAutoRoles(event, guildConfig) + } + + private fun handleAutoRoles(event: GenericGuildMemberEvent, guildConfig: GuildConfig) { + if (!guildConfig.autoRolesEnabled || !event.guild.selfMember.hasPermission(Permission.MANAGE_ROLES)) return + // a single role may be given for different reasons in certain situations + val roleMap = mutableMapOf>() + val memberConfig = guildConfig[event.member] + + // FEATURE: Delayed Default Roles + if (guildConfig.delayDefaultRoles && event.member.isPending) { + if (guildConfig.saveRolesEnabled && memberConfig.savedRoles.isNotEmpty()) { + // continue if roles should be restored, bypass member verification for known members + } else return + } + + // FEATURE: Default Roles + val defaultRoles = guildConfig.defaultRoles.mapNotNull { roleId -> event.guild.getRoleById(roleId) } + if (guildConfig.defaultRoles.size != defaultRoles.size) { + // TODO role data is stale, schedule data cleanup + } + // TODO localised reasons for auditable actions + defaultRoles.forEach { role -> roleMap.getOrPut(role) { mutableSetOf() }.add("default roles") } + LOGGER.debug("Auto Role: Adding {} default roles for {}: {}", defaultRoles.size, event.member, defaultRoles) + + // FEATURE: Save Roles + if (guildConfig.saveRolesEnabled && memberConfig.savedRoles.isNotEmpty()) { + val savedRoles = memberConfig.savedRoles.mapNotNull { roleId -> event.guild.getRoleById(roleId) } + if (memberConfig.savedRoles.size != savedRoles.size) { + // TODO role data is stale, schedule data cleanup + } + memberConfig.savedRoles.clear() + // TODO localised reasons for auditable actions + savedRoles.forEach { role -> roleMap.getOrPut(role) { mutableSetOf() }.add("previously saved roles") } + LOGGER.debug("Auto Role: Adding {} saved roles for {}: {}", savedRoles.size, event.member, savedRoles) + } + + // TODO Feature: Sync Ranks + + // TODO optionally block certain roles from being reapplied automatically (mods, admins) + + // ensure that we are able to interact with the given roles + val reachableMap = roleMap.filterKeys { role -> event.guild.selfMember.canInteract(role) } + // do nothing if none of the roles are within reach + if (reachableMap.isEmpty()) return + + val reason = reachableMap.flatMap { (_, reasons) -> reasons }.toSet().sorted().joinToString() + LOGGER.debug("Auto Role: Modifying {} roles for {} [{}]", reachableMap.size, event.member, reachableMap.keys) + event.guild.modifyMemberRoles(event.member, reachableMap.keys).reason(reason).queue(null, HANDLER) + } + + private fun onMemberRemove(event: GuildMemberRemoveEvent) { + val guildConfig = sandra.config[event.guild] + + // FEATURE: Save Roles + if (guildConfig.autoRolesEnabled && guildConfig.saveRolesEnabled) { + // attempt to store the member's current roles, this information is already lost if the member wasn't cached + val member = event.member ?: return + guildConfig[member].savedRoles += member.roles.map { it.idLong } + } + } + + private fun onMemberUpdatePending(event: GuildMemberUpdatePendingEvent) { + // FEATURE: Delayed Default Roles + handleAutoRoles(event, sandra.config[event.guild]) + } + + private companion object { + private val HANDLER = ErrorHandler().ignore(ErrorResponse.UNKNOWN_MEMBER, ErrorResponse.MISSING_PERMISSIONS) + private val LOGGER = LoggerFactory.getLogger(GuildListener::class.java) + } + +} From cea3b2a4321fd84ef16a2a94473822bf0e7f653c Mon Sep 17 00:00:00 2001 From: averen Date: Sun, 25 Jan 2026 04:30:26 -0700 Subject: [PATCH 03/10] don't save bot's roles --- .../sandra/listeners/GuildListener.kt | 82 +++++++++++-------- 1 file changed, 48 insertions(+), 34 deletions(-) diff --git a/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt index c9a776e1..3bafb5e6 100644 --- a/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt +++ b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt @@ -49,33 +49,38 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { } private fun onGuildJoin(event: GuildJoinEvent) { - LOGGER.info("Joined guild: ${event.guild.name} [${event.guild.id}] | Owned by ${event.guild.ownerId}") + LOGGER.info("Added to guild: ${event.guild.name} [${event.guild.id}] | Owned by ${event.guild.ownerId}") } private fun onGuildLeave(event: GuildLeaveEvent) { - LOGGER.info("Left guild: ${event.guild.name} [${event.guild.id}] | Owned by ${event.guild.ownerId}") + LOGGER.info("Removed from guild: ${event.guild.name} [${event.guild.id}] | Owned by ${event.guild.ownerId}") } private fun onMemberJoin(event: GuildMemberJoinEvent) { val guildConfig = sandra.config[event.guild] val selfMember = event.guild.selfMember - // FEATURE: Default Bot Role if (event.user.isBot) { + + // FEATURE: Default Bot Role if (guildConfig.autoRolesEnabled && guildConfig.defaultBotRole != 0L) { - if (!selfMember.hasPermission(Permission.MANAGE_ROLES) || !selfMember.canInteract(event.member)) return - val defaultBotRole = event.guild.getRoleById(guildConfig.defaultBotRole) ?: run { + // there's nothing we can do if we don't have this permission + if (!selfMember.hasPermission(Permission.MANAGE_ROLES)) return + val botRole = event.guild.getRoleById(guildConfig.defaultBotRole) ?: run { // TODO role data is stale, schedule data cleanup return } - if (selfMember.canInteract(defaultBotRole)) { - // TODO localised reasons for auditable actions - event.guild.addRoleToMember(event.member, defaultBotRole).reason("default bot role").queue() - LOGGER.debug("Default Bot Role: Giving {} to {}", defaultBotRole, event.member) + // verify that we can interact with the member and their new role + if (selfMember.canInteract(event.member) && selfMember.canInteract(botRole)) { + // TODO localized reasons for auditable actions + event.guild.addRoleToMember(event.member, botRole).reason("default bot role").queue(null, HANDLER) + LOGGER.debug("Default Bot Role: Giving role [{}] to {}", botRole.id, event.member) } } + // stop here for bot accounts, nothing else applies to them return + } // TODO Feature: Auto Kick @@ -83,32 +88,37 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { // TODO Feature: Welcome Messages // FEATURE: Auto Roles - handleAutoRoles(event, guildConfig) + if (guildConfig.autoRolesEnabled) handleAutoRoles(event, guildConfig) } private fun handleAutoRoles(event: GenericGuildMemberEvent, guildConfig: GuildConfig) { - if (!guildConfig.autoRolesEnabled || !event.guild.selfMember.hasPermission(Permission.MANAGE_ROLES)) return - // a single role may be given for different reasons in certain situations + val selfMember = event.guild.selfMember + + // there's nothing we can do if we don't have this permission + if (!selfMember.hasPermission(Permission.MANAGE_ROLES)) return + // a single role may be given for multiple reasons in certain situations val roleMap = mutableMapOf>() val memberConfig = guildConfig[event.member] // FEATURE: Delayed Default Roles if (guildConfig.delayDefaultRoles && event.member.isPending) { - if (guildConfig.saveRolesEnabled && memberConfig.savedRoles.isNotEmpty()) { - // continue if roles should be restored, bypass member verification for known members - } else return + // unless the user has previous roles, they must finish member onboarding first + if (!guildConfig.saveRolesEnabled || memberConfig.savedRoles.isEmpty()) return + LOGGER.debug("Delayed Default Roles: Bypassing onboarding for member with previous roles {}", event.member) } // FEATURE: Default Roles - val defaultRoles = guildConfig.defaultRoles.mapNotNull { roleId -> event.guild.getRoleById(roleId) } - if (guildConfig.defaultRoles.size != defaultRoles.size) { - // TODO role data is stale, schedule data cleanup + if (guildConfig.defaultRoles.isNotEmpty()) { + val defaultRoles = guildConfig.defaultRoles.mapNotNull { roleId -> event.guild.getRoleById(roleId) } + if (guildConfig.defaultRoles.size != defaultRoles.size) { + // TODO role data is stale, schedule data cleanup + } + // TODO localised reasons for auditable actions + defaultRoles.forEach { role -> roleMap.getOrPut(role) { mutableSetOf() }.add("default roles") } + LOGGER.debug("Default Roles: Adding entries {} for {}", defaultRoles.map { it.id }, event.member) } - // TODO localised reasons for auditable actions - defaultRoles.forEach { role -> roleMap.getOrPut(role) { mutableSetOf() }.add("default roles") } - LOGGER.debug("Auto Role: Adding {} default roles for {}: {}", defaultRoles.size, event.member, defaultRoles) - // FEATURE: Save Roles + // FEATURE: Saved Roles if (guildConfig.saveRolesEnabled && memberConfig.savedRoles.isNotEmpty()) { val savedRoles = memberConfig.savedRoles.mapNotNull { roleId -> event.guild.getRoleById(roleId) } if (memberConfig.savedRoles.size != savedRoles.size) { @@ -117,21 +127,20 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { memberConfig.savedRoles.clear() // TODO localised reasons for auditable actions savedRoles.forEach { role -> roleMap.getOrPut(role) { mutableSetOf() }.add("previously saved roles") } - LOGGER.debug("Auto Role: Adding {} saved roles for {}: {}", savedRoles.size, event.member, savedRoles) + LOGGER.debug("Saved Roles: Adding entries {} for {}", savedRoles.map { it.id }, event.member) } // TODO Feature: Sync Ranks // TODO optionally block certain roles from being reapplied automatically (mods, admins) - // ensure that we are able to interact with the given roles - val reachableMap = roleMap.filterKeys { role -> event.guild.selfMember.canInteract(role) } - // do nothing if none of the roles are within reach - if (reachableMap.isEmpty()) return + // verify that we are able to interact with the given roles + val reachableMap = roleMap.filterKeys { role -> + selfMember.canInteract(role) + }.takeUnless { it.isEmpty() } ?: return - val reason = reachableMap.flatMap { (_, reasons) -> reasons }.toSet().sorted().joinToString() - LOGGER.debug("Auto Role: Modifying {} roles for {} [{}]", reachableMap.size, event.member, reachableMap.keys) - event.guild.modifyMemberRoles(event.member, reachableMap.keys).reason(reason).queue(null, HANDLER) + val distinctReasons = reachableMap.flatMap { (_, reasons) -> reasons }.toSet().sorted().joinToString() + event.guild.modifyMemberRoles(event.member, reachableMap.keys).reason(distinctReasons).queue(null, HANDLER) } private fun onMemberRemove(event: GuildMemberRemoveEvent) { @@ -139,15 +148,20 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { // FEATURE: Save Roles if (guildConfig.autoRolesEnabled && guildConfig.saveRolesEnabled) { - // attempt to store the member's current roles, this information is already lost if the member wasn't cached - val member = event.member ?: return - guildConfig[member].savedRoles += member.roles.map { it.idLong } + // attempt to store the member's current roles + // this information is already lost if the member wasn't cached + // discord does not provide the member meta-data when a remove event is dispatched + val member = event.member?.takeUnless { it.user.isBot } ?: return + val roles = member.roles.takeUnless { it.isEmpty() } ?: return + guildConfig[member].savedRoles += roles.map { it.idLong } } } private fun onMemberUpdatePending(event: GuildMemberUpdatePendingEvent) { + val guildConfig = sandra.config[event.guild] + // FEATURE: Delayed Default Roles - handleAutoRoles(event, sandra.config[event.guild]) + if (guildConfig.autoRolesEnabled && guildConfig.delayDefaultRoles) handleAutoRoles(event, guildConfig) } private companion object { From 3c6d754ec3ac32ab5c8d82be92ac6740c378e44d Mon Sep 17 00:00:00 2001 From: averen Date: Sun, 25 Jan 2026 13:35:05 -0700 Subject: [PATCH 04/10] localized reasons --- .../sandrabot/sandra/listeners/GuildListener.kt | 16 +++++++++------- src/main/resources/content/english.json | 5 +++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt index 3bafb5e6..f6ab5a39 100644 --- a/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt +++ b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt @@ -18,6 +18,7 @@ package com.sandrabot.sandra.listeners import com.sandrabot.sandra.Sandra import com.sandrabot.sandra.config.GuildConfig +import com.sandrabot.sandra.constants.ContentStore import dev.minn.jda.ktx.events.CoroutineEventListener import net.dv8tion.jda.api.Permission import net.dv8tion.jda.api.entities.Role @@ -72,8 +73,8 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { } // verify that we can interact with the member and their new role if (selfMember.canInteract(event.member) && selfMember.canInteract(botRole)) { - // TODO localized reasons for auditable actions - event.guild.addRoleToMember(event.member, botRole).reason("default bot role").queue(null, HANDLER) + val reason = ContentStore[event.guild.locale, "core.reasons.bot_role"] + event.guild.addRoleToMember(event.member, botRole).reason(reason).queue(null, HANDLER) LOGGER.debug("Default Bot Role: Giving role [{}] to {}", botRole.id, event.member) } } @@ -113,8 +114,7 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { if (guildConfig.defaultRoles.size != defaultRoles.size) { // TODO role data is stale, schedule data cleanup } - // TODO localised reasons for auditable actions - defaultRoles.forEach { role -> roleMap.getOrPut(role) { mutableSetOf() }.add("default roles") } + defaultRoles.forEach { role -> roleMap.getOrPut(role) { mutableSetOf() }.add("default_roles") } LOGGER.debug("Default Roles: Adding entries {} for {}", defaultRoles.map { it.id }, event.member) } @@ -125,8 +125,7 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { // TODO role data is stale, schedule data cleanup } memberConfig.savedRoles.clear() - // TODO localised reasons for auditable actions - savedRoles.forEach { role -> roleMap.getOrPut(role) { mutableSetOf() }.add("previously saved roles") } + savedRoles.forEach { role -> roleMap.getOrPut(role) { mutableSetOf() }.add("saved_roles") } LOGGER.debug("Saved Roles: Adding entries {} for {}", savedRoles.map { it.id }, event.member) } @@ -139,8 +138,11 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { selfMember.canInteract(role) }.takeUnless { it.isEmpty() } ?: return - val distinctReasons = reachableMap.flatMap { (_, reasons) -> reasons }.toSet().sorted().joinToString() + val distinctReasons = reachableMap.values.asSequence().flatten().distinct().map { + ContentStore[event.guild.locale, "core.reasons.$it"] + }.sorted().joinToString() event.guild.modifyMemberRoles(event.member, reachableMap.keys).reason(distinctReasons).queue(null, HANDLER) + LOGGER.debug("Auto Role: Modifying {} roles for {}", reachableMap.size, event.member) } private fun onMemberRemove(event: GuildMemberRemoveEvent) { diff --git a/src/main/resources/content/english.json b/src/main/resources/content/english.json index 7d87f7a5..94062fe4 100644 --- a/src/main/resources/content/english.json +++ b/src/main/resources/content/english.json @@ -61,6 +61,11 @@ "manage_roles": "manage roles", "manage_permissions": "manage permissions", "moderate_members": "timeout members" + }, + "reasons": { + "bot_role": "default bot role", + "default_roles": "default roles", + "saved_roles": "saved roles" } }, "commands": { From b324c1107136fa24860a698a5bb1f3d3fb164e63 Mon Sep 17 00:00:00 2001 From: averen Date: Sun, 25 Jan 2026 14:02:30 -0700 Subject: [PATCH 05/10] clean role data whenever there's a discrepancy --- .../sandra/listeners/GuildListener.kt | 13 ++++----- .../com/sandrabot/sandra/utils/DataHelpers.kt | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/com/sandrabot/sandra/utils/DataHelpers.kt diff --git a/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt index f6ab5a39..5e6603a9 100644 --- a/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt +++ b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt @@ -19,6 +19,7 @@ package com.sandrabot.sandra.listeners import com.sandrabot.sandra.Sandra import com.sandrabot.sandra.config.GuildConfig import com.sandrabot.sandra.constants.ContentStore +import com.sandrabot.sandra.utils.cleanRoleData import dev.minn.jda.ktx.events.CoroutineEventListener import net.dv8tion.jda.api.Permission import net.dv8tion.jda.api.entities.Role @@ -68,10 +69,10 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { // there's nothing we can do if we don't have this permission if (!selfMember.hasPermission(Permission.MANAGE_ROLES)) return val botRole = event.guild.getRoleById(guildConfig.defaultBotRole) ?: run { - // TODO role data is stale, schedule data cleanup + guildConfig.cleanRoleData(event.guild) return } - // verify that we can interact with the member and their new role + // verify that we can interact with the bot and their new role if (selfMember.canInteract(event.member) && selfMember.canInteract(botRole)) { val reason = ContentStore[event.guild.locale, "core.reasons.bot_role"] event.guild.addRoleToMember(event.member, botRole).reason(reason).queue(null, HANDLER) @@ -111,9 +112,7 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { // FEATURE: Default Roles if (guildConfig.defaultRoles.isNotEmpty()) { val defaultRoles = guildConfig.defaultRoles.mapNotNull { roleId -> event.guild.getRoleById(roleId) } - if (guildConfig.defaultRoles.size != defaultRoles.size) { - // TODO role data is stale, schedule data cleanup - } + if (guildConfig.defaultRoles.size != defaultRoles.size) guildConfig.cleanRoleData(event.guild) defaultRoles.forEach { role -> roleMap.getOrPut(role) { mutableSetOf() }.add("default_roles") } LOGGER.debug("Default Roles: Adding entries {} for {}", defaultRoles.map { it.id }, event.member) } @@ -121,9 +120,7 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { // FEATURE: Saved Roles if (guildConfig.saveRolesEnabled && memberConfig.savedRoles.isNotEmpty()) { val savedRoles = memberConfig.savedRoles.mapNotNull { roleId -> event.guild.getRoleById(roleId) } - if (memberConfig.savedRoles.size != savedRoles.size) { - // TODO role data is stale, schedule data cleanup - } + if (memberConfig.savedRoles.size != savedRoles.size) guildConfig.cleanRoleData(event.guild) memberConfig.savedRoles.clear() savedRoles.forEach { role -> roleMap.getOrPut(role) { mutableSetOf() }.add("saved_roles") } LOGGER.debug("Saved Roles: Adding entries {} for {}", savedRoles.map { it.id }, event.member) diff --git a/src/main/kotlin/com/sandrabot/sandra/utils/DataHelpers.kt b/src/main/kotlin/com/sandrabot/sandra/utils/DataHelpers.kt new file mode 100644 index 00000000..0180a284 --- /dev/null +++ b/src/main/kotlin/com/sandrabot/sandra/utils/DataHelpers.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2017-2024 Avery Carroll and Logan Devecka + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.sandrabot.sandra.utils + +import com.sandrabot.sandra.config.GuildConfig +import net.dv8tion.jda.api.entities.Guild + +fun GuildConfig.cleanRoleData(guild: Guild) { + val currentRoles = guild.roleCache.map { it.idLong } + if (defaultRoles.isNotEmpty()) defaultRoles.removeIf { it !in currentRoles } + if (defaultBotRole != 0L && defaultBotRole !in currentRoles) defaultBotRole = 0L + members.values.forEach { config -> config.savedRoles.removeIf { it !in currentRoles } } +} From 93295c0a506a97c2906f498140719fe1e1da6694 Mon Sep 17 00:00:00 2001 From: averen Date: Sun, 25 Jan 2026 14:41:16 -0700 Subject: [PATCH 06/10] allow saved roles to be revoked --- .../com/sandrabot/sandra/config/GuildConfig.kt | 2 ++ .../com/sandrabot/sandra/listeners/GuildListener.kt | 12 ++++++++---- .../kotlin/com/sandrabot/sandra/utils/DataHelpers.kt | 1 + 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.kt b/src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.kt index db4be227..ba46dd09 100644 --- a/src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.kt +++ b/src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.kt @@ -42,6 +42,8 @@ class GuildConfig(override val id: Long) : Configuration() { var delayDefaultRoles: Boolean = true @SerialName("default_roles") val defaultRoles = mutableSetOf() + @SerialName("revoked_roles") + val revokedRoles = mutableSetOf() @SerialName("bot_role") var defaultBotRole: Long = 0L diff --git a/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt index 5e6603a9..6be6d00f 100644 --- a/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt +++ b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt @@ -122,14 +122,18 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { val savedRoles = memberConfig.savedRoles.mapNotNull { roleId -> event.guild.getRoleById(roleId) } if (memberConfig.savedRoles.size != savedRoles.size) guildConfig.cleanRoleData(event.guild) memberConfig.savedRoles.clear() - savedRoles.forEach { role -> roleMap.getOrPut(role) { mutableSetOf() }.add("saved_roles") } - LOGGER.debug("Saved Roles: Adding entries {} for {}", savedRoles.map { it.id }, event.member) + + // FEATURE: Revoke Saved Roles + val revokedRoles = guildConfig.revokedRoles.mapNotNull { roleId -> event.guild.getRoleById(roleId) } + if (guildConfig.revokedRoles.size != revokedRoles.size) guildConfig.cleanRoleData(event.guild) + val allowedRoles = if (revokedRoles.isEmpty()) savedRoles else savedRoles - revokedRoles.toSet() + + allowedRoles.forEach { role -> roleMap.getOrPut(role) { mutableSetOf() }.add("saved_roles") } + LOGGER.debug("Saved Roles: Adding entries {} for {}", allowedRoles.map { it.id }, event.member) } // TODO Feature: Sync Ranks - // TODO optionally block certain roles from being reapplied automatically (mods, admins) - // verify that we are able to interact with the given roles val reachableMap = roleMap.filterKeys { role -> selfMember.canInteract(role) diff --git a/src/main/kotlin/com/sandrabot/sandra/utils/DataHelpers.kt b/src/main/kotlin/com/sandrabot/sandra/utils/DataHelpers.kt index 0180a284..f5c3f75b 100644 --- a/src/main/kotlin/com/sandrabot/sandra/utils/DataHelpers.kt +++ b/src/main/kotlin/com/sandrabot/sandra/utils/DataHelpers.kt @@ -22,6 +22,7 @@ import net.dv8tion.jda.api.entities.Guild fun GuildConfig.cleanRoleData(guild: Guild) { val currentRoles = guild.roleCache.map { it.idLong } if (defaultRoles.isNotEmpty()) defaultRoles.removeIf { it !in currentRoles } + if (revokedRoles.isNotEmpty()) revokedRoles.removeIf { it !in currentRoles } if (defaultBotRole != 0L && defaultBotRole !in currentRoles) defaultBotRole = 0L members.values.forEach { config -> config.savedRoles.removeIf { it !in currentRoles } } } From f1082120344f74184042225f50b83ca70e496b0c Mon Sep 17 00:00:00 2001 From: averen Date: Sun, 25 Jan 2026 15:16:04 -0700 Subject: [PATCH 07/10] add Configuration#toData for debugging --- .../kotlin/com/sandrabot/sandra/config/Serializers.kt | 4 ++-- .../kotlin/com/sandrabot/sandra/utils/DataHelpers.kt | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/sandrabot/sandra/config/Serializers.kt b/src/main/kotlin/com/sandrabot/sandra/config/Serializers.kt index 128b28a9..dc898d99 100644 --- a/src/main/kotlin/com/sandrabot/sandra/config/Serializers.kt +++ b/src/main/kotlin/com/sandrabot/sandra/config/Serializers.kt @@ -25,11 +25,11 @@ object ConfigMapTransformer : JsonTransformingSerializer(Configuration::class) { diff --git a/src/main/kotlin/com/sandrabot/sandra/utils/DataHelpers.kt b/src/main/kotlin/com/sandrabot/sandra/utils/DataHelpers.kt index f5c3f75b..564d2ddb 100644 --- a/src/main/kotlin/com/sandrabot/sandra/utils/DataHelpers.kt +++ b/src/main/kotlin/com/sandrabot/sandra/utils/DataHelpers.kt @@ -16,9 +16,19 @@ package com.sandrabot.sandra.utils +import com.sandrabot.sandra.config.Configuration import com.sandrabot.sandra.config.GuildConfig +import kotlinx.serialization.json.Json import net.dv8tion.jda.api.entities.Guild +private val json = Json { + encodeDefaults = true + prettyPrint = true +} + +@Suppress("unused") +fun Configuration.toData() = json.encodeToString(this) + fun GuildConfig.cleanRoleData(guild: Guild) { val currentRoles = guild.roleCache.map { it.idLong } if (defaultRoles.isNotEmpty()) defaultRoles.removeIf { it !in currentRoles } From 568a92f88f52556a3d594f7031ca2dd05721bd46 Mon Sep 17 00:00:00 2001 From: averen Date: Sun, 25 Jan 2026 15:32:17 -0700 Subject: [PATCH 08/10] don't remove the member's existing roles --- .../kotlin/com/sandrabot/sandra/listeners/GuildListener.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt index 6be6d00f..12a8387c 100644 --- a/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt +++ b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt @@ -142,7 +142,9 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { val distinctReasons = reachableMap.values.asSequence().flatten().distinct().map { ContentStore[event.guild.locale, "core.reasons.$it"] }.sorted().joinToString() - event.guild.modifyMemberRoles(event.member, reachableMap.keys).reason(distinctReasons).queue(null, HANDLER) + + val roles = event.member.roles + reachableMap.keys + event.guild.modifyMemberRoles(event.member, roles).reason(distinctReasons).queue(null, HANDLER) LOGGER.debug("Auto Role: Modifying {} roles for {}", reachableMap.size, event.member) } From c98b63c11bb704f836bdacdd10fae7b620ab4262 Mon Sep 17 00:00:00 2001 From: averen Date: Sun, 25 Jan 2026 15:32:31 -0700 Subject: [PATCH 09/10] don't bypass onboarding for anyone --- .../kotlin/com/sandrabot/sandra/listeners/GuildListener.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt index 12a8387c..c06a1eed 100644 --- a/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt +++ b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt @@ -103,11 +103,7 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { val memberConfig = guildConfig[event.member] // FEATURE: Delayed Default Roles - if (guildConfig.delayDefaultRoles && event.member.isPending) { - // unless the user has previous roles, they must finish member onboarding first - if (!guildConfig.saveRolesEnabled || memberConfig.savedRoles.isEmpty()) return - LOGGER.debug("Delayed Default Roles: Bypassing onboarding for member with previous roles {}", event.member) - } + if (guildConfig.delayDefaultRoles && event.member.isPending) return // FEATURE: Default Roles if (guildConfig.defaultRoles.isNotEmpty()) { From 3fb6efc2d7ef7d54e7daaf3d7b47eb174cb98939 Mon Sep 17 00:00:00 2001 From: averen Date: Wed, 28 Jan 2026 19:38:58 -0700 Subject: [PATCH 10/10] some final tweaks --- .../kotlin/com/sandrabot/sandra/config/GuildConfig.kt | 4 ++-- .../com/sandrabot/sandra/listeners/GuildListener.kt | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.kt b/src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.kt index 12371bb9..434607a8 100644 --- a/src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.kt +++ b/src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.kt @@ -19,7 +19,7 @@ package com.sandrabot.sandra.config import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import net.dv8tion.jda.api.entities.Member -import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel +import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel /** * Stores Sandra-specific properties and settings for guilds. @@ -68,7 +68,7 @@ data class GuildConfig(override val id: Long) : Configuration() { fun getChannel(id: Long): ChannelConfig = channels.getOrPut(id) { ChannelConfig(id) } fun getMember(id: Long): MemberConfig = members.getOrPut(id) { MemberConfig(id) } - operator fun get(channel: MessageChannel) = channels.getOrPut(channel.idLong) { ChannelConfig(channel.idLong) } + operator fun get(channel: GuildMessageChannel) = channels.getOrPut(channel.idLong) { ChannelConfig(channel.idLong) } operator fun get(member: Member) = members.getOrPut(member.idLong) { MemberConfig(member.idLong) } override fun toString(): String = "GuildConfig:$id" diff --git a/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt index c06a1eed..cb84dbe8 100644 --- a/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt +++ b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt @@ -120,9 +120,12 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { memberConfig.savedRoles.clear() // FEATURE: Revoke Saved Roles - val revokedRoles = guildConfig.revokedRoles.mapNotNull { roleId -> event.guild.getRoleById(roleId) } - if (guildConfig.revokedRoles.size != revokedRoles.size) guildConfig.cleanRoleData(event.guild) - val allowedRoles = if (revokedRoles.isEmpty()) savedRoles else savedRoles - revokedRoles.toSet() + val allowedRoles = if (guildConfig.revokedRoles.isNotEmpty()) { + val revokedRoles = guildConfig.revokedRoles.mapNotNull { roleId -> event.guild.getRoleById(roleId) } + if (guildConfig.revokedRoles.size != revokedRoles.size) guildConfig.cleanRoleData(event.guild) + LOGGER.debug("Revoked Roles: Removing entries {} for {}", revokedRoles.map { it.id }, event.member) + savedRoles - revokedRoles.toSet() + } else savedRoles allowedRoles.forEach { role -> roleMap.getOrPut(role) { mutableSetOf() }.add("saved_roles") } LOGGER.debug("Saved Roles: Adding entries {} for {}", allowedRoles.map { it.id }, event.member)