diff --git a/build.gradle.kts b/build.gradle.kts index f99ca7c1..3ca09839 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -69,6 +69,7 @@ dependencies { implementation(libs.kordex.mappings) implementation(libs.kordex.phishing) implementation(libs.kordex.pluralkit) + implementation(libs.kordex.unsafe) implementation(libs.commons.text) implementation(libs.homoglyph) diff --git a/module-tags/src/main/kotlin/org/quiltmc/community/cozy/modules/tags/TagsExtension.kt b/module-tags/src/main/kotlin/org/quiltmc/community/cozy/modules/tags/TagsExtension.kt index e6dc9287..fd8a5a95 100644 --- a/module-tags/src/main/kotlin/org/quiltmc/community/cozy/modules/tags/TagsExtension.kt +++ b/module-tags/src/main/kotlin/org/quiltmc/community/cozy/modules/tags/TagsExtension.kt @@ -586,9 +586,9 @@ public class TagsExtension : Extension() { } var potentialTags = ( - tagsData.getTagsByPartialKey(tagKey, this.data.guildId.value) + - tagsData.getTagsByPartialTitle(tagKey, this.data.guildId.value) - ) + tagsData.getTagsByPartialKey(tagKey, this.data.guildId.value) + + tagsData.getTagsByPartialTitle(tagKey, this.data.guildId.value) + ) .toSet() .toList() diff --git a/src/main/kotlin/org/quiltmc/community/App.kt b/src/main/kotlin/org/quiltmc/community/App.kt index 2f18fa32..a864c7f8 100644 --- a/src/main/kotlin/org/quiltmc/community/App.kt +++ b/src/main/kotlin/org/quiltmc/community/App.kt @@ -103,6 +103,7 @@ suspend fun setupQuilt() = ExtensibleBot(DISCORD_TOKEN) { extensions { add(::ApplicationsExtension) add(::FilterExtension) + add(::ForumExtension) add(::LookupExtension) add(::MessageLogExtension) add(::MinecraftExtension) diff --git a/src/main/kotlin/org/quiltmc/community/_Constants.kt b/src/main/kotlin/org/quiltmc/community/_Constants.kt index f49924eb..2c690534 100644 --- a/src/main/kotlin/org/quiltmc/community/_Constants.kt +++ b/src/main/kotlin/org/quiltmc/community/_Constants.kt @@ -100,6 +100,10 @@ internal val TOOLCHAIN_COMMUNITY_TEAM_ROLE = envOrNull("TOOLCHAIN_COMMUNITY_TEAM ?.let { Snowflake(it) } ?: Snowflake(863765983890374656) +internal val COMMUNITY_DEVELOPER_CATEGORY = envOrNull("COMMUNITY_DEVELOPER_CATEGORY") + ?.let { Snowflake(it) } + ?: Snowflake(1102169914858541066) + internal val COMMUNITY_DEVELOPER_ROLE = envOrNull("COMMUNITY_DEVELOPER_ROLE") ?.let { Snowflake(it) } ?: Snowflake(972868531844710412) diff --git a/src/main/kotlin/org/quiltmc/community/_Utils.kt b/src/main/kotlin/org/quiltmc/community/_Utils.kt index c668f8df..0092aad5 100644 --- a/src/main/kotlin/org/quiltmc/community/_Utils.kt +++ b/src/main/kotlin/org/quiltmc/community/_Utils.kt @@ -20,6 +20,7 @@ import dev.kord.common.entity.UserFlag import dev.kord.core.Kord import dev.kord.core.behavior.UserBehavior import dev.kord.core.behavior.channel.MessageChannelBehavior +import dev.kord.core.behavior.channel.threads.ThreadChannelBehavior import dev.kord.core.behavior.getChannelOf import dev.kord.core.entity.Guild import dev.kord.core.entity.Message @@ -168,7 +169,7 @@ suspend fun ExtensibleBotBuilder.settings() { fun Guild.getMaxArchiveDuration(): ArchiveDuration { val features = features.filter { it.value == "THREE_DAY_THREAD_ARCHIVE" || - it.value == "SEVEN_DAY_THREAD_ARCHIVE" + it.value == "SEVEN_DAY_THREAD_ARCHIVE" }.map { it.value } return when { @@ -182,7 +183,7 @@ fun Guild.getMaxArchiveDuration(): ArchiveDuration { // Logging-related extensions suspend fun , A : Arguments, M : ModalForm> - SlashCommandContext.getGithubLogChannel(): GuildMessageChannel? { + SlashCommandContext.getGithubLogChannel(): GuildMessageChannel? { val channelId = getKoin().get().get()?.githubLogChannel ?: return null return event.kord.getChannelOf(channelId) @@ -283,3 +284,6 @@ fun String.replaceParams(vararg pairs: Pair): String { fun String.replaceParams(pairs: Map): String = this.replaceParams( *pairs.entries.map { it.toPair() }.toTypedArray() ) + +suspend fun ThreadChannelBehavior.getFirstMessage() = + getMessageOrNull(id) diff --git a/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/ForumExtension.kt b/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/ForumExtension.kt new file mode 100644 index 00000000..d9911e67 --- /dev/null +++ b/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/ForumExtension.kt @@ -0,0 +1,288 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +@file:Suppress("MagicNumber") +@file:OptIn(UnsafeAPI::class, KordUnsafe::class) + +package org.quiltmc.community.modes.quilt.extensions + +import com.kotlindiscord.kord.extensions.checks.hasRole +import com.kotlindiscord.kord.extensions.checks.or +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.converters.impl.channel +import com.kotlindiscord.kord.extensions.commands.converters.impl.optionalChannel +import com.kotlindiscord.kord.extensions.components.forms.ModalForm +import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.extensions.ephemeralSlashCommand +import com.kotlindiscord.kord.extensions.modules.unsafe.annotations.UnsafeAPI +import com.kotlindiscord.kord.extensions.modules.unsafe.extensions.unsafeSubCommand +import com.kotlindiscord.kord.extensions.modules.unsafe.types.InitialSlashCommandResponse +import com.kotlindiscord.kord.extensions.modules.unsafe.types.ackEphemeral +import com.kotlindiscord.kord.extensions.utils.extraData +import dev.kord.common.annotation.KordUnsafe +import dev.kord.common.entity.ChannelType +import dev.kord.core.behavior.channel.asChannelOfOrNull +import dev.kord.core.behavior.channel.createMessage +import dev.kord.core.behavior.channel.threads.edit +import dev.kord.core.behavior.edit +import dev.kord.core.behavior.interaction.modal +import dev.kord.core.behavior.interaction.response.createEphemeralFollowup +import dev.kord.core.entity.channel.ForumChannel +import dev.kord.core.entity.channel.thread.ThreadChannel +import kotlinx.coroutines.delay +import org.quiltmc.community.* +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +class ForumExtension : Extension() { + override val name: String = "forum" + + override suspend fun setup() { + ephemeralSlashCommand { + name = "forum" + description = "Forum channel management commands" + + check { + hasBaseModeratorRole(true) + + or { + hasRole(COMMUNITY_DEVELOPER_ROLE) + + if (passed) { + event.extraData["isDeveloper"] = true + } + } + } + + unsafeSubCommand(::ForumChannelArgs) { + name = "create-post" + description = "Create a Cozy-managed forum post" + + initialResponse = InitialSlashCommandResponse.None + + action { + val isDeveloper: Boolean = event.extraData.getOrDefault("isDeveloper", false) as Boolean + val forum = arguments.channel.asChannelOfOrNull() + + if (forum == null) { + ackEphemeral { + content = "Please provide a forum channel to create a post within." + } + + return@action + } + + if (isDeveloper && forum.categoryId != COMMUNITY_DEVELOPER_CATEGORY) { + ackEphemeral { + content = "Quilt Developers may only use this command to create posts in the developer " + + "forum channels." + } + + return@action + } + + val form = PostModal() + + this@unsafeSubCommand.componentRegistry.register(form) + + event.interaction.modal( + form.translateTitle(getLocale(), bundle), + form.id + ) { + form.applyToBuilder(this, getLocale(), bundle) + } + + val interactionResponse = form.awaitCompletion { + it?.deferEphemeralResponseUnsafe() + } ?: return@action + + val text = form.postText.value + val title = form.postTitle.value + + if (text == null || title == null) { + interactionResponse.createEphemeralFollowup { + content = "Please provide the post title and text." + } + + return@action + } + + val thread = forum.startPublicThread(title) { + // TODO: Tags? + + message(text) + } + + interactionResponse.createEphemeralFollowup { + content = "Post created." + } + + delay(1.seconds) + + val setupMessage = thread.createMessage { + content = "Just a moment while we finish setting up..." + } + + val firstMessage = thread.getFirstMessage()!! + + firstMessage.pin("First message in the forum post") + + setupMessage.edit { + content = "Adding role: <@&$COMMUNITY_MODERATOR_ROLE>" + } + + delay(1.seconds) + + setupMessage.edit { + content = "Adding role: <@&$COMMUNITY_DEVELOPER_ROLE>" + } + + delay(1.seconds) + + setupMessage.delete("Removing initial setup message") + } + } + + unsafeSubCommand(::ForumPostArgs) { + name = "edit-post" + description = "Edit an existing Cozy-managed forum post" + + initialResponse = InitialSlashCommandResponse.None + + action { + val isDeveloper: Boolean = event.extraData.getOrDefault("isDeveloper", false) as Boolean + + val thread = (arguments.post ?: channel) + .asChannelOfOrNull() + + val parent = thread + ?.parent + ?.asChannelOfOrNull() + + if (parent == null) { + ackEphemeral { + content = "Please provide a forum thread to edit the first post for, or run this " + + "command directly within the thread." + } + + return@action + } + + if (isDeveloper && parent.categoryId != COMMUNITY_DEVELOPER_CATEGORY) { + ackEphemeral { + content = "Quilt Developers may only use this command for threads in the developer " + + "forum channels." + } + + return@action + } + + val firstMessage = thread.getFirstMessage() + + if (firstMessage == null) { + ackEphemeral { + content = "Unable to find the first message for this thread - is it a Cozy-managed thread?" + } + + return@action + } + + val form = PostModal(thread.name, firstMessage.content) + + this@unsafeSubCommand.componentRegistry.register(form) + + event.interaction.modal( + form.translateTitle(getLocale(), bundle), + form.id + ) { + form.applyToBuilder(this, getLocale(), bundle) + } + + val interactionResponse = form.awaitCompletion { + it?.deferEphemeralResponseUnsafe() + } ?: return@action + + val text = form.postText.value + val title = form.postTitle.value + + if (text == null || title == null) { + interactionResponse.createEphemeralFollowup { + content = "Please provide the post title and text." + } + + return@action + } + + if (title != thread.name) { + thread.edit { name = title } + } + + if (text != firstMessage.content) { + firstMessage.edit { content = text } + } + + interactionResponse.createEphemeralFollowup { + content = "Post edited." + } + } + } + } + } + + inner class ForumChannelArgs : Arguments() { + val channel by channel { + name = "channel" + description = "Forum channel to post in" + + requireChannelType(ChannelType.GuildForum) + } + } + + inner class ForumPostArgs : Arguments() { + val post by optionalChannel { + name = "post" + description = "Thread to edit the first post for" + + requireChannelType(ChannelType.PublicGuildThread) + } + } + + inner class PostModal( + private val givenTitle: String? = null, + private val givenText: String? = null, + ) : ModalForm() { + override var title: String = "Create/Edit Post" + override val timeout: Duration = 15.minutes + + val postTitle = lineText { + label = "Post Text" + + minLength = 1 + required = true + + if (givenTitle == null) { + placeholder = "Forum post title" + } else { + initialValue = givenTitle + } + } + + val postText = paragraphText { + label = "Post Text" + + maxLength = 4000 + minLength = 1 + required = true + + if (givenText == null) { + placeholder = "Forum post text - supports all Discord Markdown" + } else { + initialValue = givenText + } + } + } +} diff --git a/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/UtilityExtension.kt b/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/UtilityExtension.kt index 3566757a..6b928a5e 100644 --- a/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/UtilityExtension.kt +++ b/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/UtilityExtension.kt @@ -263,7 +263,7 @@ class UtilityExtension : Extension() { } } - (GUILDS + COLLAB_GUILD).forEach { guildId -> + (GUILDS + COLLAB_GUILD).toSet().forEach { guildId -> ephemeralMessageCommand(::EventModal) { name = "Log Event" allowInDms = false diff --git a/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/settings/SettingsExtension.kt b/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/settings/SettingsExtension.kt index ea82418c..bf25a746 100644 --- a/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/settings/SettingsExtension.kt +++ b/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/settings/SettingsExtension.kt @@ -83,7 +83,7 @@ class SettingsExtension : Extension() { } } - (GUILDS + COLLAB_GUILD).forEach { guildId -> + (GUILDS + COLLAB_GUILD).toSet().forEach { guildId -> ephemeralSlashCommand { name = "config" description = "Manage your bot settings"