diff --git a/build.gradle.kts b/build.gradle.kts index d880d03..3d8c240 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/Sandra.kt b/src/main/kotlin/com/sandrabot/sandra/Sandra.kt index 8b4b1af..4b19a1d 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/config/GuildConfig.kt b/src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.kt index 680946f..434607a 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.GuildMessageChannel /** * Stores Sandra-specific properties and settings for guilds. @@ -32,6 +34,19 @@ data 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("revoked_roles") + val revokedRoles = mutableSetOf() + @SerialName("bot_role") + var defaultBotRole: Long = 0L + @SerialName("experience") var experienceEnabled: Boolean = true @SerialName("compounds") @@ -53,6 +68,9 @@ 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: 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/config/MemberConfig.kt b/src/main/kotlin/com/sandrabot/sandra/config/MemberConfig.kt index 3521e80..5c3c80f 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 data class MemberConfig(override val id: Long) : ExperienceConfig() { + @SerialName("saved_roles") + val savedRoles = mutableSetOf() + override fun toString(): String = "MemberConfig:$id" } diff --git a/src/main/kotlin/com/sandrabot/sandra/config/Serializers.kt b/src/main/kotlin/com/sandrabot/sandra/config/Serializers.kt index 128b28a..dc898d9 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/listeners/GuildListener.kt b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt new file mode 100644 index 0000000..cb84dbe --- /dev/null +++ b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt @@ -0,0 +1,176 @@ +/* + * 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 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 +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("Added to guild: ${event.guild.name} [${event.guild.id}] | Owned by ${event.guild.ownerId}") + } + + private fun onGuildLeave(event: GuildLeaveEvent) { + 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 + + if (event.user.isBot) { + + // FEATURE: Default Bot Role + if (guildConfig.autoRolesEnabled && guildConfig.defaultBotRole != 0L) { + // 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 { + guildConfig.cleanRoleData(event.guild) + return + } + // 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) + 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 + + // TODO Feature: Welcome Messages + + // FEATURE: Auto Roles + if (guildConfig.autoRolesEnabled) handleAutoRoles(event, guildConfig) + } + + private fun handleAutoRoles(event: GenericGuildMemberEvent, guildConfig: GuildConfig) { + 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) return + + // FEATURE: Default Roles + if (guildConfig.defaultRoles.isNotEmpty()) { + val defaultRoles = guildConfig.defaultRoles.mapNotNull { roleId -> event.guild.getRoleById(roleId) } + 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) + } + + // 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) guildConfig.cleanRoleData(event.guild) + memberConfig.savedRoles.clear() + + // FEATURE: Revoke Saved Roles + 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) + } + + // TODO Feature: Sync Ranks + + // verify that we are able to interact with the given roles + val reachableMap = roleMap.filterKeys { role -> + selfMember.canInteract(role) + }.takeUnless { it.isEmpty() } ?: return + + val distinctReasons = reachableMap.values.asSequence().flatten().distinct().map { + ContentStore[event.guild.locale, "core.reasons.$it"] + }.sorted().joinToString() + + 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) + } + + 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 + // 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 + if (guildConfig.autoRolesEnabled && guildConfig.delayDefaultRoles) handleAutoRoles(event, guildConfig) + } + + private companion object { + private val HANDLER = ErrorHandler().ignore(ErrorResponse.UNKNOWN_MEMBER, ErrorResponse.MISSING_PERMISSIONS) + private val LOGGER = LoggerFactory.getLogger(GuildListener::class.java) + } + +} 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 0000000..564d2dd --- /dev/null +++ b/src/main/kotlin/com/sandrabot/sandra/utils/DataHelpers.kt @@ -0,0 +1,38 @@ +/* + * 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.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 } + 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 } } +} diff --git a/src/main/resources/content/english.json b/src/main/resources/content/english.json index 7d87f7a..94062fe 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": {