diff --git a/.idea/misc.xml b/.idea/misc.xml index 3657fb2..b838806 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/build.gradle.kts b/build.gradle.kts index 98955df..ab1749f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,7 +35,9 @@ val klogging: String by project dependencies { implementation(kotlin("stdlib")) + implementation(kotlin("reflect")) implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:$serialization") + implementation("com.kotlindiscord.kord.extensions:kord-extensions:$kordex") implementation("io.github.oshai:kotlin-logging:$klogging") diff --git a/src/main/kotlin/quest/laxla/supertrouper/App.kt b/src/main/kotlin/quest/laxla/supertrouper/App.kt index a36c02b..ef05775 100644 --- a/src/main/kotlin/quest/laxla/supertrouper/App.kt +++ b/src/main/kotlin/quest/laxla/supertrouper/App.kt @@ -2,20 +2,21 @@ package quest.laxla.supertrouper import com.kotlindiscord.kord.extensions.ExtensibleBot import com.kotlindiscord.kord.extensions.utils.env -import dev.kord.common.entity.Snowflake +import com.kotlindiscord.kord.extensions.utils.envOrNull import kotlinx.coroutines.runBlocking -private val token = env("token") -val officialServer = env("official_server") +private val token = env("TOKEN") +private val testingServer = envOrNull("TESTING_SERVER") fun main() = runBlocking { ExtensibleBot(token) { - extensions { - add(::MaintenanceExtension) + applicationCommands { + testingServer?.let { defaultGuild(it) } } - applicationCommands { - defaultGuild(Snowflake(officialServer)) + extensions { + add(::MaintenanceExtension) + add(::PrivateMassagingExtension) } }.start() } diff --git a/src/main/kotlin/quest/laxla/supertrouper/MaintenanceExtension.kt b/src/main/kotlin/quest/laxla/supertrouper/MaintenanceExtension.kt index 927fc67..322e091 100644 --- a/src/main/kotlin/quest/laxla/supertrouper/MaintenanceExtension.kt +++ b/src/main/kotlin/quest/laxla/supertrouper/MaintenanceExtension.kt @@ -1,16 +1,18 @@ package quest.laxla.supertrouper +import com.kotlindiscord.kord.extensions.checks.isBotAdmin import com.kotlindiscord.kord.extensions.extensions.publicSlashCommand -import dev.kord.common.entity.Permission -import dev.kord.common.entity.Snowflake +import com.kotlindiscord.kord.extensions.extensions.slashCommandCheck class MaintenanceExtension : TrouperExtension() { override suspend fun setup() { + slashCommandCheck { + isBotAdmin() + } + publicSlashCommand { name = "stop" description = "WARNING: Stops the bot completely." - guildId = Snowflake(officialServer) - requirePermission(Permission.Administrator) action { //language=Markdown diff --git a/src/main/kotlin/quest/laxla/supertrouper/Overwrites.kt b/src/main/kotlin/quest/laxla/supertrouper/Overwrites.kt new file mode 100644 index 0000000..d299df1 --- /dev/null +++ b/src/main/kotlin/quest/laxla/supertrouper/Overwrites.kt @@ -0,0 +1,53 @@ +package quest.laxla.supertrouper + +import dev.kord.common.entity.Overwrite +import dev.kord.common.entity.OverwriteType +import dev.kord.common.entity.Permissions +import dev.kord.common.entity.Snowflake +import dev.kord.core.entity.PermissionOverwriteEntity +import dev.kord.rest.builder.channel.PermissionOverwritesBuilder + +fun overwrite( + target: Snowflake, + type: OverwriteType, + allowed: Permissions = Permissions(), + denied: Permissions = Permissions() +) = Overwrite(target, type, allowed, denied) + +fun PermissionOverwritesBuilder.addOverwrite( + target: Snowflake, + type: OverwriteType, + allowed: Permissions = Permissions(), + denied: Permissions = Permissions() +) = addOverwrite(overwrite(target, type, allowed, denied)) + +fun PermissionOverwritesBuilder.sync( + vararg overrides: Overwrite, + defaults: Iterable +) = sync(overrides.asIterable(), defaults) + +fun PermissionOverwritesBuilder.sync( + overrides: Iterable, + defaults: Iterable +) { + val permissions = mutableMapOf() + + defaults.forEach { default -> + val override = overrides.find { it.id == default.target && it.type == default.type } + + if (override == null) addOverwrite(default.target, default.type, default.allowed, default.denied) + else permissions[override] = default + } + + overrides.forEach { override -> + val default = permissions[override] + + if (default == null) addOverwrite(override) + else addOverwrite( + default.target, + default.type, + default.allowed - default.denied - override.deny + override.allow, + default.denied - default.allowed - override.allow + override.deny + ) + } +} diff --git a/src/main/kotlin/quest/laxla/supertrouper/PrivateMassagingExtension.kt b/src/main/kotlin/quest/laxla/supertrouper/PrivateMassagingExtension.kt new file mode 100644 index 0000000..00b53e3 --- /dev/null +++ b/src/main/kotlin/quest/laxla/supertrouper/PrivateMassagingExtension.kt @@ -0,0 +1,154 @@ +package quest.laxla.supertrouper + +import com.kotlindiscord.kord.extensions.checks.anyGuild +import com.kotlindiscord.kord.extensions.checks.isNotBot +import com.kotlindiscord.kord.extensions.extensions.* +import com.kotlindiscord.kord.extensions.utils.any +import com.kotlindiscord.kord.extensions.utils.envOrNull +import dev.kord.common.entity.OverwriteType +import dev.kord.common.entity.Permission +import dev.kord.core.behavior.GuildBehavior +import dev.kord.core.behavior.channel.createTextChannel +import dev.kord.core.behavior.channel.edit +import dev.kord.core.behavior.createCategory +import dev.kord.core.entity.User +import dev.kord.core.entity.channel.Category +import dev.kord.core.entity.channel.TextChannel +import dev.kord.core.event.guild.MemberJoinEvent +import dev.kord.gateway.Intent +import dev.kord.gateway.PrivilegedIntent +import dev.kord.rest.builder.channel.addMemberOverwrite +import dev.kord.rest.builder.channel.addRoleOverwrite +import kotlinx.coroutines.flow.* + +private const val PrivateMessagesCategoryName = "Private Messages" +private val memberLimit = envOrNull("AUTOMATIC_CHANNEL_CREATION_MEMBER_LIMIT")?.toInt() ?: 30 +private val privateMessageOwnerPermissions = Permission.ViewChannel + Permission.ReadMessageHistory +private val privateMessageBotPermissions = + privateMessageOwnerPermissions + Permission.ManageChannels + Permission.SendMessages + Permission.ManageMessages + +class PrivateMassagingExtension : TrouperExtension() { + @OptIn(PrivilegedIntent::class) + override suspend fun setup() { + intents += Intent.GuildMembers + + slashCommandCheck { + anyGuild() + isNotBot() + } + + userCommandCheck { + anyGuild() + isNotBot() + } + + event { + action { + if (event.guild.members.count() < memberLimit) getOrCreateChannel(event.guild, event.member) + } + } + + ephemeralSlashCommand(::TargetedArguments) { + name = "pm" + description = "Get a link to a user's private messages channel" + + action { + respond { + content = getOrCreateChannelMention(guild!!, target.asUser()) + } + } + } + + ephemeralUserCommand { + name = "Private Message" + + action { + respond { + content = getOrCreateChannelMention(guild!!, targetUsers.single()) + } + } + } + + ephemeralSlashCommand(::TargetedArguments) slash@{ + name = "sync" + description = "Syncs a private message channel's permissions with the category." + + requirePermission(Permission.ManageRoles) + + action { + val category = getOrCreateCategory(guild!!) + val targetUser = target.asUser() + val targetChannel = getChannel(category, targetUser) + if (targetChannel == null) { + respond { content = "${target.mention} does not have a private message channel in this server." } + return@action + } + + targetChannel.edit { + sync( + overwrite(this@slash.kord.selfId, OverwriteType.Member, allowed = privateMessageBotPermissions), + overwrite(targetUser.id, OverwriteType.Member, allowed = privateMessageOwnerPermissions), + defaults = category.permissionOverwrites + ) + } + + respond { + content = "Synced ${targetChannel.mention} for ${target.mention} successfully." + } + } + } + } + + private suspend fun getOrCreateChannelMention(guild: GuildBehavior, user: User): String = + user.mention + ": " + (getOrCreateChannel(guild, user)?.mention ?: "Ineligible") + + private suspend fun getOrCreateChannel(guild: GuildBehavior, user: User) = + getOrCreateChannel(getOrCreateCategory(guild), user) + + private suspend fun getOrCreateCategory(guild: GuildBehavior) = getCategory(guild) ?: createCategory(guild) + + private suspend fun getCategory(guild: GuildBehavior) = guild.channels.filterIsInstance().filter { + it.name.equals(PrivateMessagesCategoryName, ignoreCase = true) + }.singleOrNull() + + private suspend fun createCategory(guild: GuildBehavior) = guild.createCategory(PrivateMessagesCategoryName) { + reason = "Private messaging category was missing." + nsfw = false + + addMemberOverwrite(kord.selfId) { + allowed += privateMessageBotPermissions + } + + addRoleOverwrite(guild.id) { + denied += Permission.ViewChannel + } + } + + private suspend fun getOrCreateChannel(category: Category, user: User) = + if (user.isBot) null else getChannel(category, user) ?: createChannel(category, user) + + private suspend fun getChannel(category: Category, user: User) = + category.channels.filterIsInstance().firstOrNull { channel -> + channel.categoryId == category.id && (channel.topic?.contains(user.mention) == true || channel.pinnedMessages.any { + it.author?.id == kord.selfId && it.mentionedUserIds.singleOrNull() == user.id + }) + } + + private suspend fun createChannel(category: Category, user: User): TextChannel { + val mention = user.mention + + val channel = category.createTextChannel(user.username) { + reason = "Created a PM with $mention." + nsfw = category.data.nsfw.discordBoolean + topic = mention + + sync( + overwrite(kord.selfId, OverwriteType.Member, allowed = privateMessageBotPermissions), + overwrite(user.id, OverwriteType.Member, allowed = privateMessageOwnerPermissions), + defaults = category.permissionOverwrites + ) + } + + return channel + } +} diff --git a/src/main/kotlin/quest/laxla/supertrouper/TargetedArguments.kt b/src/main/kotlin/quest/laxla/supertrouper/TargetedArguments.kt new file mode 100644 index 0000000..1f2ef39 --- /dev/null +++ b/src/main/kotlin/quest/laxla/supertrouper/TargetedArguments.kt @@ -0,0 +1,19 @@ +package quest.laxla.supertrouper + +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.application.slash.SlashCommandContext +import com.kotlindiscord.kord.extensions.commands.converters.impl.optionalUser +import com.kotlindiscord.kord.extensions.components.forms.ModalForm + +private const val TargetArgumentName = "target" +private const val TargetArgumentDescription = "Target of this command. Defaults to you." + +open class TargetedArguments : Arguments() { + val targetOrNull by optionalUser { + name = TargetArgumentName + description = TargetArgumentDescription + } +} + +val C.target where C : SlashCommandContext<*, A, M>, A : TargetedArguments, M : ModalForm + get() = arguments.targetOrNull ?: user diff --git a/src/main/kotlin/quest/laxla/supertrouper/TrouperExtension.kt b/src/main/kotlin/quest/laxla/supertrouper/TrouperExtension.kt index 17183e6..7c06e3e 100644 --- a/src/main/kotlin/quest/laxla/supertrouper/TrouperExtension.kt +++ b/src/main/kotlin/quest/laxla/supertrouper/TrouperExtension.kt @@ -2,7 +2,9 @@ package quest.laxla.supertrouper import com.kotlindiscord.kord.extensions.extensions.Extension +private const val NameRegexGroup = "name" + abstract class TrouperExtension : Extension() { final override val name: String = this::class.simpleName!!.substringBeforeLast("Extension") - .replace("([A-Z])".toRegex()) { '-' + it.groups.single()!!.value.lowercase() }.removePrefix("-") + .replace("(?<$NameRegexGroup>[A-Z])".toRegex()) { '-' + it.groups[NameRegexGroup]!!.value.lowercase() }.removePrefix("-") }