diff --git a/build.gradle.kts b/build.gradle.kts index ffad4e5..04fcf04 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -50,7 +50,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") implementation("org.reflections:reflections:0.10.2") - implementation("redis.clients:jedis:7.4.1") + implementation("redis.clients:jedis:7.5.0") 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 a91a7af..a713b6d 100644 --- a/src/main/kotlin/com/sandrabot/sandra/Sandra.kt +++ b/src/main/kotlin/com/sandrabot/sandra/Sandra.kt @@ -18,10 +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 +import com.sandrabot.sandra.listeners.* import com.sandrabot.sandra.managers.* import com.sandrabot.sandra.services.BotListService import dev.minn.jda.ktx.events.CoroutineEventManager @@ -60,6 +57,7 @@ class Sandra(val settings: SandraConfig, val redis: RedisManager) { addEventListeners( GuildListener(this@Sandra), InteractionListener(this@Sandra), + LoggingListener(this@Sandra), MessageListener(this@Sandra), ReadyListener(this@Sandra), ) diff --git a/src/main/kotlin/com/sandrabot/sandra/commands/moderation/SoftBan.kt b/src/main/kotlin/com/sandrabot/sandra/commands/moderation/SoftBan.kt index 7c9ee9c..6fac7bf 100644 --- a/src/main/kotlin/com/sandrabot/sandra/commands/moderation/SoftBan.kt +++ b/src/main/kotlin/com/sandrabot/sandra/commands/moderation/SoftBan.kt @@ -55,7 +55,7 @@ class SoftBan : Command( event.deferReply(ephemeral = isQuiet).queue() // check the banlist to see if the target user is already banned event.guild!!.retrieveBan(targetUser).onErrorMap { null }.await()?.let { userBan -> - val realReason = userBan.reason?.sanitize() ?: event.get("default_reason") + val realReason = userBan.reason?.sanitize() ?: event.getAny("core.phrases.no_reason") event.replyEmoji(Emojis.BAN, event.get("already_banned", targetUser.name, realReason)).queue() return } @@ -99,7 +99,7 @@ class SoftBan : Command( return } - val reason = event.arguments.text("reason") ?: event.get("default_reason") + val reason = event.arguments.text("reason") ?: event.getAny("core.phrases.no_reason") val realReason = event.get("reason", event.user.name, reason) var banNotification: Message? = null diff --git a/src/main/kotlin/com/sandrabot/sandra/config/ChannelConfig.kt b/src/main/kotlin/com/sandrabot/sandra/config/ChannelConfig.kt index 2e6a29f..4604185 100644 --- a/src/main/kotlin/com/sandrabot/sandra/config/ChannelConfig.kt +++ b/src/main/kotlin/com/sandrabot/sandra/config/ChannelConfig.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. @@ -16,6 +16,7 @@ package com.sandrabot.sandra.config +import com.sandrabot.sandra.constants.EventType import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -25,6 +26,9 @@ import kotlinx.serialization.Serializable @Serializable data class ChannelConfig(override val id: Long) : Configuration() { + @SerialName("events") + val loggingEventsEnabled = mutableSetOf() + @SerialName("experience") var experienceEnabled: Boolean = true @SerialName("notify") diff --git a/src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.kt b/src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.kt index e031c65..8320b09 100644 --- a/src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.kt +++ b/src/main/kotlin/com/sandrabot/sandra/config/GuildConfig.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. @@ -65,6 +65,9 @@ data class GuildConfig(override val id: Long) : Configuration() { @SerialName("last_downvote") var lastDownvoteEmoji: String? = null + @SerialName("logging") + var loggingEnabled: Boolean = false + fun getChannel(id: Long): ChannelConfig = channels.getOrPut(id) { ChannelConfig(id) } fun getMember(id: Long): MemberConfig = members.getOrPut(id) { MemberConfig(id) } diff --git a/src/main/kotlin/com/sandrabot/sandra/constants/Emojis.kt b/src/main/kotlin/com/sandrabot/sandra/constants/Emojis.kt index f437182..811aa78 100644 --- a/src/main/kotlin/com/sandrabot/sandra/constants/Emojis.kt +++ b/src/main/kotlin/com/sandrabot/sandra/constants/Emojis.kt @@ -30,6 +30,7 @@ object Emojis { const val FAILURE = "<:failure:1278461668728504380>" const val FOLDER = "<:folder:1471993647737208934>" const val FUN = "<:fun:1278461721349984268>" + const val IMAGE = "<:image:1472841835214012443>" const val INFO = "<:info:1278461761858834613>" const val INVITE = "<:invite:1471969124786901165>" const val JOIN = "<:join:1278461800312213637>" diff --git a/src/main/kotlin/com/sandrabot/sandra/constants/EventType.kt b/src/main/kotlin/com/sandrabot/sandra/constants/EventType.kt new file mode 100644 index 0000000..0e25592 --- /dev/null +++ b/src/main/kotlin/com/sandrabot/sandra/constants/EventType.kt @@ -0,0 +1,32 @@ +/* + * 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.constants + +enum class EventType(val emoji: String) { + + ALL(Emojis.FOLDER), + AUTO_MOD(Emojis.MOD), + BAN(Emojis.BAN), + EMOJI(Emojis.FUN), + INVITE(Emojis.INVITE), + MEMBER(Emojis.USER), + MESSAGE(Emojis.CHAT), + POLL(Emojis.PROMPT), + SECURITY(Emojis.NOTICE), + STICKER(Emojis.IMAGE), + +} diff --git a/src/main/kotlin/com/sandrabot/sandra/constants/Unicode.kt b/src/main/kotlin/com/sandrabot/sandra/constants/Unicode.kt index 2d23e62..d5adf44 100644 --- a/src/main/kotlin/com/sandrabot/sandra/constants/Unicode.kt +++ b/src/main/kotlin/com/sandrabot/sandra/constants/Unicode.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. diff --git a/src/main/kotlin/com/sandrabot/sandra/listeners/LoggingListener.kt b/src/main/kotlin/com/sandrabot/sandra/listeners/LoggingListener.kt new file mode 100644 index 0000000..c8edf46 --- /dev/null +++ b/src/main/kotlin/com/sandrabot/sandra/listeners/LoggingListener.kt @@ -0,0 +1,330 @@ +/* + * Copyright 2026 Avery Carroll, Logan Devecka, and contributors + * + * 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 + * + * https://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.constants.Emojis +import com.sandrabot.sandra.constants.EventType +import com.sandrabot.sandra.constants.FeatureFlag +import com.sandrabot.sandra.entities.LocaleContext +import com.sandrabot.sandra.utils.format +import com.sandrabot.sandra.utils.isFeatureRestricted +import dev.minn.jda.ktx.coroutines.await +import dev.minn.jda.ktx.events.CoroutineEventListener +import dev.minn.jda.ktx.messages.MessageCreate +import io.ktor.util.date.* +import kotlinx.coroutines.delay +import net.dv8tion.jda.api.Permission +import net.dv8tion.jda.api.audit.ActionType +import net.dv8tion.jda.api.audit.AuditLogEntry +import net.dv8tion.jda.api.entities.Guild +import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel +import net.dv8tion.jda.api.events.GenericEvent +import net.dv8tion.jda.api.events.automod.AutoModExecutionEvent +import net.dv8tion.jda.api.events.automod.AutoModRuleCreateEvent +import net.dv8tion.jda.api.events.automod.AutoModRuleDeleteEvent +import net.dv8tion.jda.api.events.automod.AutoModRuleUpdateEvent +import net.dv8tion.jda.api.events.emoji.EmojiAddedEvent +import net.dv8tion.jda.api.events.emoji.EmojiRemovedEvent +import net.dv8tion.jda.api.events.emoji.update.EmojiUpdateNameEvent +import net.dv8tion.jda.api.events.guild.GuildBanEvent +import net.dv8tion.jda.api.events.guild.GuildUnbanEvent +import net.dv8tion.jda.api.events.guild.invite.GuildInviteCreateEvent +import net.dv8tion.jda.api.events.guild.invite.GuildInviteDeleteEvent +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.GuildMemberRoleAddEvent +import net.dv8tion.jda.api.events.guild.member.GuildMemberRoleRemoveEvent +import net.dv8tion.jda.api.events.guild.member.update.GuildMemberUpdateAvatarEvent +import net.dv8tion.jda.api.events.guild.member.update.GuildMemberUpdateFlagsEvent +import net.dv8tion.jda.api.events.guild.member.update.GuildMemberUpdateNicknameEvent +import net.dv8tion.jda.api.events.guild.member.update.GuildMemberUpdateTimeOutEvent +import net.dv8tion.jda.api.events.guild.update.GuildUpdateSecurityIncidentActionsEvent +import net.dv8tion.jda.api.events.guild.update.GuildUpdateSecurityIncidentDetectionsEvent +import net.dv8tion.jda.api.events.message.MessageBulkDeleteEvent +import net.dv8tion.jda.api.events.message.MessageDeleteEvent +import net.dv8tion.jda.api.events.message.MessageUpdateEvent +import net.dv8tion.jda.api.events.message.poll.MessagePollVoteAddEvent +import net.dv8tion.jda.api.events.message.poll.MessagePollVoteRemoveEvent +import net.dv8tion.jda.api.events.sticker.GuildStickerAddedEvent +import net.dv8tion.jda.api.events.sticker.GuildStickerRemovedEvent +import net.dv8tion.jda.api.events.sticker.update.GuildStickerUpdateDescriptionEvent +import net.dv8tion.jda.api.events.sticker.update.GuildStickerUpdateNameEvent +import net.dv8tion.jda.api.exceptions.ErrorHandler +import net.dv8tion.jda.api.exceptions.ErrorResponseException +import net.dv8tion.jda.api.requests.ErrorResponse +import net.dv8tion.jda.api.utils.messages.MessageCreateData +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class LoggingListener(val sandra: Sandra) : CoroutineEventListener { + + override suspend fun onEvent(event: GenericEvent) { + // detect what type of event this is, and whether we should act upon it + when (event) { + // feature: auto mod logging + is AutoModExecutionEvent -> onAutoModExecution(event) + is AutoModRuleCreateEvent -> onAutoModRuleCreate(event) + is AutoModRuleDeleteEvent -> onAutoModRuleDelete(event) + is AutoModRuleUpdateEvent -> onAutoModRuleUpdate(event) + + // feature: security incident logging + is GuildUpdateSecurityIncidentActionsEvent -> onUpdateSecurityActions(event) + is GuildUpdateSecurityIncidentDetectionsEvent -> onUpdateSecurityDetections(event) + + // feature: emoji creation and edit history + is EmojiAddedEvent -> onEmojiAdded(event) + is EmojiRemovedEvent -> onEmojiRemoved(event) + is EmojiUpdateNameEvent -> onEmojiUpdateName(event) + + // feature: sticker creation and updates + is GuildStickerAddedEvent -> onGuildStickerAdded(event) + is GuildStickerRemovedEvent -> onGuildStickerRemoved(event) + is GuildStickerUpdateNameEvent -> onGuildStickerUpdateName(event) + is GuildStickerUpdateDescriptionEvent -> onGuildStickerUpdateDescription(event) + + // feature: invite creation and deletion + is GuildInviteCreateEvent -> onGuildInviteCreate(event) + is GuildInviteDeleteEvent -> onGuildInviteDelete(event) + + // feature: log bans and unbans + is GuildBanEvent -> onGuildBan(event) + is GuildUnbanEvent -> onGuildUnban(event) + + // feature: member joins, leaves, and updates + is GuildMemberJoinEvent -> onGuildMemberJoin(event) + is GuildMemberRemoveEvent -> onGuildMemberRemove(event) + is GuildMemberRoleAddEvent -> onGuildMemberRoleAdd(event) + is GuildMemberRoleRemoveEvent -> onGuildMemberRoleRemove(event) + is GuildMemberUpdateAvatarEvent -> onGuildMemberUpdateAvatar(event) + is GuildMemberUpdateFlagsEvent -> onGuildMemberUpdateFlags(event) + is GuildMemberUpdateNicknameEvent -> onGuildMemberUpdateNickname(event) + is GuildMemberUpdateTimeOutEvent -> onGuildMemberUpdateTimeOut(event) + + // feature: deleted messages and edit history + is MessageBulkDeleteEvent -> onMessageBulkDelete(event) + is MessageDeleteEvent -> onMessageDelete(event) + is MessageUpdateEvent -> onMessageUpdate(event) + + // feature: poll announcements and vote history + is MessagePollVoteAddEvent -> onMessagePollVoteAdd(event) + is MessagePollVoteRemoveEvent -> onMessagePollVoteRemove(event) + } + } + + private suspend fun sendEvent( + guild: Guild, eventType: EventType, actionType: ActionType?, provider: (AuditLogEntry?) -> MessageCreateData, + ) { + // only continue if the logging feature is actually enabled + val config = sandra.config[guild].takeIf { it.loggingEnabled } ?: return + // consult with the access manager to determine if this feature is available + if (isFeatureRestricted(sandra, guild.idLong, FeatureFlag.LOGGING)) return + // filter channels that are subscribed to this event + val channels = config.channels.filterValues { + eventType in it.loggingEventsEnabled || EventType.ALL in it.loggingEventsEnabled + }.takeUnless { it.isEmpty() } ?: return // we can stop here if nobody is actually subscribed + // determine if we can provide the audit log entry + val auditEntry = if (actionType != null && guild.selfMember.hasPermission(Permission.VIEW_AUDIT_LOGS)) { + delay(200.milliseconds) // race condition, allow discord time to generate the audit log entry + try { // attempt to retrieve the latest audit log entry for this action type + guild.retrieveAuditLogs().type(actionType).limit(1).await().firstOrNull()?.takeUnless { + // only accept the latest entry if it was created within the last second + getTimeMillis() - it.timeCreated.toInstant().toEpochMilli() > 1_000 + } + } catch (_: ErrorResponseException) { + null // nothing we can really do if this fails + } + } else null + // generate the message content for this log event + val message = provider(auditEntry) + // send the message to each subscriber + for (id in channels.keys) { + // remove stale channel data if the channel doesn't exist anymore + val channel = guild.getGuildChannelById(id) ?: config.channels.remove(id) + // verify that messages can be sent to this channel + if (channel !is GuildMessageChannel || !channel.canTalk()) continue + channel.sendMessage(message).setAllowedMentions(emptySet()).queue(null, ERROR_HANDLER) + } + } + + private suspend fun sendEventMessage( + guild: Guild, eventType: EventType, actionType: ActionType?, emoji: String? = null, + provider: (AuditLogEntry?, LocaleContext) -> String, + ) = sendEvent(guild, eventType, actionType) { entry -> + val now = getTimeMillis() / 1000 + val content = provider(entry, LocaleContext(guild.locale, "logging")) + MessageCreate("${emoji ?: eventType.emoji} $content") + } + + private suspend fun onAutoModExecution(event: AutoModExecutionEvent) {} + private suspend fun onAutoModRuleCreate(event: AutoModRuleCreateEvent) {} + private suspend fun onAutoModRuleDelete(event: AutoModRuleDeleteEvent) {} + private suspend fun onAutoModRuleUpdate(event: AutoModRuleUpdateEvent) {} + + private suspend fun onUpdateSecurityActions(event: GuildUpdateSecurityIncidentActionsEvent) = + sendEventMessage(event.guild, EventType.SECURITY, null) { _, context -> + val oldValues = mapOf( + "invites" to event.oldValue?.invitesDisabledUntil, + "direct_messages" to event.oldValue?.directMessagesDisabledUntil, + ).filterValues { it != null } + val newValues = mapOf( + "invites" to event.newValue?.invitesDisabledUntil, + "direct_messages" to event.newValue?.directMessagesDisabledUntil, + ).filterValues { it != null } + if (newValues.size > oldValues.size) { + val actions = newValues.keys.subtract(oldValues.keys).map { context.getAny("core.phrases.$it") } + val timestamp = newValues.values.firstOrNull()?.toEpochSecond() + context["security_action", actions.joinToString("** & **"), ""] + } else { + val actions = oldValues.keys.subtract(newValues.keys).map { context.getAny("core.phrases.$it") } + context["security_action_restore", actions.joinToString("** & **")] + } + } + + private suspend fun onUpdateSecurityDetections(event: GuildUpdateSecurityIncidentDetectionsEvent) = + sendEventMessage(event.guild, EventType.SECURITY, null) { _, context -> + val oldValues = mapOf( + "raid" to event.oldValue?.timeDetectedRaid, + "dm_spam" to event.oldValue?.timeDetectedDmSpam, + ).filterValues { it != null } + val newValues = mapOf( + "raid" to event.newValue?.timeDetectedRaid, + "dm_spam" to event.newValue?.timeDetectedDmSpam, + ).filterValues { it != null } + if (newValues.size > oldValues.size) { + val actions = newValues.keys.subtract(oldValues.keys).map { context.getAny("core.$it") } + val timestamp = newValues.values.firstOrNull()?.toEpochSecond() + context["security_detection", actions.joinToString("** & **"), ""] + } else { + val actions = oldValues.keys.subtract(newValues.keys).map { context.getAny("core.$it") } + context["security_detection_restore", actions.joinToString("** & **")] + } + } + + private suspend fun onEmojiAdded(event: EmojiAddedEvent) = + sendEventMessage(event.guild, EventType.EMOJI, ActionType.EMOJI_CREATE) { entry, context -> + val someone = entry?.user?.asMention ?: context.getAny("core.phrases.someone") + context["emoji_create", someone, event.emoji.asMention, event.emoji.name] + } + + private suspend fun onEmojiRemoved(event: EmojiRemovedEvent) = + sendEventMessage(event.guild, EventType.EMOJI, ActionType.EMOJI_DELETE) { entry, context -> + val someone = entry?.user?.asMention ?: context.getAny("core.phrases.someone") + context["emoji_delete", someone, event.emoji.name] + } + + private suspend fun onEmojiUpdateName(event: EmojiUpdateNameEvent) = + sendEventMessage(event.guild, EventType.EMOJI, ActionType.EMOJI_UPDATE) { entry, context -> + val someone = entry?.user?.asMention ?: context.getAny("core.phrases.someone") + context["emoji_rename", someone, event.emoji.asMention, event.newName, event.oldName] + } + + private suspend fun onGuildStickerAdded(event: GuildStickerAddedEvent) = + sendEventMessage(event.guild, EventType.STICKER, ActionType.STICKER_CREATE) { entry, context -> + val someone = entry?.user?.asMention ?: context.getAny("core.phrases.someone") + context["sticker_create", someone, event.sticker.name] + } + + private suspend fun onGuildStickerRemoved(event: GuildStickerRemovedEvent) = + sendEventMessage(event.guild, EventType.STICKER, ActionType.STICKER_DELETE) { entry, context -> + val someone = entry?.user?.asMention ?: context.getAny("core.phrases.someone") + context["sticker_delete", someone, event.sticker.name] + } + + private suspend fun onGuildStickerUpdateName(event: GuildStickerUpdateNameEvent) = + sendEventMessage(event.guild, EventType.STICKER, ActionType.STICKER_UPDATE) { entry, context -> + val someone = entry?.user?.asMention ?: context.getAny("core.phrases.someone") + context["sticker_rename", someone, event.newValue, event.oldValue] + } + + private suspend fun onGuildStickerUpdateDescription(event: GuildStickerUpdateDescriptionEvent) = + sendEventMessage(event.guild, EventType.STICKER, ActionType.STICKER_UPDATE, provider = { entry, context -> + val someone = entry?.user?.asMention ?: context.getAny("core.phrases.someone") + context["sticker_description", someone, event.sticker.name] + }) + + private suspend fun onGuildInviteCreate(event: GuildInviteCreateEvent) = + sendEventMessage(event.guild, EventType.INVITE, ActionType.INVITE_CREATE) { entry, context -> + val someone = entry?.user?.asMention ?: context.getAny("core.phrases.someone") + context["invite_create", someone, event.channel.asMention, event.invite.code] + } + + private suspend fun onGuildInviteDelete(event: GuildInviteDeleteEvent) = + sendEventMessage(event.guild, EventType.INVITE, ActionType.INVITE_DELETE, Emojis.UNINVITE) { entry, context -> + if (entry != null) { + val someone = entry.user?.asMention ?: context.getAny("core.phrases.someone") + context["invite_delete", someone, event.channel.asMention, event.code] + } else context["invite_expire", event.channel.asMention, event.code] + } + + private suspend fun onGuildBan(event: GuildBanEvent) = + sendEventMessage(event.guild, EventType.BAN, ActionType.BAN) { entry, context -> + val someone = entry?.user?.asMention ?: context.getAny("core.phrases.someone") + val reason = entry?.reason ?: context.getAny("core.phrases.no_reason") + context["ban", someone, event.user.asMention, event.user.id, reason] + } + + private suspend fun onGuildUnban(event: GuildUnbanEvent) = + sendEventMessage(event.guild, EventType.BAN, ActionType.UNBAN) { entry, context -> + val someone = entry?.user?.asMention ?: context.getAny("core.phrases.someone") + val reason = entry?.reason ?: context.getAny("core.phrases.no_reason") + context["unban", someone, event.user.asMention, event.user.id, reason] + } + + private suspend fun onGuildMemberJoin(event: GuildMemberJoinEvent) { + sendEventMessage(event.guild, EventType.MEMBER, null, Emojis.JOIN) { _, context -> + val timestamp = event.user.timeCreated.toEpochSecond() + context["member_join", event.user.asMention, event.user.id, ""] + } + } + + private suspend fun onGuildMemberRemove(event: GuildMemberRemoveEvent) { + sendEventMessage(event.guild, EventType.MEMBER, ActionType.KICK, Emojis.LEAVE) { entry, context -> + if (entry != null && entry.targetIdLong == event.user.idLong) { + val someone = entry.user?.asMention ?: context.getAny("core.phrases.someone") + val reason = entry.reason ?: context.getAny("core.phrases.no_reason") + context["kick", someone, event.user.asMention, event.user.id, reason] + } else { + val formatted = event.member?.takeIf { it.hasTimeJoined() }?.let { + (getTimeMillis() / 1000 - it.timeJoined.toEpochSecond()).seconds.format() + } ?: "*${context.getAny("core.phrases.some_time")}*" + context["member_leave", event.user.asMention, event.user.id, formatted] + } + } + } + + private suspend fun onGuildMemberRoleAdd(event: GuildMemberRoleAddEvent) {} + private suspend fun onGuildMemberRoleRemove(event: GuildMemberRoleRemoveEvent) {} + private suspend fun onGuildMemberUpdateAvatar(event: GuildMemberUpdateAvatarEvent) {} + private suspend fun onGuildMemberUpdateFlags(event: GuildMemberUpdateFlagsEvent) {} + private suspend fun onGuildMemberUpdateNickname(event: GuildMemberUpdateNicknameEvent) {} + private suspend fun onGuildMemberUpdateTimeOut(event: GuildMemberUpdateTimeOutEvent) {} + + private suspend fun onMessageBulkDelete(event: MessageBulkDeleteEvent) {} + private suspend fun onMessageDelete(event: MessageDeleteEvent) {} + private suspend fun onMessageUpdate(event: MessageUpdateEvent) {} + + private suspend fun onMessagePollVoteAdd(event: MessagePollVoteAddEvent) {} + private suspend fun onMessagePollVoteRemove(event: MessagePollVoteRemoveEvent) {} + + private companion object { + private val ERROR_HANDLER = ErrorHandler().ignore( + ErrorResponse.MISSING_ACCESS, ErrorResponse.MISSING_PERMISSIONS, ErrorResponse.UNKNOWN_CHANNEL + ) + } + +} diff --git a/src/main/kotlin/com/sandrabot/sandra/utils/AccessHelpers.kt b/src/main/kotlin/com/sandrabot/sandra/utils/AccessHelpers.kt index c1e846d..e4484a5 100644 --- a/src/main/kotlin/com/sandrabot/sandra/utils/AccessHelpers.kt +++ b/src/main/kotlin/com/sandrabot/sandra/utils/AccessHelpers.kt @@ -22,18 +22,17 @@ import com.sandrabot.sandra.constants.Category import com.sandrabot.sandra.constants.FeatureFlag import com.sandrabot.sandra.events.CommandEvent -fun isFeatureAllowed(sandra: Sandra, user: Long, feature: FeatureFlag) = !isFeatureRestricted(sandra, user, feature) +fun isFeatureAllowed(sandra: Sandra, id: Long, feature: FeatureFlag) = !isFeatureRestricted(sandra, id, 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 isContextAllowed(sandra: Sandra, userId: Long, guildId: Long?, feature: FeatureFlag) = + !isContextRestricted(sandra, userId, 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 isContextRestricted(sandra: Sandra, userId: Long, guildId: Long?, feature: FeatureFlag): Boolean { + val isUserRevoked = isFeatureRestricted(sandra, userId, feature) + return if (guildId != null) isUserRevoked || isFeatureRestricted(sandra, guildId, feature) else isUserRevoked } fun CommandEvent.isAccessRestricted(): Boolean = when { diff --git a/src/main/kotlin/com/sandrabot/sandra/utils/PermissionHelpers.kt b/src/main/kotlin/com/sandrabot/sandra/utils/PermissionHelpers.kt index 682d6e5..df84327 100644 --- a/src/main/kotlin/com/sandrabot/sandra/utils/PermissionHelpers.kt +++ b/src/main/kotlin/com/sandrabot/sandra/utils/PermissionHelpers.kt @@ -1,11 +1,11 @@ /* - * Copyright 2017-2024 Avery Carroll and Logan Devecka + * Copyright 2026 Avery Carroll, Logan Devecka, and contributors * * 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 + * https://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, @@ -22,7 +22,7 @@ import net.dv8tion.jda.api.Permission fun CommandEvent.missingPermissionMessage(permission: Permission, self: Boolean = true): String { val key = if (self) "core.missing_self_permission" else "core.missing_other_permission" val name = getAny("core.permissions.${permission.translationKey}") - val context = getAny(if (permission.isChannel) "core.channel" else "core.server") + val context = getAny(if (permission.isChannel) "core.phrases.channel" else "core.phrases.server") return getAny(key, name, context) } diff --git a/src/main/resources/content/english.json b/src/main/resources/content/english.json index 1e604c8..247c040 100644 --- a/src/main/resources/content/english.json +++ b/src/main/resources/content/english.json @@ -11,8 +11,6 @@ "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": { "custom": "custom", "essential": "essential", @@ -63,6 +61,18 @@ "manage_permissions": "manage permissions", "moderate_members": "timeout members" }, + "phrases": { + "channel": "channel", + "direct_messages": "direct messages", + "dm_spam": "spam in dms", + "invites": "invites", + "never": "never", + "no_reason": "no reason given", + "raid": "raid", + "server": "server", + "some_time": "some amount of time", + "someone": "someone" + }, "reasons": { "bot_role": "default bot role", "default_roles": "default roles", @@ -373,7 +383,6 @@ "confirmation": "## %1$s are you sure you want to softban %2$s?\ntheir most recent messages will be deleted and cannot be recovered", "success": "## %1$s **%2$s** was soft-banned\nnow, who's gonna invite them back? not me!", "reason": "/softban was used by %1$s; %2$s", - "default_reason": "no reason given", "button_confirm": "confirm softban", "button_cancel": "cancel", "arguments": { @@ -411,5 +420,26 @@ "heads": "heads", "tails": "tails" } + }, + "logging": { + "emoji_create": "%1$s created an emoji %2$s named **%3$s**", + "emoji_delete": "%1$s deleted an emoji named **%2$s**", + "emoji_rename": "%1$s renamed an emoji %2$s to **%3$s** (was **%4$s**)", + "invite_create": "%1$s created an invite in %2$s (**%3$s**)", + "invite_delete": "%1$s deleted an invite in %2$s (**%3$s**)", + "invite_expire": "an invite in %1$s has expired (**%2$s**)", + "member_join": "%1$s (**%2$s**) joined the server, their account was created %3$s", + "member_leave": "%1$s (**%2$s**) left the server, they were a member here for %3$s", + "security_action": "**%1$s** have been temporarily disabled in this server until %2$s", + "security_action_restore": "**%1$s** have been re-enabled in this server following a recent security incident", + "security_detection": "discord has detected a **%1$s** incident in this server that started at %2$s", + "security_detection_restore": "discord has determined that the **%1$s** has concluded", + "sticker_create": "%1$s created a sticker named **%2$s**", + "sticker_delete": "%1$s deleted a sticker named **%2$s**", + "sticker_rename": "%1$s renamed a sticker to **%2$s** (was **%3$s**)", + "sticker_description": "%1$s updated the sticker description for **%2$s**", + "kick": "%1$s kicked %2$s (**%3$s**) from the server because: `%4$s`", + "ban": "%1$s banned %2$s (**%3$s**) from the server because: `%4$s`", + "unban": "%1$s unbanned %2$s (**%3$s**) from the server because: `%4$s`" } }