Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Expand Down
10 changes: 9 additions & 1 deletion src/main/kotlin/com/sandrabot/sandra/Sandra.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()

}
18 changes: 18 additions & 0 deletions src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -32,6 +34,19 @@ data class GuildConfig(override val id: Long) : Configuration() {
@Serializable(with = ConfigMapTransformer::class)
val members = mutableMapOf<Long, MemberConfig>()

@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<Long>()
@SerialName("revoked_roles")
val revokedRoles = mutableSetOf<Long>()
@SerialName("bot_role")
var defaultBotRole: Long = 0L

@SerialName("experience")
var experienceEnabled: Boolean = true
@SerialName("compounds")
Expand All @@ -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"

}
4 changes: 4 additions & 0 deletions src/main/kotlin/com/sandrabot/sandra/config/MemberConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package com.sandrabot.sandra.config

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
Expand All @@ -24,6 +25,9 @@ import kotlinx.serialization.Serializable
@Serializable
data class MemberConfig(override val id: Long) : ExperienceConfig() {

@SerialName("saved_roles")
val savedRoles = mutableSetOf<Long>()

override fun toString(): String = "MemberConfig:$id"

}
4 changes: 2 additions & 2 deletions src/main/kotlin/com/sandrabot/sandra/config/Serializers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ object ConfigMapTransformer : JsonTransformingSerializer<Map<Long, Configuration
) {
// Convert the map into a list of only values
override fun transformSerialize(element: JsonElement): JsonElement =
buildJsonArray { element.jsonObject.map { add(it.value) } }
buildJsonArray { element.jsonObject.forEach { add(it.value) } }

// Convert the list back to a map with the id as they key
override fun transformDeserialize(element: JsonElement): JsonElement =
buildJsonObject { element.jsonArray.map { put(it.jsonObject["id"].toString(), it.jsonObject) } }
buildJsonObject { element.jsonArray.forEach { put(it.jsonObject["id"].toString(), it.jsonObject) } }
}

object ConfigSerializer : JsonContentPolymorphicSerializer<Configuration>(Configuration::class) {
Expand Down
176 changes: 176 additions & 0 deletions src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt
Original file line number Diff line number Diff line change
@@ -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<Role, MutableSet<String>>()
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)
}

}
38 changes: 38 additions & 0 deletions src/main/kotlin/com/sandrabot/sandra/utils/DataHelpers.kt
Original file line number Diff line number Diff line change
@@ -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 } }
}
5 changes: 5 additions & 0 deletions src/main/resources/content/english.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down