diff --git a/build.gradle.kts b/build.gradle.kts index 14b547a1..48866c45 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 Avery Carroll and Logan Devecka + * 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. @@ -16,8 +16,8 @@ plugins { application - kotlin("jvm") version "2.3.0" - kotlin("plugin.serialization") version "2.3.0" + kotlin("jvm") version "2.3.10" + kotlin("plugin.serialization") version "2.3.10" id("com.github.gmazzo.buildconfig") version "6.0.7" id("io.ktor.plugin") version "3.4.0" } @@ -44,8 +44,8 @@ dependencies { exclude(module = "tink") } - implementation("ch.qos.logback:logback-classic:1.5.25") - implementation("io.sentry:sentry-logback:8.31.0") + implementation("ch.qos.logback:logback-classic:1.5.29") + implementation("io.sentry:sentry-logback:8.32.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.10.0") diff --git a/src/main/kotlin/com/sandrabot/sandra/Main.kt b/src/main/kotlin/com/sandrabot/sandra/Main.kt index cd957292..acc3dfe7 100644 --- a/src/main/kotlin/com/sandrabot/sandra/Main.kt +++ b/src/main/kotlin/com/sandrabot/sandra/Main.kt @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 Avery Carroll and Logan Devecka + * 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. @@ -111,7 +111,7 @@ private fun shutdownHook(sandra: Sandra) = with(sandra) { try { // disable any auxiliary services first botList.shutdown() - blocklist.shutdown() + access.shutdown() subscriptions.shutdown() // stop accepting new requests and sign out from discord diff --git a/src/main/kotlin/com/sandrabot/sandra/Sandra.kt b/src/main/kotlin/com/sandrabot/sandra/Sandra.kt index 4b19a1d3..a91a7af6 100644 --- a/src/main/kotlin/com/sandrabot/sandra/Sandra.kt +++ b/src/main/kotlin/com/sandrabot/sandra/Sandra.kt @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 Avery Carroll and Logan Devecka + * 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. @@ -43,7 +43,7 @@ class Sandra(val settings: SandraConfig, val redis: RedisManager) { val commands = CommandManager() val messages = MessageManager() val statistics = StatisticsManager() - val blocklist = BlocklistManager(this) + val access = AccessManager(this) val botList = BotListService(this) val config = ConfigurationManager(this) val lastfm = LastRequestManager(this) diff --git a/src/main/kotlin/com/sandrabot/sandra/config/FeatureConfig.kt b/src/main/kotlin/com/sandrabot/sandra/config/FeatureConfig.kt index a90f1496..9dde2b04 100644 --- a/src/main/kotlin/com/sandrabot/sandra/config/FeatureConfig.kt +++ b/src/main/kotlin/com/sandrabot/sandra/config/FeatureConfig.kt @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 Avery Carroll and Logan Devecka + * 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. @@ -16,6 +16,7 @@ package com.sandrabot.sandra.config +import com.sandrabot.sandra.entities.FeatureFlag import kotlinx.serialization.Serializable /** @@ -24,6 +25,11 @@ import kotlinx.serialization.Serializable @Serializable data class FeatureConfig( + /** + * A set of features [FeatureFlag] that should be disabled globally. + */ + val disabledFeatures: Set = setOf(), + /** * The channel where feedback messages should be sent. * **(Default: 0)** diff --git a/src/main/kotlin/com/sandrabot/sandra/entities/blocklist/types.kt b/src/main/kotlin/com/sandrabot/sandra/entities/FeatureFlag.kt similarity index 70% rename from src/main/kotlin/com/sandrabot/sandra/entities/blocklist/types.kt rename to src/main/kotlin/com/sandrabot/sandra/entities/FeatureFlag.kt index 141d880e..b55e47e4 100644 --- a/src/main/kotlin/com/sandrabot/sandra/entities/blocklist/types.kt +++ b/src/main/kotlin/com/sandrabot/sandra/entities/FeatureFlag.kt @@ -1,5 +1,5 @@ /* - * Copyright 2017-2022 Avery Carroll and Logan Devecka + * 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. @@ -14,16 +14,10 @@ * limitations under the License. */ -package com.sandrabot.sandra.entities.blocklist +package com.sandrabot.sandra.entities -enum class FeatureType { +enum class FeatureFlag { - COMMANDS, FEEDBACK, GUILD_CHAT, GLOBAL_EXPERIENCE, MESSAGES - -} - -enum class TargetType { - - GUILD, USER + ALL, AUTO_ROLE, COMMANDS, EXPERIENCE, FEEDBACK, LASTFM, LOGGING, MINIGAMES, NOTIFY, SOCIAL, WELCOME } diff --git a/src/main/kotlin/com/sandrabot/sandra/entities/blocklist/entry.kt b/src/main/kotlin/com/sandrabot/sandra/entities/blocklist/entry.kt deleted file mode 100644 index 33468fd1..00000000 --- a/src/main/kotlin/com/sandrabot/sandra/entities/blocklist/entry.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2017-2022 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.entities.blocklist - -import kotlinx.serialization.Serializable - -private fun blockedFeature(entry: BlocklistEntry, featureType: FeatureType): BlockedFeature? { - return synchronized(entry.blockedFeatures) { - entry.blockedFeatures.find { it.feature == featureType } - } -} - -@Serializable -data class BlocklistEntry( - val targetId: Long, - val targetType: TargetType, - val blockedFeatures: MutableList, - val offences: MutableList -) { - - fun getReason(featureType: FeatureType): String? { - return synchronized(offences) { - offences.find { featureType in it.features } - }?.reason - } - - fun isFeatureBlocked(featureType: FeatureType): Boolean { - blockedFeature(this, featureType)?.apply { - val currentTimeSeconds = System.currentTimeMillis() / 1000 - val isExpired = expiresAt != 0L && currentTimeSeconds >= expiresAt - if (isExpired) synchronized(blockedFeatures) { - blockedFeatures.remove(this) - } - return !isExpired - } - return false - } - - fun isNotified(featureType: FeatureType) = blockedFeature(this, featureType)?.notification != null - - fun recordNotify(featureType: FeatureType, channel: Long, message: Long) { - blockedFeature(this, featureType)?.apply { - val currentTimeSeconds = System.currentTimeMillis() / 1000 - notification = FeatureNotification(currentTimeSeconds, channel, message) - } - } - -} - -@Serializable -data class BlockedFeature( - val feature: FeatureType, val expiresAt: Long, var notification: FeatureNotification? = null -) - -@Serializable -data class FeatureNotification( - val timestamp: Long, val channel: Long, val message: Long -) - -@Serializable -data class BlocklistOffence( - val features: List, val moderator: Long, - val timestamp: Long, val automated: Boolean, val reason: String -) diff --git a/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt index cb84dbe8..373309f5 100644 --- a/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt +++ b/src/main/kotlin/com/sandrabot/sandra/listeners/GuildListener.kt @@ -19,7 +19,9 @@ 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.entities.FeatureFlag import com.sandrabot.sandra.utils.cleanRoleData +import com.sandrabot.sandra.utils.isFeatureRestricted import dev.minn.jda.ktx.events.CoroutineEventListener import net.dv8tion.jda.api.Permission import net.dv8tion.jda.api.entities.Role @@ -66,6 +68,8 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { // FEATURE: Default Bot Role if (guildConfig.autoRolesEnabled && guildConfig.defaultBotRole != 0L) { + // check with the access manager first thing + if (isFeatureRestricted(sandra, event.guild.idLong, FeatureFlag.AUTO_ROLE)) return // 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 { @@ -94,8 +98,10 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { } private fun handleAutoRoles(event: GenericGuildMemberEvent, guildConfig: GuildConfig) { - val selfMember = event.guild.selfMember + // first things first, check with the access manager + if (isFeatureRestricted(sandra, event.guild.idLong, FeatureFlag.AUTO_ROLE)) return + 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 @@ -148,8 +154,10 @@ class GuildListener(private val sandra: Sandra) : CoroutineEventListener { } private fun onMemberRemove(event: GuildMemberRemoveEvent) { - val guildConfig = sandra.config[event.guild] + // don't preserve role data for restricted guilds + if (isFeatureRestricted(sandra, event.guild.idLong, FeatureFlag.AUTO_ROLE)) return + val guildConfig = sandra.config[event.guild] // FEATURE: Save Roles if (guildConfig.autoRolesEnabled && guildConfig.saveRolesEnabled) { // attempt to store the member's current roles diff --git a/src/main/kotlin/com/sandrabot/sandra/listeners/InteractionListener.kt b/src/main/kotlin/com/sandrabot/sandra/listeners/InteractionListener.kt index 370adb74..b4a8c312 100644 --- a/src/main/kotlin/com/sandrabot/sandra/listeners/InteractionListener.kt +++ b/src/main/kotlin/com/sandrabot/sandra/listeners/InteractionListener.kt @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 Avery Carroll and Logan Devecka + * 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. @@ -21,7 +21,7 @@ import com.sandrabot.sandra.events.CommandEvent import com.sandrabot.sandra.events.asEphemeral import com.sandrabot.sandra.exceptions.MissingArgumentException import com.sandrabot.sandra.exceptions.MissingPermissionException -import com.sandrabot.sandra.utils.checkCommandBlocklist +import com.sandrabot.sandra.utils.isAccessRestricted import com.sandrabot.sandra.utils.missingPermissionMessage import dev.minn.jda.ktx.events.CoroutineEventListener import net.dv8tion.jda.api.events.GenericEvent @@ -52,8 +52,18 @@ class InteractionListener(private val sandra: Sandra) : CoroutineEventListener { // the first thing we want to do is wrap this with our own object val event = CommandEvent(command, slashEvent, sandra) - // check the blocklist to prevent responding in actively blocked contexts - if (checkCommandBlocklist(event)) return + + // log the command context information for usage history + val channel = if (event.guild == null) "direct message" + else "${event.channel.name} [${event.channel.id}] | ${event.guild.name} [${event.guild.id}]" + LOGGER.info("$path | ${event.user.name} [${event.user.id}] | $channel | ${slashEvent.commandString}") + + // and the next thing is gonna be checking with the access manager + if (event.isAccessRestricted() && !event.isOwner) { + event.replyWarning(event.getAny("core.restricted")).asEphemeral().queue() + return + } + // restrict owner commands from being used by anyone if (command.isOwnerOnly && !event.isOwner) { event.replyError(event.getAny("core.owner_only")).asEphemeral().queue() @@ -73,11 +83,6 @@ class InteractionListener(private val sandra: Sandra) : CoroutineEventListener { } } - // log the command context information for usage history - val channel = if (event.guild == null) "direct message" - else "${event.channel.name} [${event.channel.id}] | ${event.guild.name} [${event.guild.id}]" - LOGGER.info("$path | ${event.user.name} [${event.user.id}] | $channel | ${slashEvent.commandString}") - // execute the command, catch any exceptions and log them try { command.execute(event) diff --git a/src/main/kotlin/com/sandrabot/sandra/listeners/MessageListener.kt b/src/main/kotlin/com/sandrabot/sandra/listeners/MessageListener.kt index 1bd214cd..13b074d6 100644 --- a/src/main/kotlin/com/sandrabot/sandra/listeners/MessageListener.kt +++ b/src/main/kotlin/com/sandrabot/sandra/listeners/MessageListener.kt @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 Avery Carroll and Logan Devecka + * 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. @@ -18,8 +18,8 @@ package com.sandrabot.sandra.listeners import com.sandrabot.sandra.Sandra import com.sandrabot.sandra.constants.Emotes +import com.sandrabot.sandra.entities.FeatureFlag import com.sandrabot.sandra.entities.LocaleContext -import com.sandrabot.sandra.entities.blocklist.FeatureType import com.sandrabot.sandra.utils.* import dev.minn.jda.ktx.events.CoroutineEventListener import net.dv8tion.jda.api.Permission @@ -72,9 +72,6 @@ class MessageListener(private val sandra: Sandra) : CoroutineEventListener { val guildId = event.guild.idLong val channelId = event.channel.idLong - // Check the blocklist to prevent responding in actively blocked contexts - if (checkBlocklist(sandra, event.channel, authorId, guildId, FeatureType.MESSAGES)) return - val guildConfig = sandra.config.getGuild(guildId) val memberConfig = guildConfig.getMember(authorId) val channelConfig = guildConfig.getChannel(channelId) @@ -91,7 +88,7 @@ class MessageListener(private val sandra: Sandra) : CoroutineEventListener { val multiplier = guildConfig.computeMultiplier(channelConfig) if (memberConfig.awardExperience(randomExperience(multiplier))) { // Check if the guild has level up notifications enabled - if (guildConfig.experienceNotifyEnabled) { + if (guildConfig.experienceNotifyEnabled && isFeatureAllowed(sandra, guildId, FeatureFlag.NOTIFY)) { // Check if the guild has a specific channel for notifications // Otherwise, check if this channel can receive notifications val notifyChannel = if (guildConfig.experienceNotifyChannel != 0L) { @@ -119,11 +116,10 @@ class MessageListener(private val sandra: Sandra) : CoroutineEventListener { } // Feature: Global Experience - // Check to make sure this user is allowed to gain global experience - if (!checkBlocklist(sandra, event.channel, authorId, guildId, FeatureType.GLOBAL_EXPERIENCE)) { - // Check to see if this user can receive experience - // Award a random amount of experience between 15 and 25 - if (userConfig.canExperience()) userConfig.awardExperience(randomExperience()) + // Check to see if this user can receive experience + // Award a random amount of experience between 15 and 25 + if (userConfig.canExperience() && isContextAllowed(sandra, authorId, guildId, FeatureFlag.EXPERIENCE)) { + userConfig.awardExperience(randomExperience()) } // TODO Feature: Message Replies @@ -132,12 +128,10 @@ class MessageListener(private val sandra: Sandra) : CoroutineEventListener { /** * Processes any private messages that the bot receives. */ - private fun handlePrivateMessage(event: MessageReceivedEvent) = buildString { - append("Direct Message: ", event.author.name, " [", event.author.id, "] | ", event.message.contentDisplay) - with(event.message.attachments) { - if (isNotEmpty()) forEach { append("\nDirect Message Attachment: ${it.url}") } - } - }.let { LOGGER.info(it) } + private fun handlePrivateMessage(event: MessageReceivedEvent) { + LOGGER.info("Direct Message: ${event.author.name} [${event.author.id}] | ${event.message.contentDisplay}") + event.message.attachments.forEach { LOGGER.info("Direct Message Attachment: ${it.url}") } + } private companion object { private val LOGGER = LoggerFactory.getLogger(MessageListener::class.java) diff --git a/src/main/kotlin/com/sandrabot/sandra/managers/AccessManager.kt b/src/main/kotlin/com/sandrabot/sandra/managers/AccessManager.kt new file mode 100644 index 00000000..64a94f6f --- /dev/null +++ b/src/main/kotlin/com/sandrabot/sandra/managers/AccessManager.kt @@ -0,0 +1,95 @@ +/* + * 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.managers + +import com.sandrabot.sandra.Sandra +import com.sandrabot.sandra.constants.RedisPrefix +import com.sandrabot.sandra.entities.FeatureFlag +import kotlinx.serialization.json.Json + +/** + * Manages granular access to features throughout the bot. + */ +class AccessManager(private val sandra: Sandra) { + + /** + * Creates a mutable copy of features that are disabled in the config file. + * Any changes made to this collection will not be reflected at the next startup. + */ + val disabledFeatures = sandra.settings.features.disabledFeatures.toMutableSet() + + /** + * This map keeps track of the users or guilds that have been restricted. + * Restrictions are automatically loaded from the database while starting up. + * Any changes made to the database while running will not be reflected and may be overwritten. + * + * @see isRestricted + * @see grant + * @see revoke + */ + private val restrictedMap: MutableMap> by lazy { + sandra.redis[REDIS_ACCESS]?.let { Json.decodeFromString(it) } ?: mutableMapOf() + } + + /** + * Returns `true` when access to the `feature` has been revoked from this `id`. + */ + fun isAccessRevoked(id: Long, feature: FeatureFlag): Boolean { + val set = restrictedMap[id] ?: return false + return feature in set || FeatureFlag.ALL in set + } + + /** + * Returns `true` when this `feature` has been disabled globally. + */ + fun isFeatureDisabled(feature: FeatureFlag): Boolean { + return feature in disabledFeatures || FeatureFlag.ALL in disabledFeatures + } + + /** + * Returns `true` if this `id` maintains any restrictions whatsoever. + */ + fun isRestricted(id: Long) = id in restrictedMap + + /** + * Restores access to `feature` for this `id`. Returns `true` if changes were made. + */ + fun grant(id: Long, feature: FeatureFlag): Boolean { + val set = restrictedMap[id] ?: return false + return set.remove(feature).also { if (set.isEmpty()) restrictedMap.remove(id) } + } + + /** + * Revokes access to `feature` for this `id`. Returns `true` if changes were made. + */ + fun revoke(id: Long, feature: FeatureFlag): Boolean { + return restrictedMap.getOrPut(id) { mutableSetOf() }.add(feature) + } + + /** + * Save current restrictions to the database if applicable. + */ + fun shutdown() { + if (restrictedMap.isEmpty()) sandra.redis - REDIS_ACCESS else + sandra.redis[REDIS_ACCESS] = Json.encodeToString(restrictedMap) + } + + private companion object { + val REDIS_ACCESS = RedisPrefix.SETTING + "access" + } + +} diff --git a/src/main/kotlin/com/sandrabot/sandra/managers/BlocklistManager.kt b/src/main/kotlin/com/sandrabot/sandra/managers/BlocklistManager.kt deleted file mode 100644 index dbfb49d3..00000000 --- a/src/main/kotlin/com/sandrabot/sandra/managers/BlocklistManager.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2017-2022 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.managers - -import com.sandrabot.sandra.Sandra -import com.sandrabot.sandra.constants.RedisPrefix -import com.sandrabot.sandra.entities.blocklist.* -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -/** - * Keeps track of blocked features and offence history. - */ -class BlocklistManager(private val sandra: Sandra) { - - private val entries = mutableMapOf() - - init { - val data = sandra.redis[RedisPrefix.SETTING + "blocklist"] ?: "[]" - Json.decodeFromString>(data).forEach { entries[it.targetId] = it } - } - - fun getEntry(targetId: Long): BlocklistEntry? = entries[targetId] - - fun shutdown() { - sandra.redis[RedisPrefix.SETTING + "blocklist"] = Json.encodeToString(entries.values.toList()) - } - - fun appendOffence( - targetId: Long, targetType: TargetType, features: List, - expiresAt: Long, moderator: Long, automated: Boolean, reason: String - ) { - val entry = if (targetId in entries) entries[targetId] else { - BlocklistEntry(targetId, targetType, mutableListOf(), mutableListOf()).also { - entries[targetId] = it - } - } - val blockedFeatures = entry!!.blockedFeatures - synchronized(blockedFeatures) { - // By removing features that are already blocked, we guarantee - // only the most recent offence expresses the expiration timestamp - blockedFeatures.removeIf { it.feature in features } - val additionalFeatures = features.map { BlockedFeature(it, expiresAt) } - blockedFeatures.addAll(additionalFeatures) - } - val offences = entry.offences - synchronized(offences) { - val timestamp = System.currentTimeMillis() / 1000 - val currentOffence = BlocklistOffence(features, moderator, timestamp, automated, reason) - offences.add(currentOffence) - } - } - -} diff --git a/src/main/kotlin/com/sandrabot/sandra/managers/RedisManager.kt b/src/main/kotlin/com/sandrabot/sandra/managers/RedisManager.kt index 92285b70..1d0642a9 100644 --- a/src/main/kotlin/com/sandrabot/sandra/managers/RedisManager.kt +++ b/src/main/kotlin/com/sandrabot/sandra/managers/RedisManager.kt @@ -44,4 +44,6 @@ class RedisManager(config: RedisConfig) { operator fun minus(key: String): Long = client.del(key) + operator fun contains(key: String): Boolean = client.exists(key) + } diff --git a/src/main/kotlin/com/sandrabot/sandra/utils/AccessHelpers.kt b/src/main/kotlin/com/sandrabot/sandra/utils/AccessHelpers.kt new file mode 100644 index 00000000..f444a8e7 --- /dev/null +++ b/src/main/kotlin/com/sandrabot/sandra/utils/AccessHelpers.kt @@ -0,0 +1,48 @@ +/* + * 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.utils + +import com.sandrabot.sandra.Sandra +import com.sandrabot.sandra.commands.essential.Feedback +import com.sandrabot.sandra.entities.Category +import com.sandrabot.sandra.entities.FeatureFlag +import com.sandrabot.sandra.events.CommandEvent + +fun isFeatureAllowed(sandra: Sandra, user: Long, feature: FeatureFlag) = !isFeatureRestricted(sandra, user, feature) +fun isFeatureRestricted(sandra: Sandra, id: Long, feature: FeatureFlag): Boolean { + return sandra.access.isFeatureDisabled(feature) || sandra.access.isAccessRevoked(id, feature) +} + +fun isContextAllowed( + sandra: Sandra, authorId: Long, guildId: Long, feature: FeatureFlag, +) = !isContextRestricted(sandra, authorId, guildId, feature) + +fun isContextRestricted(sandra: Sandra, user: Long, guild: Long?, feature: FeatureFlag): Boolean { + val isUserRevoked = isFeatureRestricted(sandra, user, feature) + return if (guild != null) isUserRevoked || isFeatureRestricted(sandra, guild, feature) else isUserRevoked +} + +fun CommandEvent.isAccessRestricted(): Boolean = when { + command is Feedback -> checkContext(FeatureFlag.FEEDBACK) + command.category == Category.LASTFM -> checkContext(FeatureFlag.LASTFM) + command.category == Category.GAME -> checkContext(FeatureFlag.MINIGAMES) + command.category == Category.SOCIAL -> checkContext(FeatureFlag.SOCIAL) + else -> false +} || checkContext(FeatureFlag.COMMANDS) + +private fun CommandEvent.checkContext(feature: FeatureFlag) = + isContextRestricted(sandra, user.idLong, guild?.idLong, feature) diff --git a/src/main/kotlin/com/sandrabot/sandra/utils/Blocklist.kt b/src/main/kotlin/com/sandrabot/sandra/utils/Blocklist.kt deleted file mode 100644 index 80931207..00000000 --- a/src/main/kotlin/com/sandrabot/sandra/utils/Blocklist.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2017-2023 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.Sandra -import com.sandrabot.sandra.constants.ContentStore -import com.sandrabot.sandra.constants.Unicode -import com.sandrabot.sandra.entities.blocklist.BlocklistEntry -import com.sandrabot.sandra.entities.blocklist.FeatureType -import com.sandrabot.sandra.entities.blocklist.TargetType -import com.sandrabot.sandra.events.CommandEvent -import com.sandrabot.sandra.managers.BlocklistManager -import net.dv8tion.jda.api.Permission -import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel -import org.slf4j.LoggerFactory - -fun checkCommandBlocklist(event: CommandEvent): Boolean = checkBlocklist( - event.sandra, event.channel, event.user.idLong, event.guild?.idLong, FeatureType.COMMANDS -) - -fun checkBlocklist( - sandra: Sandra, channel: MessageChannel, userId: Long, guildId: Long?, featureType: FeatureType -): Boolean { - // This is the easiest way to process different contexts at the same time - val entry = arrayOf(userId, guildId).filterNotNull().mapNotNull { - sandra.blocklist.getEntry(it) - }.find { it.isFeatureBlocked(featureType) } ?: return false - // Always make sure that the context has been notified - if (!entry.isNotified(featureType)) blocklistNotify(sandra, channel, userId, guildId, featureType, entry) - return true -} - -fun blocklistNotify( - sandra: Sandra, channel: MessageChannel, userId: Long, - guildId: Long?, featureType: FeatureType, entry: BlocklistEntry -) { - val (entryName, locale) = if (entry.targetType == TargetType.GUILD) { - // The guildId should only be null if the context is not from a guild - val guild = sandra.shards.getGuildById(guildId ?: return) ?: return - // If this channel is in a guild, make sure we have permissions to even continue - if (!guild.selfMember.hasPermission(Permission.MESSAGE_SEND)) return - guild.name.sanitize() to guild.locale - } else { - val user = sandra.shards.getUserById(userId) ?: return - user.name.sanitize() to user.probableLocale() - } - val reason = entry.getReason(featureType) - val blockedMessage = "${Unicode.CROSS_MARK} ${ContentStore[locale, "core.blocked"].format(entryName, reason)}" - channel.sendMessage(blockedMessage).queue { - entry.recordNotify(featureType, channel.idLong, it.idLong) - LoggerFactory.getLogger(BlocklistManager::class.java).info( - """This context has been notified of feature $featureType and reason "$reason" with message ${it.id} in channel ${channel.id}""" - ) - } -} diff --git a/src/main/kotlin/com/sandrabot/sandra/utils/JsonHelpers.kt b/src/main/kotlin/com/sandrabot/sandra/utils/JsonHelpers.kt index 20aed08f..04da5874 100644 --- a/src/main/kotlin/com/sandrabot/sandra/utils/JsonHelpers.kt +++ b/src/main/kotlin/com/sandrabot/sandra/utils/JsonHelpers.kt @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 Avery Carroll and Logan Devecka + * 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. @@ -17,9 +17,6 @@ package com.sandrabot.sandra.utils import kotlinx.serialization.json.* -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.set fun emptyJsonObject(): JsonObject = JsonObject(emptyMap()) fun emptyJsonArray(): JsonArray = JsonArray(emptyList()) diff --git a/src/main/resources/content/english.json b/src/main/resources/content/english.json index 94062fe4..1e604c86 100644 --- a/src/main/resources/content/english.json +++ b/src/main/resources/content/english.json @@ -10,6 +10,7 @@ "missing_self_permission": "i need the **%1$s** permission in this **%2$s** to do that", "missing_other_permission": "you need the **%1$s** permission in this **%2$s** to do that", "owner_only": "this command **can only be used** by **my developers**, nice try tho", + "restricted": "this feature is **restricted** or is otherwise unavailable to you at this time", "channel": "channel", "server": "server", "categories": {