diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60c02b50cc..ce319e3adc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,14 +26,16 @@ jobs: echo "org.gradle.jvmargs=-XX:MaxMetaspaceSize=5G" >> ~/.gradle/gradle.properties - name: Gradle (Build) - run: sh gradlew build + uses: gradle/gradle-build-action@v2 + with: + arguments: build - name: Upload artifact (Extra Module JARs) uses: actions/upload-artifact@v2 with: name: JARs (Extra Modules) - path: extra/*/build/libs/*.jar + path: extra-modules/*/build/libs/*.jar - name: Upload artifact (Main JARs) uses: actions/upload-artifact@v2 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 393e8294d2..14b94feeae 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,17 +18,30 @@ jobs: with: java-version: 1.14 + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@v3 + + with: + gpg-private-key: ${{ secrets.GPG_KEY }} + passphrase: ${{ secrets.GPG_PASSWORD }} + - name: Set up Gradle properties run: | mkdir -p ~/.gradle echo "githubToken=${{ secrets.GITHUB_TOKEN }}" >> ~/.gradle/gradle.properties echo -e "\norg.gradle.jvmargs=-XX:MaxMetaspaceSize=5G" >> ~/.gradle/gradle.properties + echo -e "\nsigning.gnupg.keyName=BFAAD5D6093EF5E62BC9A16A10DB8C6B4AE61C2F" >> ~/.gradle/gradle.properties + echo -e "\nsigning.gnupg.passphrase=${{ secrets.GPG_PASSWORD }}" >> ~/.gradle/gradle.properties - name: Gradle (Build) - run: sh gradlew build + uses: gradle/gradle-build-action@v2 + with: + arguments: build - name: Gradle (Publish) - run: sh gradlew -Pkotdis.user=${{ secrets.MAVEN_USER }} -Pkotdis.password=${{ secrets.MAVEN_PASSWORD }} publish + uses: gradle/gradle-build-action@v2 + with: + arguments: publish -Pkotdis.user=${{ secrets.MAVEN_USER }} -Pkotdis.password=${{ secrets.MAVEN_PASSWORD }} - name: Upload artifact (Extra Module JARs) uses: actions/upload-artifact@v2 diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index 0964254988..eb3e582b79 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -16,7 +16,14 @@ jobs: uses: actions/setup-java@v1 with: - java-version: 1.11 + java-version: 1.14 + + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@v3 + + with: + gpg-private-key: ${{ secrets.GPG_KEY }} + passphrase: ${{ secrets.GPG_PASSWORD }} - name: Set up Kotlin uses: fwilhe2/setup-kotlin@main @@ -25,6 +32,8 @@ jobs: run: | mkdir -p ~/.gradle echo "org.gradle.jvmargs=-XX:MaxMetaspaceSize=5G" >> ~/.gradle/gradle.properties + echo -e "\nsigning.gnupg.keyName=BFAAD5D6093EF5E62BC9A16A10DB8C6B4AE61C2F" >> ~/.gradle/gradle.properties + echo -e "\nsigning.gnupg.passphrase=${{ secrets.GPG_PASSWORD }}" >> ~/.gradle/gradle.properties - name: Set up git credentials uses: oleksiyrudenko/gha-git-credentials@v2-latest @@ -34,10 +43,14 @@ jobs: token: '${{ secrets.GITHUB_TOKEN }}' - name: Gradle (Build) - run: sh gradlew build + uses: gradle/gradle-build-action@v2 + with: + arguments: build - name: Gradle (Publish) - run: sh gradlew -Pkotdis.user=${{ secrets.MAVEN_USER }} -Pkotdis.password=${{ secrets.MAVEN_PASSWORD }} publish + uses: gradle/gradle-build-action@v2 + with: + arguments: publish -Pkotdis.user=${{ secrets.MAVEN_USER }} -Pkotdis.password=${{ secrets.MAVEN_PASSWORD }} - name: Create release description run: kotlin .github/tag.main.kts diff --git a/.idea/GradleUpdaterPlugin.xml b/.idea/GradleUpdaterPlugin.xml new file mode 100644 index 0000000000..6ed5855a3a --- /dev/null +++ b/.idea/GradleUpdaterPlugin.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 6f6540bdc8..dfaf95a0b3 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -2,9 +2,6 @@ - - - diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml index 3305aa1d69..8fc19501fa 100644 --- a/.idea/jarRepositories.xml +++ b/.idea/jarRepositories.xml @@ -61,5 +61,15 @@ \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000000..797acea53e --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 97bb578388..6b28a199ef 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,11 @@ # Kord Extensions -[![Docs: Click here](https://img.shields.io/static/v1?label=Docs&message=Click%20here&color=7289DA&style=for-the-badge&logo=read-the-docs)](https://kordex.kotlindiscord.com/) [![Discord: Click here](https://img.shields.io/static/v1?label=Discord&message=Click%20here&color=7289DA&style=for-the-badge&logo=discord)](https://discord.gg/gjXqqCS) [![Build Status](https://img.shields.io/github/workflow/status/Kotlin-Discord/kord-extensions/CI/root?logo=github&style=for-the-badge)](https://github.com/Kotlin-Discord/kord-extensions/actions?query=workflow%3ACI+branch%3Aroot)
+[![Docs: Click here](https://img.shields.io/static/v1?label=Docs&message=Click%20here&color=7289DA&style=for-the-badge&logo=read-the-docs)](https://kordex.kotlindiscord.com/) [![Discord: Click here](https://img.shields.io/static/v1?label=Discord&message=Click%20here&color=7289DA&style=for-the-badge&logo=discord)](https://discord.gg/gjXqqCS)
+[![Build Status](https://img.shields.io/github/workflow/status/Kotlin-Discord/kord-extensions/CI/root?logo=github&style=for-the-badge)](https://github.com/Kotlin-Discord/kord-extensions/actions?query=workflow%3ACI+branch%3Aroot) [![Weblate project translated](https://img.shields.io/weblate/progress/kord-extensions?style=for-the-badge)]((https://hosted.weblate.org/engage/kord-extensions/))
[![Release](https://img.shields.io/nexus/r/com.kotlindiscord.kord.extensions/kord-extensions?nexusVersion=3&logo=gradle&color=blue&label=Release&server=https%3A%2F%2Fmaven.kotlindiscord.com&style=for-the-badge)](https://maven.kotlindiscord.com/#browse/browse:maven-releases:com%2Fkotlindiscord%2Fkord%2Fextensions%2Fkord-extensions) [![Snapshot](https://img.shields.io/nexus/s/com.kotlindiscord.kord.extensions/kord-extensions?logo=gradle&color=orange&label=Snapshot&server=https%3A%2F%2Fmaven.kotlindiscord.com&style=for-the-badge)](https://maven.kotlindiscord.com/#browse/browse:maven-snapshots:com%2Fkotlindiscord%2Fkord%2Fextensions%2Fkord-extensions) +[![Translation status](https://hosted.weblate.org/widgets/kord-extensions/-/main/287x66-grey.png)](https://hosted.weblate.org/engage/kord-extensions/) + Kord Extensions is an addon for the excellent [Kord library](https://github.com/kordlib/kord). It intends to provide a framework for larger bot projects, with easy-to-use commands, rich argument parsing and event handling, wrapped up into individual extension classes. @@ -14,22 +17,3 @@ for our fairly object-oriented design, especially where it comes to its extensio Discord.py). Despite this, we still strive to provide an idiomatic API that makes full use of Kotlin's niceties. If you're ready to get started, please [take a look at the documentation](https://kordex.kotlindiscord.com/). - -# Why not kordx.commands? - -Kord has released their own command framework, [kordx.commands](https://github.com/kordlib/kordx.commands). It's -a competent library, but it takes some very different approaches to solving the same problems Kord Extensions does. -Most notably, it requires the use of [kapt](https://kotlinlang.org/docs/reference/kapt.html) and makes use of an -annotation-based autowire system for getting things registered. - -In contrast, Kord Extensions provides a less magical approach that is more closely tied to object-oriented -programming, and may be more suitable for embedding into other applications. In addition, it provides many useful -utilities and niceties that make working with Kord a breeze. At the end of the day, though, the -choice is yours - both approaches have pros and cons, and it's worth checking both out to see what you like -better! - -# Under Development - -This file is in an early state, and we're working on bringing over our framework from our bot project. Once we're -happy with what we've done, and we've written up some documentation, we'll update this file and make a proper -release. diff --git a/annotation-processor/build.gradle.kts b/annotation-processor/build.gradle.kts index d9c6f0a929..e24dc4d2ba 100644 --- a/annotation-processor/build.gradle.kts +++ b/annotation-processor/build.gradle.kts @@ -1,13 +1,7 @@ -import java.io.ByteArrayOutputStream -import java.net.URL - plugins { - `maven-publish` - - kotlin("jvm") - - id("io.gitlab.arturbosch.detekt") - id("org.jetbrains.dokka") + `kordex-module` + `published-module` + `dokka-module` } dependencies { @@ -22,124 +16,10 @@ dependencies { detektPlugins(libs.detekt) } -val sourceJar = task("sourceJar", Jar::class) { - dependsOn(tasks["classes"]) - archiveClassifier.set("sources") - from(sourceSets.main.get().allSource) -} - -val javadocJar = task("javadocJar", Jar::class) { - dependsOn("dokkaJavadoc") - archiveClassifier.set("javadoc") - from(tasks.javadoc) -} - -java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 -} - -kotlin { - explicitApi() -} - -detekt { - buildUponDefaultConfig = true - config = files("$rootDir/detekt.yml") - - autoCorrect = true -} - -publishing { - repositories { - maven { - name = "KotDis" - - url = if (project.version.toString().contains("SNAPSHOT")) { - uri("https://maven.kotlindiscord.com/repository/maven-snapshots/") - } else { - uri("https://maven.kotlindiscord.com/repository/maven-releases/") - } - - credentials { - username = project.findProperty("kotdis.user") as String? ?: System.getenv("KOTLIN_DISCORD_USER") - password = project.findProperty("kotdis.password") as String? - ?: System.getenv("KOTLIN_DISCORD_PASSWORD") - } - - version = project.version - } - } - - publications { - create("maven") { - from(components.getByName("java")) - - artifact(sourceJar) - artifact(javadocJar) - } - } -} - -fun runCommand(command: String): String { - val output = ByteArrayOutputStream() - - project.exec { - commandLine(command.split(" ")) - standardOutput = output - } - - return output.toString().trim() -} - -fun getCurrentGitBranch(): String { // https://gist.github.com/lordcodes/15b2a4aecbeff7c3238a70bfd20f0931 - var gitBranch = "Unknown branch" - - try { - gitBranch = runCommand("git rev-parse --abbrev-ref HEAD") - } catch (t: Throwable) { - println(t) - } - - return gitBranch -} - -tasks.dokkaHtml.configure { +dokkaModule { moduleName.set("Kord Extensions: Annotation Processor") - - dokkaSourceSets { - configureEach { - includeNonPublic.set(false) - skipDeprecated.set(false) - - displayName.set("Kord Extensions: Java Time") - includes.from("packages.md") - jdkVersion.set(8) - - sourceLink { - localDirectory.set(file("${project.projectDir}/src/main/kotlin")) - - remoteUrl.set( - URL( - "https://github.com/Kotlin-Discord/kord-extensions/" + - "tree/${getCurrentGitBranch()}/annotation-processor/src/main/kotlin" - ) - ) - - remoteLineSuffix.set("#L") - } - - externalDocumentationLink { - url.set(URL("http://kordlib.github.io/kord/common/common/")) - } - - externalDocumentationLink { - url.set(URL("http://kordlib.github.io/kord/core/core/")) - } - } - } } -tasks.build { - this.finalizedBy(sourceJar, javadocJar) +kordex { + jvmTarget.set("1.8") } diff --git a/annotation-processor/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/annotations/converters/ConverterProcessor.kt b/annotation-processor/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/annotations/converters/ConverterProcessor.kt index de6ca2eaae..9abb8d280e 100644 --- a/annotation-processor/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/annotations/converters/ConverterProcessor.kt +++ b/annotation-processor/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/annotations/converters/ConverterProcessor.kt @@ -228,7 +228,7 @@ public class ConverterProcessor( // Imports that all converters need import com.kotlindiscord.kord.extensions.commands.converters.* - import com.kotlindiscord.kord.extensions.commands.parser.Arguments + import com.kotlindiscord.kord.extensions.commands.Arguments import dev.kord.common.annotation.KordPreview diff --git a/annotations/build.gradle.kts b/annotations/build.gradle.kts index 6f6205391c..6e28ddcbe2 100644 --- a/annotations/build.gradle.kts +++ b/annotations/build.gradle.kts @@ -1,13 +1,7 @@ -import java.io.ByteArrayOutputStream -import java.net.URL - plugins { - `maven-publish` - - kotlin("jvm") - - id("io.gitlab.arturbosch.detekt") - id("org.jetbrains.dokka") + `kordex-module` + `published-module` + `dokka-module` } dependencies { @@ -16,124 +10,10 @@ dependencies { detektPlugins(libs.detekt) } -val sourceJar = task("sourceJar", Jar::class) { - dependsOn(tasks["classes"]) - archiveClassifier.set("sources") - from(sourceSets.main.get().allSource) -} - -val javadocJar = task("javadocJar", Jar::class) { - dependsOn("dokkaJavadoc") - archiveClassifier.set("javadoc") - from(tasks.javadoc) -} - -java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 -} - -kotlin { - explicitApi() -} - -detekt { - buildUponDefaultConfig = true - config = files("$rootDir/detekt.yml") - - autoCorrect = true -} - -publishing { - repositories { - maven { - name = "KotDis" - - url = if (project.version.toString().contains("SNAPSHOT")) { - uri("https://maven.kotlindiscord.com/repository/maven-snapshots/") - } else { - uri("https://maven.kotlindiscord.com/repository/maven-releases/") - } - - credentials { - username = project.findProperty("kotdis.user") as String? ?: System.getenv("KOTLIN_DISCORD_USER") - password = project.findProperty("kotdis.password") as String? - ?: System.getenv("KOTLIN_DISCORD_PASSWORD") - } - - version = project.version - } - } - - publications { - create("maven") { - from(components.getByName("java")) - - artifact(sourceJar) - artifact(javadocJar) - } - } -} - -fun runCommand(command: String): String { - val output = ByteArrayOutputStream() - - project.exec { - commandLine(command.split(" ")) - standardOutput = output - } - - return output.toString().trim() -} - -fun getCurrentGitBranch(): String { // https://gist.github.com/lordcodes/15b2a4aecbeff7c3238a70bfd20f0931 - var gitBranch = "Unknown branch" - - try { - gitBranch = runCommand("git rev-parse --abbrev-ref HEAD") - } catch (t: Throwable) { - println(t) - } - - return gitBranch -} - -tasks.dokkaHtml.configure { +dokkaModule { moduleName.set("Kord Extensions: Annotation Processor") - - dokkaSourceSets { - configureEach { - includeNonPublic.set(false) - skipDeprecated.set(false) - - displayName.set("Kord Extensions: Java Time") - includes.from("packages.md") - jdkVersion.set(8) - - sourceLink { - localDirectory.set(file("${project.projectDir}/src/main/kotlin")) - - remoteUrl.set( - URL( - "https://github.com/Kotlin-Discord/kord-extensions/" + - "tree/${getCurrentGitBranch()}/annotation-processor/src/main/kotlin" - ) - ) - - remoteLineSuffix.set("#L") - } - - externalDocumentationLink { - url.set(URL("http://kordlib.github.io/kord/common/common/")) - } - - externalDocumentationLink { - url.set(URL("http://kordlib.github.io/kord/core/core/")) - } - } - } } -tasks.build { - this.finalizedBy(sourceJar, javadocJar) +kordex { + jvmTarget.set("1.8") } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000000..9aabad9254 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + `kotlin-dsl` +} + +repositories { + google() + gradlePluginPortal() +} + +dependencies { + implementation(kotlin("gradle-plugin", version = "1.5.30")) + implementation(kotlin("serialization", version = "1.5.30")) + implementation("org.jetbrains.dokka", "dokka-gradle-plugin", "1.5.0") + implementation("com.google.devtools.ksp", "com.google.devtools.ksp.gradle.plugin", "1.5.30-1.0.0-beta08") + implementation("io.gitlab.arturbosch.detekt", "detekt-gradle-plugin", "1.17.1") + implementation(gradleApi()) + implementation(localGroovy()) +} diff --git a/buildSrc/src/main/kotlin/GitTools.kt b/buildSrc/src/main/kotlin/GitTools.kt new file mode 100644 index 0000000000..cf59b32922 --- /dev/null +++ b/buildSrc/src/main/kotlin/GitTools.kt @@ -0,0 +1,25 @@ +import org.gradle.api.Project +import java.io.ByteArrayOutputStream + +fun Project.runCommand(command: String): String { + val output = ByteArrayOutputStream() + + exec { + commandLine(command.split(" ")) + standardOutput = output + } + + return output.toString().trim() +} + +fun Project.getCurrentGitBranch(): String { // https://gist.github.com/lordcodes/15b2a4aecbeff7c3238a70bfd20f0931 + var gitBranch = "Unknown branch" + + try { + gitBranch = runCommand("git rev-parse --abbrev-ref HEAD") + } catch (t: Throwable) { + println(t) + } + + return gitBranch +} diff --git a/buildSrc/src/main/kotlin/disable-explicit-api-mode.gradle.kts b/buildSrc/src/main/kotlin/disable-explicit-api-mode.gradle.kts new file mode 100644 index 0000000000..be996c1466 --- /dev/null +++ b/buildSrc/src/main/kotlin/disable-explicit-api-mode.gradle.kts @@ -0,0 +1,23 @@ +import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode + +plugins { + kotlin("jvm") +} + +kotlin { + // https://github.com/JetBrains/kotlin/pull/4598 + fixExplicitApiModeArg() + // We still need to set this, because the IntelliJ Kotlin plugin Inspections + // look for this option instead of the CLI arg + explicitApi = ExplicitApiMode.Disabled +} + +fun fixExplicitApiModeArg() { + val clazz = ExplicitApiMode.Disabled.javaClass + val field = clazz.getDeclaredField("cliOption") + + with(field) { + isAccessible = true + set(ExplicitApiMode.Disabled, "disable") + } +} diff --git a/buildSrc/src/main/kotlin/dokka-module.gradle.kts b/buildSrc/src/main/kotlin/dokka-module.gradle.kts new file mode 100644 index 0000000000..5fbe4a0c16 --- /dev/null +++ b/buildSrc/src/main/kotlin/dokka-module.gradle.kts @@ -0,0 +1,61 @@ +import java.net.URL + +plugins { + id("org.jetbrains.dokka") +} + +val dokkaModuleExtensionName = "dokkaModule" + +abstract class DokkaModuleExtension { + abstract val moduleName: Property + abstract val includes: ListProperty +} + +extensions.create(dokkaModuleExtensionName) + +tasks { + afterEvaluate { + val projectDir = project.projectDir.relativeTo(rootProject.rootDir).toString() + dokkaHtml { + val extension = project.extensions.getByName(dokkaModuleExtensionName) + extension.moduleName.orNull?.let { + moduleName.set(it) + } + + dokkaSourceSets { + configureEach { + includeNonPublic.set(false) + skipDeprecated.set(false) + extension.moduleName.orNull?.let { + displayName.set(it) + } + extension.includes.orNull?.let { + includes.from(*it.toTypedArray()) + } + jdkVersion.set(8) + + sourceLink { + localDirectory.set(file("${project.projectDir}/src/main/kotlin")) + + remoteUrl.set( + URL( + "https://github.com/Kotlin-Discord/kord-extensions/" + + "tree/${getCurrentGitBranch()}/${projectDir}/src/main/kotlin" + ) + ) + + remoteLineSuffix.set("#L") + } + + externalDocumentationLink { + url.set(URL("http://kordlib.github.io/kord/common/common/")) + } + + externalDocumentationLink { + url.set(URL("http://kordlib.github.io/kord/core/core/")) + } + } + } + } + } +} diff --git a/buildSrc/src/main/kotlin/kordex-module.gradle.kts b/buildSrc/src/main/kotlin/kordex-module.gradle.kts new file mode 100644 index 0000000000..7ed01f4177 --- /dev/null +++ b/buildSrc/src/main/kotlin/kordex-module.gradle.kts @@ -0,0 +1,58 @@ +import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") + + id("io.gitlab.arturbosch.detekt") +} + +abstract class KordexExtension { + abstract val jvmTarget: Property + abstract val javaVersion: Property +} + +val kordexExtensionName = "kordex" + +extensions.create(kordexExtensionName) + +val sourceJar = task("sourceJar", Jar::class) { + dependsOn(tasks["classes"]) + archiveClassifier.set("sources") + from(sourceSets.main.get().allSource) +} + +val javadocJar = task("javadocJar", Jar::class) { + dependsOn("dokkaJavadoc") + archiveClassifier.set("javadoc") + from(tasks.javadoc) +} + +tasks { + build { + finalizedBy(sourceJar, javadocJar) + } + kotlin { + explicitApi() + } + + afterEvaluate { + val extension = project.extensions.getByName(kordexExtensionName) + java { + sourceCompatibility = extension.javaVersion.getOrElse(JavaVersion.VERSION_1_8) + } + withType().configureEach { + kotlinOptions { + jvmTarget = extension.jvmTarget.getOrElse("1.8") + } + } + } + +} + +detekt { + buildUponDefaultConfig = true + config = files("$rootDir/detekt.yml") + + autoCorrect = true +} diff --git a/buildSrc/src/main/kotlin/ksp-module.gradle.kts b/buildSrc/src/main/kotlin/ksp-module.gradle.kts new file mode 100644 index 0000000000..e1c3aa0a8b --- /dev/null +++ b/buildSrc/src/main/kotlin/ksp-module.gradle.kts @@ -0,0 +1,18 @@ +plugins { + java + id("com.google.devtools.ksp") +} + +sourceSets { + main { + java { + srcDir(file("$buildDir/generated/ksp/main/kotlin/")) + } + } + + test { + java { + srcDir(file("$buildDir/generated/ksp/test/kotlin/")) + } + } +} diff --git a/buildSrc/src/main/kotlin/published-module.gradle.kts b/buildSrc/src/main/kotlin/published-module.gradle.kts new file mode 100644 index 0000000000..30419fe9d5 --- /dev/null +++ b/buildSrc/src/main/kotlin/published-module.gradle.kts @@ -0,0 +1,45 @@ +import org.gradle.api.publish.maven.MavenPublication + +plugins { + `maven-publish` + signing +} + +val sourceJar: Task by tasks.getting +val javadocJar: Task by tasks.getting + +publishing { + repositories { + maven { + name = "KotDis" + + url = if (project.version.toString().contains("SNAPSHOT")) { + uri("https://maven.kotlindiscord.com/repository/maven-snapshots/") + } else { + uri("https://maven.kotlindiscord.com/repository/maven-releases/") + } + + credentials { + username = project.findProperty("kotdis.user") as String? ?: System.getenv("KOTLIN_DISCORD_USER") + password = project.findProperty("kotdis.password") as String? + ?: System.getenv("KOTLIN_DISCORD_PASSWORD") + } + + version = project.version + } + } + + publications { + create("maven") { + from(components.getByName("java")) + + artifact(sourceJar) + artifact(javadocJar) + } + } +} + +signing { + useGpgCmd() + sign(publishing.publications["maven"]) +} diff --git a/buildSrc/src/main/kotlin/tested-module.gradle.kts b/buildSrc/src/main/kotlin/tested-module.gradle.kts new file mode 100644 index 0000000000..0530c19976 --- /dev/null +++ b/buildSrc/src/main/kotlin/tested-module.gradle.kts @@ -0,0 +1,17 @@ +plugins { + java +} + +tasks { + test { + useJUnitPlatform() + + testLogging.showStandardStreams = true + + testLogging { + events("PASSED", "FAILED", "SKIPPED", "STANDARD_OUT", "STANDARD_ERROR") + } + + systemProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug") + } +} diff --git a/changes/1.5.1-RC1.md b/changes/1.5.1-RC1.md new file mode 100644 index 0000000000..2b96176add --- /dev/null +++ b/changes/1.5.1-RC1.md @@ -0,0 +1,18 @@ +# KordEx 1.5.1-RC1 + +This release is our first "stable" release in quite a long time, targeting Kord `0.8.0-M7`. The reason for this is largely due to Kord's extremely long snapshot cycle, which itself was caused by many changes to Discord's APIs. In turn, this means that this release contains a mind-boggling number of internal changes. + +We've done our best to keep things as compatible as possible, API-wise. Despite this, though, we've had no choice but to break a few things. + +Highlights of this release: + +* With a lot of help from ByteAlex, we've been able to eliminate many, many unnecessary cache hits, making use of behaviors rather than entities wherever possible. This makes KordEx far more suitable for large bots with different caching requirements. +* Full support for message and user commands have been added, which come with a full rewrite of the application commands system. Application commands now always require a `public` or `ephemeral` type, to help keep things safe. Additionally, our old message commands are now named chat commands, and have their functions prefixed with `chat`. +* The components system has been fully rewritten, including a similar typing requirement to application commands. It comes with a `ComponentContainer` type which makes it easier to re-use components, as well as a callback registry for advanced use-cases (such as components that need to work after a restart). +* The Sentry integration has been rewritten, and you'll find a `SentryContext` provided everywhere you'd expect to be able to make use of Sentry, instead of a plain list of breadcrumbs. This, along with several other improvements, should make Sentry much more pleasant to work with. +* Our translations platform [has been switched to Weblate](https://hosted.weblate.org/engage/kord-extensions/). If you're a translator (or would like to help with translations), please head over there! +* Lots of deprecated things have now been removed. If you were still using them, well, you were warned! + +There are far too many changes to list here. The [existing pages on the wiki](https://kordex.kotlindiscord.com/) have been rewritten for this release, and we'd suggest taking a look at them to refresh your knowledge. There's still documentation work that needs doing, but we'll get there! + +As always, if you run into any problems, please let us know! diff --git a/crowdin.yml b/crowdin.yml deleted file mode 100644 index f0f1984840..0000000000 --- a/crowdin.yml +++ /dev/null @@ -1,6 +0,0 @@ -files: - - source: /kord-extensions/src/main/resources/translations/kordex/strings.properties - translation: >- - /kord-extensions/src/main/resources/translations/kordex/%file_name%_%locale_with_underscore%.%file_extension% - -"commit_message": "[ci skip]" diff --git a/detekt.yml b/detekt.yml index 03268694e5..90bc7c785a 100644 --- a/detekt.yml +++ b/detekt.yml @@ -170,7 +170,7 @@ exceptions: active: true methodNames: [toString, hashCode, equals, finalize] InstanceOfCheckForException: - active: true + active: false # It does often make things more readable excludes: ['**/test/**', '**/androidTest/**', '**/*.Test.kt', '**/*.Spec.kt', '**/*.Spek.kt'] NotImplementedDeclaration: active: false diff --git a/extra-modules/extra-common/build.gradle.kts b/extra-modules/extra-common/build.gradle.kts index f973315098..2d2eb52dd8 100644 --- a/extra-modules/extra-common/build.gradle.kts +++ b/extra-modules/extra-common/build.gradle.kts @@ -1,9 +1,9 @@ plugins { - `maven-publish` + `kordex-module` + `published-module` + `dokka-module` + `disable-explicit-api-mode` - id("io.gitlab.arturbosch.detekt") - - kotlin("jvm") kotlin("plugin.serialization") } @@ -25,68 +25,7 @@ dependencies { implementation(project(":kord-extensions")) } -/** - * You probably don't want to touch anything below this line. It contains mostly boilerplate, and expands variables - * from the gradle.properties file. - */ - -val sourceJar = task("sourceJar", Jar::class) { - dependsOn(tasks["classes"]) - archiveClassifier.set("sources") - from(sourceSets.main.get().allSource) -} - -detekt { - buildUponDefaultConfig = true - config = rootProject.files("detekt.yml") - - autoCorrect = true -} - -sourceSets { - main { - java { - srcDir(file("$buildDir/generated/ksp/main/kotlin/")) - } - } - - test { - java { - srcDir(file("$buildDir/generated/ksp/test/kotlin/")) - } - } -} - -tasks.build { - this.finalizedBy(sourceJar) -} - -publishing { - repositories { - maven { - name = "KotDis" - - url = if (project.version.toString().contains("SNAPSHOT")) { - uri("https://maven.kotlindiscord.com/repository/maven-snapshots/") - } else { - uri("https://maven.kotlindiscord.com/repository/maven-releases/") - } - - credentials { - username = project.findProperty("kotdis.user") as String? ?: System.getenv("KOTLIN_DISCORD_USER") - password = project.findProperty("kotdis.password") as String? - ?: System.getenv("KOTLIN_DISCORD_PASSWORD") - } - - version = project.version - } - } - - publications { - create("maven") { - from(components.getByName("java")) - - artifact(sourceJar) - } - } +kordex { + jvmTarget.set("9") + javaVersion.set(JavaVersion.VERSION_1_9) } diff --git a/extra-modules/extra-common/src/main/kotlin/com/kotlindiscord/kordex/ext/common/extensions/EmojiExtension.kt b/extra-modules/extra-common/src/main/kotlin/com/kotlindiscord/kordex/ext/common/extensions/EmojiExtension.kt index d095d6d5bc..b671c9b2fa 100644 --- a/extra-modules/extra-common/src/main/kotlin/com/kotlindiscord/kordex/ext/common/extensions/EmojiExtension.kt +++ b/extra-modules/extra-common/src/main/kotlin/com/kotlindiscord/kordex/ext/common/extensions/EmojiExtension.kt @@ -1,8 +1,8 @@ package com.kotlindiscord.kordex.ext.common.extensions import com.kotlindiscord.kord.extensions.checks.inGuild -import com.kotlindiscord.kord.extensions.checks.or import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.extensions.event import com.kotlindiscord.kordex.ext.common.builders.ExtCommonBuilder import com.kotlindiscord.kordex.ext.common.configuration.emoji.EmojiConfig import com.kotlindiscord.kordex.ext.common.emoji.NamedEmoji @@ -24,19 +24,15 @@ class EmojiExtension : Extension() { } event { - check( - or( - // No configured guilds? Do them all. - { config.getGuilds().isEmpty() }, - - { - config.getGuilds() - .mapNotNull { kord.getGuild(it) } - .map { inGuild { it } } - .any() - } - ) - ) + check { + failIfNot { + config.getGuilds().isEmpty() || + config.getGuilds() + .mapNotNull { kord.getGuild(it) } + .map { guild -> inGuild { guild } } + .any() + } + } action { populateEmojis(event.guildId) } } diff --git a/extra-modules/extra-mappings/README.md b/extra-modules/extra-mappings/README.md index 3f7001e804..ec10bcf155 100644 --- a/extra-modules/extra-mappings/README.md +++ b/extra-modules/extra-mappings/README.md @@ -14,15 +14,19 @@ If you're looking for older versions (and older tags), you can find them * **Maven repo:** `https://maven.kotlindiscord.com/repository/maven-public/` * **Maven coordinates:** `com.kotlindiscord.kord.extensions:extra-mappings:VERSION` -* If Fabric dependencies fail to resolve, you may also need to add their Maven repo: `https://maven.fabricmc.net` + +* Some extra Maven repos are required: + * **FabricMC**: `https://maven.fabricmc.net/` + * **QuiltMC (Releases)**: `https://maven.quiltmc.org/repository/release/` + * **QuiltMC (Snapshots)**: `https://maven.quiltmc.org/repository/snapshot/` At its simplest, you can add this extension directly to your bot with no further configuration. For example: ```kotlin suspend fun main() { val bot = ExtensibleBot(System.getenv("TOKEN")) { - commands { - defaultPrefix = "!" + chatCommands { + enabled = true } extensions { @@ -42,8 +46,9 @@ they're detailed below. This extension provides a number of commands for use on Discord. -* Commands for retrieving information about mappings namespaces: `legacy-yarn`, `mcp`, `mojang`, `plasma`, `yarn` +* Commands for retrieving information about mappings namespaces: `hashed`, `legacy-yarn`, `mcp`, `mojang`, `plasma`, `yarn` and `yarrn` +* Hashed Mojang-specific lookup commands: `hc`, `hf` and `hm` * Legacy Yarn-specific lookup commands: `lyc`, `lyf` and `lym` * MCP-specific lookup commands: `mcpc`, `mcpf` and `mcpm` * Mojang-specific lookup commands: `mmc`, `mmf` and `mmm` @@ -80,7 +85,7 @@ following configuration keys are available: other guilds. This setting takes priority over `guilds.banned`. * `guilds.banned`: List of guilds mappings commands may **not** be run within. When set, mappings commands may not be run within the given guilds. -* `settings.namespaces`: List of enabled namespaces. Currently, `legacy-yarn`, `mcp`, `mojang`, `plasma`, `yarn` +* `settings.namespaces`: List of enabled namespaces. Currently, `hashed-mojang`, `legacy-yarn`, `mcp`, `mojang`, `plasma`, `yarn` and `yarrn` are supported, and they will all be enabled by default. * `settings.timeout`: Time (in seconds) to wait before destroying mappings paginators, defaulting to 5 minutes (300 seconds). Be careful when setting this value to something high - a busy bot may end up running out of memory if diff --git a/extra-modules/extra-mappings/build.gradle.kts b/extra-modules/extra-mappings/build.gradle.kts index fa90d90bab..09670d3828 100644 --- a/extra-modules/extra-mappings/build.gradle.kts +++ b/extra-modules/extra-mappings/build.gradle.kts @@ -1,9 +1,8 @@ plugins { - `maven-publish` - - id("io.gitlab.arturbosch.detekt") - - kotlin("jvm") + `kordex-module` + `published-module` + `dokka-module` + `disable-explicit-api-mode` } repositories { @@ -17,6 +16,16 @@ repositories { url = uri("https://maven.fabricmc.net/") } + maven { + name = "QuiltMC (Releases)" + url = uri("https://maven.quiltmc.org/repository/release/") + } + + maven { + name = "QuiltMC (Snapshots)" + url = uri("https://maven.quiltmc.org/repository/snapshot/") + } + maven { name = "Shedaniel" url = uri("https://maven.shedaniel.me") @@ -46,63 +55,7 @@ dependencies { group = "com.kotlindiscord.kord.extensions" -val sourceJar = task("sourceJar", Jar::class) { - dependsOn(tasks["classes"]) - archiveClassifier.set("sources") - from(sourceSets.main.get().allSource) -} - -detekt { - buildUponDefaultConfig = true - config = rootProject.files("detekt.yml") - - autoCorrect = true -} - -sourceSets { - main { - java { - srcDir(file("$buildDir/generated/ksp/main/kotlin/")) - } - } - - test { - java { - srcDir(file("$buildDir/generated/ksp/test/kotlin/")) - } - } -} - -tasks.build { - this.finalizedBy(sourceJar) -} - -publishing { - repositories { - maven { - name = "KotDis" - - url = if (project.version.toString().contains("SNAPSHOT")) { - uri("https://maven.kotlindiscord.com/repository/maven-snapshots/") - } else { - uri("https://maven.kotlindiscord.com/repository/maven-releases/") - } - - credentials { - username = project.findProperty("kotdis.user") as String? ?: System.getenv("KOTLIN_DISCORD_USER") - password = project.findProperty("kotdis.password") as String? - ?: System.getenv("KOTLIN_DISCORD_PASSWORD") - } - - version = project.version - } - } - - publications { - create("maven") { - from(components.getByName("java")) - - artifact(sourceJar) - } - } +kordex { + jvmTarget.set("9") + javaVersion.set(JavaVersion.VERSION_1_9) } diff --git a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/Checks.kt b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/Checks.kt index 1dbcadfec4..1f3683470d 100644 --- a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/Checks.kt +++ b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/Checks.kt @@ -2,7 +2,7 @@ package com.kotlindiscord.kord.extensions.modules.extra.mappings import com.kotlindiscord.kord.extensions.checks.channelFor import com.kotlindiscord.kord.extensions.checks.guildFor -import com.kotlindiscord.kord.extensions.checks.types.Check +import com.kotlindiscord.kord.extensions.checks.types.CheckContext import dev.kord.common.entity.Snowflake import dev.kord.core.entity.channel.CategorizableChannel import dev.kord.core.entity.channel.GuildChannel @@ -26,19 +26,19 @@ import mu.KotlinLogging * @param allowed List of allowed category IDs * @param banned List of banned category IDs */ -fun allowedCategory( +suspend fun CheckContext.allowedCategory( allowed: List, banned: List -): Check = { +) { val logger = KotlinLogging.logger { } val channel = channelFor(event) if (channel == null) { - logger.debug { "Passing: Event is not channel-related" } + logger.trace { "Passing: Event is not channel-related" } pass() } else if (channel !is CategorizableChannel) { - logger.debug { "Passing: Channel is not categorizable (eg, it's a DM)" } + logger.trace { "Passing: Channel is not categorizable (eg, it's a DM)" } pass() } else { @@ -50,7 +50,7 @@ fun allowedCategory( fail() } else if (allowed.contains(parent.id)) { - logger.debug { "Passing: Event happened in an allowed category" } + logger.trace { "Passing: Event happened in an allowed category" } pass() } else { @@ -60,14 +60,14 @@ fun allowedCategory( } } else { if (parent == null) { - logger.debug { + logger.trace { "Passing: We have no allowed categories, and the message was sent outside of a category" } pass() } else if (banned.isNotEmpty()) { if (!banned.contains(parent.id)) { - logger.debug { "Passing: Event did not happen in a banned category" } + logger.trace { "Passing: Event did not happen in a banned category" } pass() } else { @@ -76,7 +76,7 @@ fun allowedCategory( fail() } } else { - logger.debug { "Passing: No allowed or banned categories configured" } + logger.trace { "Passing: No allowed or banned categories configured" } pass() } @@ -100,24 +100,24 @@ fun allowedCategory( * @param allowed List of allowed channel IDs * @param banned List of banned channel IDs */ -fun allowedChannel( +suspend fun CheckContext.allowedChannel( allowed: List, banned: List -): Check = { +) { val logger = KotlinLogging.logger { } val channel = channelFor(event) if (channel == null) { - logger.debug { "Passing: Event is not channel-related" } + logger.trace { "Passing: Event is not channel-related" } pass() } else if (channel !is GuildChannel) { - logger.debug { "Passing: Message was sent privately" } + logger.trace { "Passing: Message was sent privately" } pass() // It's a DM } else if (allowed.isNotEmpty()) { if (allowed.contains(channel.id)) { - logger.debug { "Passing: Event happened in an allowed channel" } + logger.trace { "Passing: Event happened in an allowed channel" } pass() } else { @@ -127,7 +127,7 @@ fun allowedChannel( } } else if (banned.isNotEmpty()) { if (!banned.contains(channel.id)) { - logger.debug { "Passing: Event did not happen in a banned channel" } + logger.trace { "Passing: Event did not happen in a banned channel" } pass() } else { @@ -136,7 +136,7 @@ fun allowedChannel( fail() } } else { - logger.debug { "Passing: No allowed or banned channels configured" } + logger.trace { "Passing: No allowed or banned channels configured" } pass() } @@ -157,20 +157,20 @@ fun allowedChannel( * @param allowed List of allowed guild IDs * @param banned List of banned guild IDs */ -fun allowedGuild( +suspend fun CheckContext.allowedGuild( allowed: List, banned: List -): Check = { +) { val logger = KotlinLogging.logger { } val guild = guildFor(event) if (guild == null) { - logger.debug { "Passing: Event is not guild-related" } + logger.trace { "Passing: Event is not guild-related" } pass() } else if (allowed.isNotEmpty()) { if (allowed.contains(guild.id)) { - logger.debug { "Passing: Event happened in an allowed guild" } + logger.trace { "Passing: Event happened in an allowed guild" } pass() } else { @@ -180,7 +180,7 @@ fun allowedGuild( } } else if (banned.isNotEmpty()) { if (!banned.contains(guild.id)) { - logger.debug { "Passing: Event did not happen in a banned guild" } + logger.trace { "Passing: Event did not happen in a banned guild" } pass() } else { @@ -189,7 +189,7 @@ fun allowedGuild( fail() } } else { - logger.debug { "Passing: No allowed or banned guilds configured" } + logger.trace { "Passing: No allowed or banned guilds configured" } pass() } diff --git a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/MappingsExtension.kt b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/MappingsExtension.kt index f742f2375c..b59483b403 100644 --- a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/MappingsExtension.kt +++ b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/MappingsExtension.kt @@ -2,11 +2,12 @@ package com.kotlindiscord.kord.extensions.modules.extra.mappings -import com.kotlindiscord.kord.extensions.checks.and import com.kotlindiscord.kord.extensions.checks.types.Check -import com.kotlindiscord.kord.extensions.commands.MessageCommandContext -import com.kotlindiscord.kord.extensions.commands.parser.Arguments +import com.kotlindiscord.kord.extensions.checks.types.CheckContext +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.chat.ChatCommandContext import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.extensions.chatCommand import com.kotlindiscord.kord.extensions.modules.extra.mappings.arguments.* import com.kotlindiscord.kord.extensions.modules.extra.mappings.builders.ExtMappingsBuilder import com.kotlindiscord.kord.extensions.modules.extra.mappings.enums.Channels @@ -19,8 +20,12 @@ import com.kotlindiscord.kord.extensions.pagination.EXPAND_EMOJI import com.kotlindiscord.kord.extensions.pagination.MessageButtonPaginator import com.kotlindiscord.kord.extensions.pagination.pages.Page import com.kotlindiscord.kord.extensions.pagination.pages.Pages +import com.kotlindiscord.kord.extensions.sentry.BreadcrumbType import com.kotlindiscord.kord.extensions.utils.respond import dev.kord.core.behavior.channel.withTyping +import dev.kord.core.event.message.MessageCreateEvent +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.withContext import me.shedaniel.linkie.* import me.shedaniel.linkie.namespaces.* import me.shedaniel.linkie.utils.MappingsQuery @@ -49,6 +54,7 @@ class MappingsExtension : Extension() { "legacy-yarn" -> namespaces.add(LegacyYarnNamespace) "mcp" -> namespaces.add(MCPNamespace) "mojang" -> namespaces.add(MojangNamespace) + "hashed-mojang" -> namespaces.add(MojangHashedNamespace) "plasma" -> namespaces.add(PlasmaNamespace) "yarn" -> namespaces.add(YarnNamespace) "yarrn" -> namespaces.add(YarrnNamespace) @@ -67,15 +73,24 @@ class MappingsExtension : Extension() { val legacyYarnEnabled = enabledNamespaces.contains("legacy-yarn") val mcpEnabled = enabledNamespaces.contains("mcp") val mojangEnabled = enabledNamespaces.contains("mojang") + val hashedMojangEnabled = enabledNamespaces.contains("hashed-mojang") val plasmaEnabled = enabledNamespaces.contains("plasma") val yarnEnabled = enabledNamespaces.contains("yarn") val yarrnEnabled = enabledNamespaces.contains("yarrn") val patchworkEnabled = builder.config.yarnChannelEnabled(YarnChannels.PATCHWORK) - val categoryCheck = allowedCategory(builder.config.getAllowedCategories(), builder.config.getBannedCategories()) - val channelCheck = allowedGuild(builder.config.getAllowedChannels(), builder.config.getBannedChannels()) - val guildCheck = allowedGuild(builder.config.getAllowedGuilds(), builder.config.getBannedGuilds()) + val categoryCheck: Check = { + allowedCategory(builder.config.getAllowedCategories(), builder.config.getBannedCategories()) + } + + val channelCheck: Check = { + allowedGuild(builder.config.getAllowedChannels(), builder.config.getBannedChannels()) + } + + val guildCheck: Check = { + allowedGuild(builder.config.getAllowedGuilds(), builder.config.getBannedGuilds()) + } val yarnChannels = YarnChannels.values().filter { it != YarnChannels.PATCHWORK || patchworkEnabled @@ -85,7 +100,7 @@ class MappingsExtension : Extension() { if (legacyYarnEnabled) { // Class - command(::LegacyYarnArguments) { + chatCommand(::LegacyYarnArguments) { name = "lyc" aliases = arrayOf("lyarnc", "legacy-yarnc", "legacyyarnc", "legacyarnc") @@ -94,7 +109,7 @@ class MappingsExtension : Extension() { "For more information or a list of versions for Legacy Yarn mappings, you can use the " + "`lyarn` command." - check(customChecks(name, LegacyYarnNamespace)) + check { customChecks(name, LegacyYarnNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -103,7 +118,7 @@ class MappingsExtension : Extension() { } // Field - command(::LegacyYarnArguments) { + chatCommand(::LegacyYarnArguments) { name = "lyf" aliases = arrayOf("lyarnf", "legacy-yarnf", "legacyyarnf", "legacyarnf") @@ -112,7 +127,7 @@ class MappingsExtension : Extension() { "For more information or a list of versions for Legacy Yarn mappings, you can use the " + "`lyarn` command." - check(customChecks(name, LegacyYarnNamespace)) + check { customChecks(name, LegacyYarnNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -121,7 +136,7 @@ class MappingsExtension : Extension() { } // Method - command(::LegacyYarnArguments) { + chatCommand(::LegacyYarnArguments) { name = "lym" aliases = arrayOf("lyarnm", "legacy-yarnm", "legacyyarnm", "legacyarnm") @@ -130,7 +145,7 @@ class MappingsExtension : Extension() { "For more information or a list of versions for Legacy Yarn mappings, you can use the " + "`lyarn` command." - check(customChecks(name, LegacyYarnNamespace)) + check { customChecks(name, LegacyYarnNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -145,14 +160,14 @@ class MappingsExtension : Extension() { if (mcpEnabled) { // Class - command(::MCPArguments) { + chatCommand(::MCPArguments) { name = "mcpc" description = "Look up MCP mappings info for a class.\n\n" + "For more information or a list of versions for MCP mappings, you can use the `mcp` command." - check(customChecks(name, MCPNamespace)) + check { customChecks(name, MCPNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -161,14 +176,14 @@ class MappingsExtension : Extension() { } // Field - command(::MCPArguments) { + chatCommand(::MCPArguments) { name = "mcpf" description = "Look up MCP mappings info for a field.\n\n" + "For more information or a list of versions for MCP mappings, you can use the `mcp` command." - check(customChecks(name, MCPNamespace)) + check { customChecks(name, MCPNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -177,14 +192,14 @@ class MappingsExtension : Extension() { } // Method - command(::MCPArguments) { + chatCommand(::MCPArguments) { name = "mcpm" description = "Look up MCP mappings info for a method.\n\n" + "For more information or a list of versions for MCP mappings, you can use the `mcp` command." - check(customChecks(name, MCPNamespace)) + check { customChecks(name, MCPNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -199,7 +214,7 @@ class MappingsExtension : Extension() { if (mojangEnabled) { // Class - command(::MojangArguments) { + chatCommand(::MojangArguments) { name = "mmc" aliases = arrayOf("mojc", "mojmapc") @@ -211,7 +226,7 @@ class MappingsExtension : Extension() { "For more information or a list of versions for Mojang mappings, you can use the `mojang` " + "command." - check(customChecks(name, MojangNamespace)) + check { customChecks(name, MojangNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -220,7 +235,7 @@ class MappingsExtension : Extension() { } // Field - command(::MojangArguments) { + chatCommand(::MojangArguments) { name = "mmf" aliases = arrayOf("mojf", "mojmapf") @@ -232,7 +247,7 @@ class MappingsExtension : Extension() { "For more information or a list of versions for Mojang mappings, you can use the `mojang` " + "command." - check(customChecks(name, MojangNamespace)) + check { customChecks(name, MojangNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -241,7 +256,7 @@ class MappingsExtension : Extension() { } // Method - command(::MojangArguments) { + chatCommand(::MojangArguments) { name = "mmm" aliases = arrayOf("mojm", "mojmapm") @@ -253,7 +268,7 @@ class MappingsExtension : Extension() { "For more information or a list of versions for Mojang mappings, you can use the `mojang` " + "command." - check(customChecks(name, MojangNamespace)) + check { customChecks(name, MojangNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -264,11 +279,80 @@ class MappingsExtension : Extension() { // endregion + // region: Hashed Mojang mappings lookups + + if (hashedMojangEnabled) { + // Class + chatCommand(::HashedMojangArguments) { + name = "hc" + aliases = arrayOf("hmojc", "hmojmapc", "hmc", "qhc") + + description = "Look up Hashed Mojang mappings info for a class.\n\n" + + + "**Channels:** " + Channels.values().joinToString(", ") { "`${it.str}`" } + + "\n\n" + + + "For more information or a list of versions for Mojang mappings, you can use the `mojang` " + + "command." + + check { customChecks(name, MojangHashedNamespace) } + check(categoryCheck, channelCheck, guildCheck) // Default checks + + action { + queryClasses(MojangHashedNamespace, arguments.query, arguments.version, arguments.channel?.str) + } + } + + // Field + chatCommand(::HashedMojangArguments) { + name = "hf" + aliases = arrayOf("hmojf", "hmojmapf", "hmf", "qhf") + + description = "Look up Hashed Mojang mappings info for a field.\n\n" + + + "**Channels:** " + Channels.values().joinToString(", ") { "`${it.str}`" } + + "\n\n" + + + "For more information or a list of versions for Mojang mappings, you can use the `mojang` " + + "command." + + check { customChecks(name, MojangHashedNamespace) } + check(categoryCheck, channelCheck, guildCheck) // Default checks + + action { + queryFields(MojangHashedNamespace, arguments.query, arguments.version, arguments.channel?.str) + } + } + + // Method + chatCommand(::HashedMojangArguments) { + name = "hm" + aliases = arrayOf("hmojm", "hmojmapm", "hmm", "qhm") + + description = "Look up Hashed Mojang mappings info for a method.\n\n" + + + "**Channels:** " + Channels.values().joinToString(", ") { "`${it.str}`" } + + "\n\n" + + + "For more information or a list of versions for Mojang mappings, you can use the `mojang` " + + "command." + + check { customChecks(name, MojangHashedNamespace) } + check(categoryCheck, channelCheck, guildCheck) // Default checks + + action { + queryMethods(MojangHashedNamespace, arguments.query, arguments.version, arguments.channel?.str) + } + } + } + + // endregion + // region: Plasma mappings lookups if (plasmaEnabled) { // Class - command(::PlasmaArguments) { + chatCommand(::PlasmaArguments) { name = "pc" description = "Look up Plasma mappings info for a class.\n\n" + @@ -276,7 +360,7 @@ class MappingsExtension : Extension() { "For more information or a list of versions for Plasma mappings, you can use the " + "`plasma` command." - check(customChecks(name, PlasmaNamespace)) + check { customChecks(name, PlasmaNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -285,7 +369,7 @@ class MappingsExtension : Extension() { } // Field - command(::PlasmaArguments) { + chatCommand(::PlasmaArguments) { name = "pf" description = "Look up Plasma mappings info for a field.\n\n" + @@ -293,7 +377,7 @@ class MappingsExtension : Extension() { "For more information or a list of versions for Plasma mappings, you can use the " + "`plasma` command." - check(customChecks(name, PlasmaNamespace)) + check { customChecks(name, PlasmaNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -302,7 +386,7 @@ class MappingsExtension : Extension() { } // Method - command(::PlasmaArguments) { + chatCommand(::PlasmaArguments) { name = "pm" description = "Look up Plasma mappings info for a method.\n\n" + @@ -310,7 +394,7 @@ class MappingsExtension : Extension() { "For more information or a list of versions for Plasma mappings, you can use the " + "`plasma` command." - check(customChecks(name, PlasmaNamespace)) + check { customChecks(name, PlasmaNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -325,7 +409,7 @@ class MappingsExtension : Extension() { if (yarnEnabled) { // Class - command({ YarnArguments(patchworkEnabled) }) { + chatCommand({ YarnArguments(patchworkEnabled) }) { name = "yc" aliases = arrayOf("yarnc") @@ -337,7 +421,7 @@ class MappingsExtension : Extension() { "For more information or a list of versions for Yarn mappings, you can use the `yarn` " + "command." - check(customChecks(name, YarnNamespace)) + check { customChecks(name, YarnNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -351,7 +435,7 @@ class MappingsExtension : Extension() { } // Field - command({ YarnArguments(patchworkEnabled) }) { + chatCommand({ YarnArguments(patchworkEnabled) }) { name = "yf" aliases = arrayOf("yarnf") @@ -363,7 +447,7 @@ class MappingsExtension : Extension() { "For more information or a list of versions for Yarn mappings, you can use the `yarn` " + "command." - check(customChecks(name, YarnNamespace)) + check { customChecks(name, YarnNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -377,7 +461,7 @@ class MappingsExtension : Extension() { } // Method - command({ YarnArguments(patchworkEnabled) }) { + chatCommand({ YarnArguments(patchworkEnabled) }) { name = "ym" aliases = arrayOf("yarnm") @@ -389,7 +473,7 @@ class MappingsExtension : Extension() { "For more information or a list of versions for Yarn mappings, you can use the `yarn` " + "command." - check(customChecks(name, YarnNamespace)) + check { customChecks(name, YarnNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -409,7 +493,7 @@ class MappingsExtension : Extension() { if (yarrnEnabled) { // Class - command(::YarrnArguments) { + chatCommand(::YarrnArguments) { name = "yrc" description = "Look up Yarrn mappings info for a class.\n\n" + @@ -417,7 +501,7 @@ class MappingsExtension : Extension() { "For more information or a list of versions for Yarrn mappings, you can use the " + "`yarrn` command." - check(customChecks(name, YarrnNamespace)) + check { customChecks(name, YarrnNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -426,7 +510,7 @@ class MappingsExtension : Extension() { } // Field - command(::YarrnArguments) { + chatCommand(::YarrnArguments) { name = "yrf" description = "Look up Yarrn mappings info for a field.\n\n" + @@ -434,7 +518,7 @@ class MappingsExtension : Extension() { "For more information or a list of versions for Yarrn mappings, you can use the " + "`yarrn` command." - check(customChecks(name, YarrnNamespace)) + check { customChecks(name, YarrnNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -443,7 +527,7 @@ class MappingsExtension : Extension() { } // Method - command(::YarrnArguments) { + chatCommand(::YarrnArguments) { name = "yrm" description = "Look up Yarrn mappings info for a method.\n\n" + @@ -451,7 +535,7 @@ class MappingsExtension : Extension() { "For more information or a list of versions for Yarrn mappings, you can use the " + "`yarrn` command." - check(customChecks(name, YarrnNamespace)) + check { customChecks(name, YarrnNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -465,13 +549,13 @@ class MappingsExtension : Extension() { // region: Mappings info commands if (legacyYarnEnabled) { - command { + chatCommand { name = "lyarn" aliases = arrayOf("legacy-yarn", "legacyyarn", "legacyarn") description = "Get information and a list of supported versions for Legacy Yarn mappings." - check(customChecks(name, LegacyYarnNamespace)) + check { customChecks(name, LegacyYarnNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -517,7 +601,6 @@ class MappingsExtension : Extension() { } val paginator = MessageButtonPaginator( - extension = this@MappingsExtension, targetMessage = event.message, pages = pagesObj, keepEmbed = true, @@ -532,12 +615,12 @@ class MappingsExtension : Extension() { } if (mcpEnabled) { - command { + chatCommand { name = "mcp" description = "Get information and a list of supported versions for MCP mappings." - check(customChecks(name, MCPNamespace)) + check { customChecks(name, MCPNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -582,7 +665,6 @@ class MappingsExtension : Extension() { } val paginator = MessageButtonPaginator( - extension = this@MappingsExtension, targetMessage = event.message, pages = pagesObj, keepEmbed = true, @@ -597,13 +679,13 @@ class MappingsExtension : Extension() { } if (mojangEnabled) { - command { + chatCommand { name = "mojang" aliases = arrayOf("mojmap") description = "Get information and a list of supported versions for Mojang mappings." - check(customChecks(name, MojangNamespace)) + check { customChecks(name, MojangNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -651,7 +733,75 @@ class MappingsExtension : Extension() { } val paginator = MessageButtonPaginator( - extension = this@MappingsExtension, + targetMessage = event.message, + pages = pagesObj, + keepEmbed = true, + owner = message.author, + timeoutSeconds = getTimeout(), + locale = getLocale(), + ) + + paginator.send() + } + } + } + + if (hashedMojangEnabled) { + chatCommand { + name = "hashed" + aliases = arrayOf("hashed-mojmap", "hashed-mojang", "quilt-hashed", "qh", "hm") + + description = "Get information and a list of supported versions for hashed Mojang mappings." + + check { customChecks(name, MojangHashedNamespace) } + check(categoryCheck, channelCheck, guildCheck) // Default checks + + action { + val defaultVersion = MojangHashedNamespace.getDefaultVersion() + val allVersions = MojangHashedNamespace.getAllSortedVersions() + + val pages = allVersions.chunked(VERSION_CHUNK_SIZE).map { + it.joinToString("\n") { version -> + if (version == defaultVersion) { + "**» $version** (Default)" + } else { + "**»** $version" + } + } + }.toMutableList() + + pages.add( + 0, + "Hashed Mojang mappings are available for queries across **${allVersions.size}** " + + "versions.\n\n" + + + "**Default version:** $defaultVersion\n\n" + + + "**Channels:** " + Channels.values().joinToString(", ") { "`${it.str}`" } + + "\n" + + "**Commands:** `hc`, `hf`, `hm`\n\n" + + + "For a full list of supported hashed Mojang versions, please view the rest of the pages." + ) + + val pagesObj = Pages() + val pageTitle = "Mappings info: Hashed Mojang" + + pages.forEach { + pagesObj.addPage( + Page { + description = it + title = pageTitle + + footer { + text = PAGE_FOOTER + icon = PAGE_FOOTER_ICON + } + } + ) + } + + val paginator = MessageButtonPaginator( targetMessage = event.message, pages = pagesObj, keepEmbed = true, @@ -666,12 +816,12 @@ class MappingsExtension : Extension() { } if (plasmaEnabled) { - command { + chatCommand { name = "plasma" description = "Get information and a list of supported versions for Plasma mappings." - check(customChecks(name, PlasmaNamespace)) + check { customChecks(name, PlasmaNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -717,7 +867,6 @@ class MappingsExtension : Extension() { } val paginator = MessageButtonPaginator( - extension = this@MappingsExtension, targetMessage = event.message, pages = pagesObj, keepEmbed = true, @@ -732,12 +881,12 @@ class MappingsExtension : Extension() { } if (yarnEnabled) { - command { + chatCommand { name = "yarn" description = "Get information and a list of supported versions for Yarn mappings." - check(customChecks(name, YarnNamespace)) + check { customChecks(name, YarnNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -811,7 +960,6 @@ class MappingsExtension : Extension() { } val paginator = MessageButtonPaginator( - extension = this@MappingsExtension, targetMessage = event.message, pages = pagesObj, keepEmbed = true, @@ -826,12 +974,12 @@ class MappingsExtension : Extension() { } if (yarrnEnabled) { - command { + chatCommand { name = "yarrn" description = "Get information and a list of supported versions for Yarrn mappings." - check(customChecks(name, YarrnNamespace)) + check { customChecks(name, YarrnNamespace) } check(categoryCheck, channelCheck, guildCheck) // Default checks action { @@ -877,7 +1025,6 @@ class MappingsExtension : Extension() { } val paginator = MessageButtonPaginator( - extension = this@MappingsExtension, targetMessage = event.message, pages = pagesObj, keepEmbed = true, @@ -896,347 +1043,465 @@ class MappingsExtension : Extension() { logger.info { "Mappings extension set up - namespaces: " + enabledNamespaces.joinToString(", ") } } - private suspend fun MessageCommandContext.queryClasses( + private suspend fun ChatCommandContext.queryClasses( namespace: Namespace, givenQuery: String, version: MappingsContainer?, channel: String? = null ) { - val provider = if (version == null) { - if (channel != null) { - namespace.getProvider( - namespace.getDefaultVersion { channel } - ) - } else { - MappingsProvider.empty(namespace) - } - } else { - namespace.getProvider(version.version) + sentry.breadcrumb(BreadcrumbType.Query) { + message = "Beginning class lookup" + + data["channel"] = channel ?: "N/A" + data["namespace"] = namespace.id + data["query"] = givenQuery + data["version"] = version?.version ?: "N/A" } - provider.injectDefaultVersion( - namespace.getDefaultProvider { - channel ?: namespace.getDefaultMappingChannel() - } - ) - - val query = givenQuery.replace(".", "/") - var pages: List> - - message.channel.withTyping { - @Suppress("TooGenericExceptionCaught") - val result = try { - MappingsQuery.queryClasses( - QueryContext( - provider = provider, - searchKey = query - ) - ) - } catch (e: NullPointerException) { - message.respond(e.localizedMessage) - return@queryClasses - } + val context = newSingleThreadContext("c: $givenQuery") - pages = classesToPages(namespace, result) - } + try { + withContext(context) { + val provider = if (version == null) { + if (channel != null) { + namespace.getProvider( + namespace.getDefaultVersion { channel } + ) + } else { + MappingsProvider.empty(namespace) + } + } else { + namespace.getProvider(version.version) + } - if (pages.isEmpty()) { - message.respond("No results found") - return - } + provider.injectDefaultVersion( + namespace.getDefaultProvider { + channel ?: namespace.getDefaultMappingChannel() + } + ) - val meta = provider.get() + sentry.breadcrumb(BreadcrumbType.Info) { + message = "Provider resolved, with injected default version" - val pagesObj = Pages("${EXPAND_EMOJI.mention} for more") - val pageTitle = "List of ${meta.name} classes: ${meta.version}" + data["version"] = provider.version ?: "Unknown" + } - val shortPages = mutableListOf() - val longPages = mutableListOf() + val query = givenQuery.replace(".", "/") + var pages: List> - pages.forEach { (short, long) -> - shortPages.add(short) - longPages.add(long) - } + sentry.breadcrumb(BreadcrumbType.Info) { + message = "Attempting to run sanitized query" - shortPages.forEach { - pagesObj.addPage( - "${EXPAND_EMOJI.mention} for more", + data["query"] = query + } + + message.channel.withTyping { + @Suppress("TooGenericExceptionCaught") + val result = try { + MappingsQuery.queryClasses( + QueryContext( + provider = provider, + searchKey = query + ) + ) + } catch (e: NullPointerException) { + message.respond(e.localizedMessage) + return@withContext + } - Page { - description = it - title = pageTitle + sentry.breadcrumb(BreadcrumbType.Info) { + message = "Generating pages for results" - footer { - text = PAGE_FOOTER - icon = PAGE_FOOTER_ICON + data["resultCount"] = result.value.size } + + pages = classesToPages(namespace, result) + } + + if (pages.isEmpty()) { + message.respond("No results found") + return@withContext } - ) - } - if (shortPages != longPages) { - longPages.forEach { - pagesObj.addPage( - "${EXPAND_EMOJI.mention} for less", + val meta = provider.get() - Page { - description = it - title = pageTitle + val pagesObj = Pages("${EXPAND_EMOJI.mention} for more") + val pageTitle = "List of ${meta.name} classes: ${meta.version}" - footer { - text = PAGE_FOOTER - icon = PAGE_FOOTER_ICON + val shortPages = mutableListOf() + val longPages = mutableListOf() + + pages.forEach { (short, long) -> + shortPages.add(short) + longPages.add(long) + } + + shortPages.forEach { + pagesObj.addPage( + "${EXPAND_EMOJI.mention} for more", + + Page { + description = it + title = pageTitle + + footer { + text = PAGE_FOOTER + icon = PAGE_FOOTER_ICON + } } + ) + } + + if (shortPages != longPages) { + longPages.forEach { + pagesObj.addPage( + "${EXPAND_EMOJI.mention} for less", + + Page { + description = it + title = pageTitle + + footer { + text = PAGE_FOOTER + icon = PAGE_FOOTER_ICON + } + } + ) } + } + + sentry.breadcrumb(BreadcrumbType.Info) { + message = "Creating and sending paginator to Discord" + } + + val paginator = MessageButtonPaginator( + targetMessage = event.message, + pages = pagesObj, + keepEmbed = true, + owner = message.author, + timeoutSeconds = getTimeout(), + locale = getLocale(), ) + + paginator.send() } + } finally { + context.close() } - - val paginator = MessageButtonPaginator( - extension = this@MappingsExtension, - targetMessage = event.message, - pages = pagesObj, - keepEmbed = true, - owner = message.author, - timeoutSeconds = getTimeout(), - locale = getLocale(), - ) - - paginator.send() } - private suspend fun MessageCommandContext.queryFields( + private suspend fun ChatCommandContext.queryFields( namespace: Namespace, givenQuery: String, version: MappingsContainer?, channel: String? = null ) { - val provider = if (version == null) { - if (channel != null) { - namespace.getProvider( - namespace.getDefaultVersion { channel } - ) - } else { - MappingsProvider.empty(namespace) - } - } else { - namespace.getProvider(version.version) + sentry.breadcrumb(BreadcrumbType.Query) { + message = "Beginning field lookup" + + data["channel"] = channel ?: "N/A" + data["namespace"] = namespace.id + data["query"] = givenQuery + data["version"] = version?.version ?: "N/A" } - provider.injectDefaultVersion( - namespace.getDefaultProvider { - channel ?: namespace.getDefaultMappingChannel() - } - ) - - val query = givenQuery.replace(".", "/") - var pages: List> - - message.channel.withTyping { - @Suppress("TooGenericExceptionCaught") - val result = try { - MappingsQuery.queryFields( - QueryContext( - provider = provider, - searchKey = query - ) - ) - } catch (e: NullPointerException) { - message.respond(e.localizedMessage) - return@queryFields - } + val context = newSingleThreadContext("f: $givenQuery") - pages = fieldsToPages(namespace, provider.get(), result) - } + try { + withContext(context) { + val provider = if (version == null) { + if (channel != null) { + namespace.getProvider( + namespace.getDefaultVersion { channel } + ) + } else { + MappingsProvider.empty(namespace) + } + } else { + namespace.getProvider(version.version) + } - if (pages.isEmpty()) { - message.respond("No results found") - return - } + provider.injectDefaultVersion( + namespace.getDefaultProvider { + channel ?: namespace.getDefaultMappingChannel() + } + ) - val meta = provider.get() + sentry.breadcrumb(BreadcrumbType.Info) { + message = "Provider resolved, with injected default version" - val pagesObj = Pages("${EXPAND_EMOJI.mention} for more") - val pageTitle = "List of ${meta.name} fields: ${meta.version}" + data["version"] = provider.version ?: "Unknown" + } - val shortPages = mutableListOf() - val longPages = mutableListOf() + val query = givenQuery.replace(".", "/") + var pages: List> - pages.forEach { (short, long) -> - shortPages.add(short) - longPages.add(long) - } + sentry.breadcrumb(BreadcrumbType.Info) { + message = "Attempting to run sanitized query" + + data["query"] = query + } - shortPages.forEach { - pagesObj.addPage( - "${EXPAND_EMOJI.mention} for more", + message.channel.withTyping { + @Suppress("TooGenericExceptionCaught") + val result = try { + MappingsQuery.queryFields( + QueryContext( + provider = provider, + searchKey = query + ) + ) + } catch (e: NullPointerException) { + message.respond(e.localizedMessage) + return@withContext + } - Page { - description = it - title = pageTitle + sentry.breadcrumb(BreadcrumbType.Info) { + message = "Generating pages for results" - footer { - text = PAGE_FOOTER - icon = PAGE_FOOTER_ICON + data["resultCount"] = result.value.size } + + pages = fieldsToPages(namespace, provider.get(), result) } - ) - } - if (shortPages != longPages) { - longPages.forEach { - pagesObj.addPage( - "${EXPAND_EMOJI.mention} for less", + if (pages.isEmpty()) { + message.respond("No results found") + return@withContext + } + + val meta = provider.get() + + val pagesObj = Pages("${EXPAND_EMOJI.mention} for more") + val pageTitle = "List of ${meta.name} fields: ${meta.version}" + + val shortPages = mutableListOf() + val longPages = mutableListOf() + + pages.forEach { (short, long) -> + shortPages.add(short) + longPages.add(long) + } - Page { - description = it - title = pageTitle + shortPages.forEach { + pagesObj.addPage( + "${EXPAND_EMOJI.mention} for more", - footer { - text = PAGE_FOOTER - icon = PAGE_FOOTER_ICON + Page { + description = it + title = pageTitle + + footer { + text = PAGE_FOOTER + icon = PAGE_FOOTER_ICON + } } + ) + } + + if (shortPages != longPages) { + longPages.forEach { + pagesObj.addPage( + "${EXPAND_EMOJI.mention} for less", + + Page { + description = it + title = pageTitle + + footer { + text = PAGE_FOOTER + icon = PAGE_FOOTER_ICON + } + } + ) } + } + + sentry.breadcrumb(BreadcrumbType.Info) { + message = "Creating and sending paginator to Discord" + } + + val paginator = MessageButtonPaginator( + targetMessage = event.message, + pages = pagesObj, + keepEmbed = true, + owner = message.author, + timeoutSeconds = getTimeout(), + locale = getLocale(), ) + + paginator.send() } + } finally { + context.close() } - - val paginator = MessageButtonPaginator( - extension = this@MappingsExtension, - targetMessage = event.message, - pages = pagesObj, - keepEmbed = true, - owner = message.author, - timeoutSeconds = getTimeout(), - locale = getLocale(), - ) - - paginator.send() } - private suspend fun MessageCommandContext.queryMethods( + private suspend fun ChatCommandContext.queryMethods( namespace: Namespace, givenQuery: String, version: MappingsContainer?, channel: String? = null ) { - val provider = if (version == null) { - if (channel != null) { - namespace.getProvider( - namespace.getDefaultVersion { channel } - ) - } else { - MappingsProvider.empty(namespace) - } - } else { - namespace.getProvider(version.version) + sentry.breadcrumb(BreadcrumbType.Query) { + message = "Beginning method lookup" + + data["channel"] = channel ?: "N/A" + data["namespace"] = namespace.id + data["query"] = givenQuery + data["version"] = version?.version ?: "N/A" } - provider.injectDefaultVersion( - namespace.getDefaultProvider { - channel ?: namespace.getDefaultMappingChannel() - } - ) - - val query = givenQuery.replace(".", "/") - var pages: List> - - message.channel.withTyping { - @Suppress("TooGenericExceptionCaught") - val result = try { - MappingsQuery.queryMethods( - QueryContext( - provider = provider, - searchKey = query - ) - ) - } catch (e: NullPointerException) { - message.respond(e.localizedMessage) - return@queryMethods - } + val context = newSingleThreadContext("m: $givenQuery") - pages = methodsToPages(namespace, provider.get(), result) - } + try { + withContext(context) { + val provider = if (version == null) { + if (channel != null) { + namespace.getProvider( + namespace.getDefaultVersion { channel } + ) + } else { + MappingsProvider.empty(namespace) + } + } else { + namespace.getProvider(version.version) + } - if (pages.isEmpty()) { - message.respond("No results found") - return - } + provider.injectDefaultVersion( + namespace.getDefaultProvider { + channel ?: namespace.getDefaultMappingChannel() + } + ) + + sentry.breadcrumb(BreadcrumbType.Info) { + message = "Provider resolved, with injected default version" - val meta = provider.get() + data["version"] = provider.version ?: "Unknown" + } - val pagesObj = Pages("${EXPAND_EMOJI.mention} for more") - val pageTitle = "List of ${meta.name} methods: ${meta.version}" + val query = givenQuery.replace(".", "/") + var pages: List> - val shortPages = mutableListOf() - val longPages = mutableListOf() + sentry.breadcrumb(BreadcrumbType.Info) { + message = "Attempting to run sanitized query" - pages.forEach { (short, long) -> - shortPages.add(short) - longPages.add(long) - } + data["query"] = query + } - shortPages.forEach { - pagesObj.addPage( - "${EXPAND_EMOJI.mention} for more", + message.channel.withTyping { + @Suppress("TooGenericExceptionCaught") + val result = try { + MappingsQuery.queryMethods( + QueryContext( + provider = provider, + searchKey = query + ) + ) + } catch (e: NullPointerException) { + message.respond(e.localizedMessage) + return@withContext + } - Page { - description = it - title = pageTitle + sentry.breadcrumb(BreadcrumbType.Info) { + message = "Generating pages for results" - footer { - text = PAGE_FOOTER - icon = PAGE_FOOTER_ICON + data["resultCount"] = result.value.size } + + pages = methodsToPages(namespace, provider.get(), result) + } + + if (pages.isEmpty()) { + message.respond("No results found") + return@withContext } - ) - } - if (shortPages != longPages) { - longPages.forEach { - pagesObj.addPage( - "${EXPAND_EMOJI.mention} for less", + val meta = provider.get() - Page { - description = it - title = pageTitle + val pagesObj = Pages("${EXPAND_EMOJI.mention} for more") + val pageTitle = "List of ${meta.name} methods: ${meta.version}" - footer { - text = PAGE_FOOTER - icon = PAGE_FOOTER_ICON + val shortPages = mutableListOf() + val longPages = mutableListOf() + + pages.forEach { (short, long) -> + shortPages.add(short) + longPages.add(long) + } + + shortPages.forEach { + pagesObj.addPage( + "${EXPAND_EMOJI.mention} for more", + + Page { + description = it + title = pageTitle + + footer { + text = PAGE_FOOTER + icon = PAGE_FOOTER_ICON + } } + ) + } + + if (shortPages != longPages) { + longPages.forEach { + pagesObj.addPage( + "${EXPAND_EMOJI.mention} for less", + + Page { + description = it + title = pageTitle + + footer { + text = PAGE_FOOTER + icon = PAGE_FOOTER_ICON + } + } + ) } + } + + sentry.breadcrumb(BreadcrumbType.Info) { + message = "Creating and sending paginator to Discord" + } + + val paginator = MessageButtonPaginator( + targetMessage = event.message, + pages = pagesObj, + keepEmbed = true, + owner = message.author, + timeoutSeconds = getTimeout(), + locale = getLocale(), ) + + paginator.send() } + } finally { + context.close() } - - val paginator = MessageButtonPaginator( - extension = this@MappingsExtension, - targetMessage = event.message, - pages = pagesObj, - keepEmbed = true, - owner = message.author, - timeoutSeconds = getTimeout(), - locale = getLocale(), - ) - - paginator.send() } private suspend fun getTimeout() = builder.config.getTimeout() - private suspend fun customChecks(command: String, namespace: Namespace): Check<*> { - val allChecks = builder.commandChecks.map { it.invoke(command) }.toMutableList() - val allNamespaceChecks = builder.namespaceChecks.map { it.invoke(namespace) }.toMutableList() + private suspend fun CheckContext.customChecks(command: String, namespace: Namespace) { + builder.commandChecks.forEach { + it(command)() - var rootCheck = allChecks.removeFirstOrNull() - ?: allNamespaceChecks.removeFirstOrNull() - ?: return { pass() } + if (!passed) { + return + } + } - allChecks.forEach { rootCheck = rootCheck and it } - allNamespaceChecks.forEach { rootCheck = rootCheck and it } + builder.namespaceChecks.forEach { + it(namespace)() - return rootCheck + if (!passed) { + return + } + } } companion object { diff --git a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/HashedMojangArguments.kt b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/HashedMojangArguments.kt new file mode 100644 index 0000000000..08e39f493a --- /dev/null +++ b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/HashedMojangArguments.kt @@ -0,0 +1,27 @@ +package com.kotlindiscord.kord.extensions.modules.extra.mappings.arguments + +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.converters.impl.optionalEnum +import com.kotlindiscord.kord.extensions.commands.converters.impl.string +import com.kotlindiscord.kord.extensions.modules.extra.mappings.converters.optionalMappingsVersion +import com.kotlindiscord.kord.extensions.modules.extra.mappings.enums.Channels +import me.shedaniel.linkie.namespaces.MojangHashedNamespace + +/** Arguments for hashed Mojang mappings lookup commands. **/ +@Suppress("UndocumentedPublicProperty") +class HashedMojangArguments : Arguments() { + val query by string("query", "Name to query mappings for") + + val channel by optionalEnum( + displayName = "channel", + description = "Mappings channel to use for this query", + typeName = "official/snapshot" + ) + + val version by optionalMappingsVersion( + "version", + "Minecraft version to use for this query", + true, + MojangHashedNamespace + ) +} diff --git a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/LegacyYarnArguments.kt b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/LegacyYarnArguments.kt index 438099c667..c73ffd8114 100644 --- a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/LegacyYarnArguments.kt +++ b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/LegacyYarnArguments.kt @@ -1,7 +1,7 @@ package com.kotlindiscord.kord.extensions.modules.extra.mappings.arguments +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.converters.impl.string -import com.kotlindiscord.kord.extensions.commands.parser.Arguments import com.kotlindiscord.kord.extensions.modules.extra.mappings.converters.optionalMappingsVersion import me.shedaniel.linkie.namespaces.LegacyYarnNamespace diff --git a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/MCPArguments.kt b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/MCPArguments.kt index 3b5370c40b..66027a3722 100644 --- a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/MCPArguments.kt +++ b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/MCPArguments.kt @@ -1,7 +1,7 @@ package com.kotlindiscord.kord.extensions.modules.extra.mappings.arguments +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.converters.impl.string -import com.kotlindiscord.kord.extensions.commands.parser.Arguments import com.kotlindiscord.kord.extensions.modules.extra.mappings.converters.optionalMappingsVersion import me.shedaniel.linkie.namespaces.MCPNamespace diff --git a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/MojangArguments.kt b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/MojangArguments.kt index b0edd449aa..5c6dbb7f4c 100644 --- a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/MojangArguments.kt +++ b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/MojangArguments.kt @@ -1,8 +1,8 @@ package com.kotlindiscord.kord.extensions.modules.extra.mappings.arguments +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.converters.impl.optionalEnum import com.kotlindiscord.kord.extensions.commands.converters.impl.string -import com.kotlindiscord.kord.extensions.commands.parser.Arguments import com.kotlindiscord.kord.extensions.modules.extra.mappings.converters.optionalMappingsVersion import com.kotlindiscord.kord.extensions.modules.extra.mappings.enums.Channels import me.shedaniel.linkie.namespaces.MojangNamespace diff --git a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/PlasmaArguments.kt b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/PlasmaArguments.kt index cbbbc9706d..92da012a79 100644 --- a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/PlasmaArguments.kt +++ b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/PlasmaArguments.kt @@ -1,7 +1,7 @@ package com.kotlindiscord.kord.extensions.modules.extra.mappings.arguments +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.converters.impl.string -import com.kotlindiscord.kord.extensions.commands.parser.Arguments import com.kotlindiscord.kord.extensions.modules.extra.mappings.converters.optionalMappingsVersion import me.shedaniel.linkie.namespaces.PlasmaNamespace diff --git a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/YarnArguments.kt b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/YarnArguments.kt index 6138b3e317..3ca311dcfb 100644 --- a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/YarnArguments.kt +++ b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/YarnArguments.kt @@ -1,8 +1,8 @@ package com.kotlindiscord.kord.extensions.modules.extra.mappings.arguments +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.converters.impl.optionalEnum import com.kotlindiscord.kord.extensions.commands.converters.impl.string -import com.kotlindiscord.kord.extensions.commands.parser.Arguments import com.kotlindiscord.kord.extensions.modules.extra.mappings.converters.optionalMappingsVersion import com.kotlindiscord.kord.extensions.modules.extra.mappings.enums.YarnChannels import me.shedaniel.linkie.namespaces.YarnNamespace diff --git a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/YarrnArguments.kt b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/YarrnArguments.kt index d9981830af..1d1aa889a9 100644 --- a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/YarrnArguments.kt +++ b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/arguments/YarrnArguments.kt @@ -1,7 +1,7 @@ package com.kotlindiscord.kord.extensions.modules.extra.mappings.arguments +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.converters.impl.string -import com.kotlindiscord.kord.extensions.commands.parser.Arguments import com.kotlindiscord.kord.extensions.modules.extra.mappings.converters.optionalMappingsVersion import me.shedaniel.linkie.namespaces.YarrnNamespace diff --git a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/builders/ExtMappingsBuilder.kt b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/builders/ExtMappingsBuilder.kt index 25f9694cca..9e01be2484 100644 --- a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/builders/ExtMappingsBuilder.kt +++ b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/builders/ExtMappingsBuilder.kt @@ -12,18 +12,18 @@ class ExtMappingsBuilder { var config: MappingsConfigAdapter = TomlMappingsConfig() /** List of checks to apply against the name of the command. **/ - val commandChecks: MutableList Check<*>> = mutableListOf() + val commandChecks: MutableList Check> = mutableListOf() /** List of checks to apply against the namespace corresponding with the command. **/ - val namespaceChecks: MutableList Check<*>> = mutableListOf() + val namespaceChecks: MutableList Check> = mutableListOf() /** Register a check that applies against the name of a command, and its message creation event. **/ fun commandCheck(check: suspend (String) -> Check) { - commandChecks.add(check as (suspend (String) -> Check<*>)) + commandChecks.add(check) } /** Register a check that applies against the mappings namespace for a command, and its message creation event. **/ fun namespaceCheck(check: suspend (Namespace) -> Check) { - namespaceChecks.add(check as (suspend (Namespace) -> Check<*>)) + namespaceChecks.add(check) } } diff --git a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/converters/MappingsVersionConverter.kt b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/converters/MappingsVersionConverter.kt index 0b6308014f..83a6afcba6 100644 --- a/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/converters/MappingsVersionConverter.kt +++ b/extra-modules/extra-mappings/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/mappings/converters/MappingsVersionConverter.kt @@ -2,15 +2,16 @@ package com.kotlindiscord.kord.extensions.modules.extra.mappings.converters -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.ConverterToOptional import com.kotlindiscord.kord.extensions.commands.converters.SingleConverter import com.kotlindiscord.kord.extensions.commands.converters.Validator -import com.kotlindiscord.kord.extensions.commands.parser.Argument -import com.kotlindiscord.kord.extensions.commands.parser.Arguments import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder import me.shedaniel.linkie.MappingsContainer @@ -34,20 +35,6 @@ class MappingsVersionConverter( if (arg in namespace.getAllVersions()) { val version = namespace.getProvider(arg).getOrNull() -// if (version == null) { -// throw CommandException("Invalid ${namespace.id} version: `$arg`") -// -// val created = namespace.createAndAdd(arg) -// -// if (created != null) { -// this.parsed = created -// } else { -// throw CommandException("Invalid ${namespace.id} version: `$arg`") -// } -// } else { -// this.parsed = version -// } - if (version != null) { this.parsed = version @@ -55,11 +42,28 @@ class MappingsVersionConverter( } } - throw CommandException("Invalid ${namespace.id} version: `$arg`") + throw DiscordRelayedException("Invalid ${namespace.id} version: `$arg`") } override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + val namespace = namespaceGetter.invoke() + + if (optionValue in namespace.getAllVersions()) { + val version = namespace.getProvider(optionValue).getOrNull() + + if (version != null) { + this.parsed = version + + return true + } + } + + throw DiscordRelayedException("Invalid ${namespace.id} version: `$optionValue`") + } } /** Optional mappings version converter; see KordEx bundled functions for more info. **/ diff --git a/extra-modules/extra-mappings/src/main/resources/kordex/mappings/default.toml b/extra-modules/extra-mappings/src/main/resources/kordex/mappings/default.toml index 787f875c64..d68f93eb03 100644 --- a/extra-modules/extra-mappings/src/main/resources/kordex/mappings/default.toml +++ b/extra-modules/extra-mappings/src/main/resources/kordex/mappings/default.toml @@ -20,8 +20,8 @@ allowed = [] banned = [] [settings] -# Which namespaces to allow lookups for - "legacy-yarn", "plasma", "mcp", "mojang", "yarn" or "yarrn" -namespaces = ["legacy-yarn", "plasma", "mcp", "mojang", "yarn", "yarrn"] +# Which namespaces to allow lookups for - "hashed-mojang", "legacy-yarn", "plasma", "mcp", "mojang", "yarn" or "yarrn" +namespaces = ["hashed-mojang", "legacy-yarn", "plasma", "mcp", "mojang", "yarn", "yarrn"] # How long to wait before closing mappings paginators (in seconds), defaults to 5 minutes # timeout = 300 diff --git a/extra-modules/extra-mappings/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt b/extra-modules/extra-mappings/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt index 6795038fbe..1ed5021c91 100644 --- a/extra-modules/extra-mappings/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt +++ b/extra-modules/extra-mappings/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt @@ -1,35 +1,35 @@ package com.kotlindiscord.kord.extensions.test.bot import com.kotlindiscord.kord.extensions.ExtensibleBot -import com.kotlindiscord.kord.extensions.checks.isNotbot +import com.kotlindiscord.kord.extensions.checks.isNotBot import com.kotlindiscord.kord.extensions.modules.extra.mappings.extMappings import com.kotlindiscord.kord.extensions.utils.env -import me.shedaniel.linkie.namespaces.YarnNamespace import org.koin.core.logger.Level suspend fun main() { - val bot = ExtensibleBot(env("TOKEN")!!) { + val bot = ExtensibleBot(env("TOKEN")) { koinLogLevel = Level.DEBUG - messageCommands { - check(isNotbot) + chatCommands { + check { isNotBot() } + enabled = true } - slashCommands { + applicationCommands { enabled = true } extensions { extMappings { - namespaceCheck { namespace -> - { - if (namespace == YarnNamespace) { - pass() - } else { - fail("Yarn only, ya dummy.") - } - } - } +// namespaceCheck { namespace -> +// { +// if (namespace == YarnNamespace) { +// pass() +// } else { +// fail("Yarn only, ya dummy.") +// } +// } +// } } } } diff --git a/extra-modules/extra-phishing/README.md b/extra-modules/extra-phishing/README.md new file mode 100644 index 0000000000..0a3a2e0c70 --- /dev/null +++ b/extra-modules/extra-phishing/README.md @@ -0,0 +1,56 @@ +# Phishing Extension + +[![Discord: Click here](https://img.shields.io/static/v1?label=Discord&message=Click%20here&color=7289DA&style=for-the-badge&logo=discord)](https://discord.gg/gjXqqCS) [![Release](https://img.shields.io/nexus/r/com.kotlindiscord.kord.extensions/extra-phishing?nexusVersion=3&logo=gradle&color=blue&label=Release&server=https%3A%2F%2Fmaven.kotlindiscord.com&style=for-the-badge)](https://maven.kotlindiscord.com/#browse/browse:maven-releases:com%2Fkotlindiscord%2Fkord%2Fextensions%2Fextra-phishing) [![Snapshot](https://img.shields.io/nexus/s/com.kotlindiscord.kord.extensions/extra-phishing?logo=gradle&color=orange&label=Snapshot&server=https%3A%2F%2Fmaven.kotlindiscord.com&style=for-the-badge)](https://maven.kotlindiscord.com/#browse/browse:maven-snapshots:com%2Fkotlindiscord%2Fkord%2Fextensions%2Fextra-phishing) + +This module contains an extension written to provide some anti-phishing protection, based on the crowdsourced [Sinking Yachts API](https://phish.sinking.yachts/docs). + +# Getting Started + +* **Maven repo:** `https://maven.kotlindiscord.com/repository/maven-public/` +* **Maven coordinates:** `com.kotlindiscord.kord.extensions:extra-phishing:VERSION` + +At its simplest, you can add this extension directly to your bot with a minimum configuration. For example: + +```kotlin +suspend fun main() { + val bot = ExtensibleBot(System.getenv("TOKEN")) { + + extensions { + extPhishing { + appName = "My Bot" + } + } + } + + bot.start() +} +``` + +This will install the extension using its default configuration. However, the extension may be configured in several ways - as is detailed below. + +# Commands + +This extension provides a number of commands for use on Discord. + +* Slash command: `/phishing-check`, for checking whether the given argument is a phishing domain +* Message command: `Phishing Check`, for manually checking a specific message via the right-click menu + +Access to both commands can be limited to a specific Discord permission. This can be configured below, but defaults to "Manage Messages". + +# Configuration + +To configure this module, values can be provided within the `extPhishing` builder. + +* **Required:** `appName` - Give your application a name so that Sinking Yachts can identify it for statistics purposes + +* `detectionAction` (default: Delete) - What to do when a message containing a phishing domain is detected +* `logChannelName` (default: "logs") - The name of the channel to use for logging; the extension will search the channels present on the current server and use the last one with an exactly-matching name +* `notifyUser` (default: True) - Whether to DM the user, letting them know they posted a phishing domain and what action was taken +* `requiredCommandPermission` (default: Manage Server) - The permission a user must have in order to run the bundled message and slash commands +* `updateDelay` (default: 15 minutes) - How often to check for new phishing domains, five minutes at minimum +* `urlRegex` (default: [The Perfect URL Regex](https://urlregex.com/)) - A regular expression used to extract domains from messages, with **exactly one capturing group containing the entire domain** (and optionally the URL path) + +Additionally, the following configuration functions are available: + +* `check` - Used to define checks that must pass for event handlers to be run, and thus for messages to be checked (you could use this to exempt your staff, for example) +* `regex` - A convenience function for registering a `String` as URL regex, with the case-insensitive flag diff --git a/extra-modules/extra-phishing/build.gradle.kts b/extra-modules/extra-phishing/build.gradle.kts new file mode 100644 index 0000000000..d9eb8ff125 --- /dev/null +++ b/extra-modules/extra-phishing/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + `kordex-module` + `published-module` + `dokka-module` + `disable-explicit-api-mode` + + kotlin("plugin.serialization") +} + +repositories { + maven { + name = "KotDis" + url = uri("https://maven.kotlindiscord.com/repository/maven-public/") + } +} + +dependencies { + detektPlugins(libs.detekt) + + implementation(libs.logging) + implementation(libs.kotlin.stdlib) + implementation(libs.ktor.logging) + + testImplementation(libs.groovy) // For logback config + testImplementation(libs.logback) + + implementation(project(":kord-extensions")) +} + +group = "com.kotlindiscord.kord.extensions" + +kordex { + jvmTarget.set("9") + javaVersion.set(JavaVersion.VERSION_1_9) +} diff --git a/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/DetectionAction.kt b/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/DetectionAction.kt new file mode 100644 index 0000000000..2122d61075 --- /dev/null +++ b/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/DetectionAction.kt @@ -0,0 +1,22 @@ +package com.kotlindiscord.kord.extensions.modules.extra.phishing + +/** + * Sealed class representing what should happen when a phishing link is detected. + * + * The extension will always try to log the message, but you can specify [LogOnly] if that's _all_ you want. + * + * @property message Message to return to the user. + */ +sealed class DetectionAction(val message: String) { + /** Ban 'em and delete the message. **/ + object Ban : DetectionAction("you have been banned from the server") + + /** Delete the message. **/ + object Delete : DetectionAction("it has been deleted") + + /** Kick 'em and delete the message. **/ + object Kick : DetectionAction("you have been kicked from the server") + + /** Don't do anything, just log it in the logs channel. **/ + object LogOnly : DetectionAction("it has been logged for the server staff to review") +} diff --git a/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/DomainChange.kt b/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/DomainChange.kt new file mode 100644 index 0000000000..622c9fe2d2 --- /dev/null +++ b/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/DomainChange.kt @@ -0,0 +1,26 @@ +package com.kotlindiscord.kord.extensions.modules.extra.phishing + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Data class representing a Sinking Yachts domain change object. + * + * @property type Domain change type - add or delete + * @property domains Set of domains that this change concerns + */ +@Serializable +data class DomainChange( + val type: DomainChangeType, + val domains: Set +) + +/** Enum representing domain change types. **/ +@Serializable +enum class DomainChangeType { + @SerialName("add") + Add, + + @SerialName("delete") + Delete +} diff --git a/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/ExtPhishingBuilder.kt b/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/ExtPhishingBuilder.kt new file mode 100644 index 0000000000..2ff7b4e4cd --- /dev/null +++ b/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/ExtPhishingBuilder.kt @@ -0,0 +1,85 @@ +@file:OptIn(ExperimentalTime::class) +@file:Suppress("MagicNumber") + +package com.kotlindiscord.kord.extensions.modules.extra.phishing + +import com.kotlindiscord.kord.extensions.checks.types.Check +import dev.kord.common.entity.Permission +import dev.kord.core.event.Event +import kotlin.time.Duration +import kotlin.time.ExperimentalTime + +/** Builder used to configure the phishing extension. **/ +class ExtPhishingBuilder { + /** The name of your application, which allows the Sinking Yachts maintainers to identify what it is. **/ + lateinit var appName: String + + /** Delay between domain update checks, 5 minutes at minimum. **/ + var updateDelay = Duration.minutes(15) + + /** + * Regular expression used to extract domains from messages. + * + * If you customize this, it must contain exactly one capturing group, containing the full domain name, and + * optionally the path, if this is an actual URL. You can mark a group as non-capturing by prefixing it with + * `?:`. + * + * The provided regex comes from https://urlregex.com/ - but you can provide a different regex if you need + * detection to be more sensitive than just clickable links. + */ + var urlRegex = "(?:https?|ftp|file|discord)://([-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|])" + .toRegex(RegexOption.IGNORE_CASE) + + /** @suppress List of checks to apply to event handlers. **/ + val checks: MutableList> = mutableListOf() + + /** + * If you want to require a permission for the phishing check commands, supply it here. Alternatively, supply + * `null` and everyone will be given access to them. + */ + var requiredCommandPermission: Permission? = Permission.ManageMessages + + /** + * What to do when a message creation/edit contains a phishing domain. + * + * @see DetectionAction + */ + var detectionAction: DetectionAction = DetectionAction.Delete + + /** Whether to DM users when their messages contain phishing domains, with the action taken. **/ + var notifyUser = true + + /** + * The name of the logs channel to use for detection messages, if not "logs". + * + * The extension will try to find the last channel in the channel list with a name exactly matching the + * given name here, "logs" by default. + */ + var logChannelName = "logs" + + /** Register a check that must pass in order for an event handler to run, and for messages to be processed. **/ + fun check(check: Check) { + checks.add(check) + } + + /** Register checks that must pass in order for an event handler to run, and for messages to be processed. **/ + fun check(vararg checkList: Check) { + checks.addAll(checkList) + } + + /** Convenience function for supplying a case-insensitive [urlRegex]. **/ + fun regex(pattern: String) { + urlRegex = pattern.toRegex(RegexOption.IGNORE_CASE) + } + + /** @suppress **/ + fun validate() { + if (!this::appName.isInitialized) { + error("Application name must be provided") + } + + if (updateDelay < Duration.minutes(5)) { + error("The update delay must be at least five minutes - don't spam the API!") + } + } +} diff --git a/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/Functions.kt b/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/Functions.kt new file mode 100644 index 0000000000..deb9008f49 --- /dev/null +++ b/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/Functions.kt @@ -0,0 +1,16 @@ +package com.kotlindiscord.kord.extensions.modules.extra.phishing + +import com.kotlindiscord.kord.extensions.builders.ExtensibleBotBuilder + +/** + * Configure the phishing extension and add it to the bot. + */ +inline fun ExtensibleBotBuilder.ExtensionsBuilder.extPhishing(builder: ExtPhishingBuilder.() -> Unit) { + val settings = ExtPhishingBuilder() + + builder(settings) + + settings.validate() + + add { PhishingExtension(settings) } +} diff --git a/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/PhishingApi.kt b/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/PhishingApi.kt new file mode 100644 index 0000000000..27e654210b --- /dev/null +++ b/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/PhishingApi.kt @@ -0,0 +1,42 @@ +package com.kotlindiscord.kord.extensions.modules.extra.phishing + +import io.ktor.client.* +import io.ktor.client.features.json.* +import io.ktor.client.features.logging.* +import io.ktor.client.request.* + +internal const val ALL_PATH = "https://phish.sinking.yachts/v2/all" +internal const val CHECK_PATH = "https://phish.sinking.yachts/v2/check/%" +internal const val RECENT_PATH = "https://phish.sinking.yachts/v2/recent/%" +internal const val SIZE_PATH = "https://phish.sinking.yachts/v2/dbsize" + +/** Implementation of the Sinking Yachts phishing domain API. **/ +class PhishingApi(internal val appName: String) { + internal val client = HttpClient { + install(JsonFeature) + + install(Logging) { + level = LogLevel.INFO + } + } + + internal suspend inline fun get(url: String): T = client.get(url) { + header("X-Identity", "$appName (via Kord Extensions)") + } + + /** Get all known phishing domains from the API. **/ + suspend fun getAllDomains(): Set = + get(ALL_PATH) + + /** Query the API directly to check a specific domain. **/ + suspend fun checkDomain(domain: String): Boolean = + get(CHECK_PATH.replace("%", domain)) + + /** Get all new phishing domains added in the previous [seconds] seconds. **/ + suspend fun getRecentDomains(seconds: Long): List = + get(RECENT_PATH.replace("%", seconds.toString())) + + /** Get the total number of phishing domains that the API knows about. **/ + suspend fun getTotalDomains(): Long = + get(SIZE_PATH) +} diff --git a/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/PhishingExtension.kt b/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/PhishingExtension.kt new file mode 100644 index 0000000000..4d95810a0e --- /dev/null +++ b/extra-modules/extra-phishing/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/extra/phishing/PhishingExtension.kt @@ -0,0 +1,306 @@ +@file:OptIn(ExperimentalTime::class) +@file:Suppress("StringLiteralDuplication") + +package com.kotlindiscord.kord.extensions.modules.extra.phishing + +import com.kotlindiscord.kord.extensions.DISCORD_RED +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.checks.hasPermission +import com.kotlindiscord.kord.extensions.checks.isNotBot +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.converters.impl.string +import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.extensions.ephemeralMessageCommand +import com.kotlindiscord.kord.extensions.extensions.ephemeralSlashCommand +import com.kotlindiscord.kord.extensions.extensions.event +import com.kotlindiscord.kord.extensions.types.respond +import com.kotlindiscord.kord.extensions.utils.dm +import com.kotlindiscord.kord.extensions.utils.getJumpUrl +import com.kotlindiscord.kord.extensions.utils.scheduling.Scheduler +import com.kotlindiscord.kord.extensions.utils.scheduling.Task +import dev.kord.core.behavior.ban +import dev.kord.core.behavior.channel.createMessage +import dev.kord.core.entity.Message +import dev.kord.core.entity.channel.GuildMessageChannel +import dev.kord.core.event.message.MessageCreateEvent +import dev.kord.core.event.message.MessageUpdateEvent +import dev.kord.rest.builder.message.create.embed +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.lastOrNull +import kotlinx.coroutines.launch +import mu.KotlinLogging +import kotlin.time.ExperimentalTime + +/** Phishing extension, responsible for checking for phishing domains in messages. **/ +class PhishingExtension(private val settings: ExtPhishingBuilder) : Extension() { + override val name = "phishing" + + private val api = PhishingApi(settings.appName) + private val domainCache: MutableSet = mutableSetOf() + private val logger = KotlinLogging.logger { } + + private val scheduler = Scheduler() + private var checkTask: Task? = null + + override suspend fun setup() { + domainCache.addAll(api.getAllDomains()) + + checkTask = scheduler.schedule(settings.updateDelay, pollingSeconds = 30, callback = ::updateDomains) + + event { + check { isNotBot() } + check { event.message.author != null } + check { event.guildId != null } + + check { + settings.checks.forEach { + if (passed) it() + } + } + + action { + handleMessage(event.message) + } + } + + event { + check { isNotBot() } + check { event.new.author.value != null } + check { event.new.guildId.value != null } + + check { + settings.checks.forEach { + if (passed) it() + } + } + + action { + handleMessage(event.message.asMessage()) + } + } + + ephemeralMessageCommand { + name = "Phishing Check" + + if (this@PhishingExtension.settings.requiredCommandPermission != null) { + check { hasPermission(this@PhishingExtension.settings.requiredCommandPermission!!) } + } + + action { + for (message in targetMessages) { + val domains = parseDomains(message.content.lowercase()) + val matches = domains intersect domainCache + + respond { + content = if (matches.isNotEmpty()) { + "⚠️ [Message ${message.id.value}](${message.getJumpUrl()}) " + + "**contains ${matches.size} phishing link/s**." + } else { + "✅ [Message ${message.id.value}](${message.getJumpUrl()}) " + + "**does not contain any phishing links**." + } + } + } + } + } + + ephemeralSlashCommand(::DomainArgs) { + name = "phishing-check" + description = "Check whether a given domain is a known phishing domain." + + if (this@PhishingExtension.settings.requiredCommandPermission != null) { + check { hasPermission(this@PhishingExtension.settings.requiredCommandPermission!!) } + } + + action { + respond { + content = if (domainCache.contains(arguments.domain.lowercase())) { + "⚠️ `${arguments.domain}` is a known phishing domain." + } else { + "✅ `${arguments.domain}` is not a known phishing domain." + } + } + } + } + } + + internal suspend fun handleMessage(message: Message) { + val domains = parseDomains(message.content.lowercase()) + val matches = domains intersect domainCache + + if (matches.isNotEmpty()) { + logger.debug { "Found a message with ${matches.size} phishing domains." } + + if (settings.notifyUser) { + message.kord.launch { + message.author!!.dm { + content = "We've detected that the following message contains a phishing domain. For this " + + "reason, **${settings.detectionAction.message}**." + + embed { + title = "Phishing domain detected" + description = message.content + color = DISCORD_RED + + field { + inline = true + + name = "Channel" + value = message.channel.mention + } + + field { + inline = true + + name = "Message ID" + value = "`${message.id.value}`" + } + + field { + inline = true + + name = "Server" + value = message.getGuild().name + } + } + } + } + } + + when (settings.detectionAction) { + DetectionAction.Ban -> { + message.getAuthorAsMember()!!.ban { + reason = "Message contained a phishing domain" + } + + message.delete("Message contained a phishing domain") + } + + DetectionAction.Delete -> message.delete("Message contained a phishing domain") + + DetectionAction.Kick -> { + message.getAuthorAsMember()!!.kick("Message contained a phishing domain") + message.delete("Message contained a phishing domain") + } + + DetectionAction.LogOnly -> { + // Do nothing, we always log + } + } + + logDeletion(message, matches) + } + } + + internal suspend fun logDeletion(message: Message, matches: Set) { + val guild = message.getGuild() + + val channel = message + .getGuild() + .channels + .filter { it.name == settings.logChannelName } + .lastOrNull() + ?.asChannelOrNull() as? GuildMessageChannel + + if (channel == null) { + logger.warn { + "Unable to find a channel named ${settings.logChannelName} on ${guild.name} (${guild.id.value})" + } + + return + } + + val matchList = "# Phishing Domain Matches\n\n" + + "**Total:** ${matches.size}\n\n" + + matches.joinToString("\n") { "* `$it`" } + + channel.createMessage { + addFile("matches.md", matchList.byteInputStream()) + + embed { + title = "Phishing domain detected" + description = message.content + color = DISCORD_RED + + field { + inline = true + + name = "Author" + value = "${message.author!!.mention} (" + + "`${message.author!!.tag}` / " + + "`${message.author!!.id.value}`" + + ")" + } + + field { + inline = true + + name = "Channel" + value = "${message.channel.mention} (`${message.channelId.value}`)" + } + + field { + inline = true + + name = "Message" + value = "[`${message.id.value}`](${message.getJumpUrl()})" + } + + field { + inline = true + + name = "Total Matches" + value = matches.size.toString() + } + } + } + } + + internal fun parseDomains(content: String): MutableSet { + val domains: MutableSet = mutableSetOf() + + for (match in settings.urlRegex.findAll(content)) { + var found = match.groups[1]!!.value.trim('/') + + if ("/" in found) { + found = found.split("/", limit = 2).first() + } + + domains.add(found) + } + + logger.debug { "Found ${domains.size} domains: ${domains.joinToString()}" } + + return domains + } + + override suspend fun unload() { + checkTask?.cancel() + checkTask = null + } + + @Suppress("MagicNumber") + internal suspend fun updateDomains() { + logger.trace { "Updating domains..." } + + // An extra 30 seconds for safety + api.getRecentDomains(settings.updateDelay.inWholeSeconds + 30).forEach { + when (it.type) { + DomainChangeType.Add -> domainCache.addAll(it.domains) + DomainChangeType.Delete -> domainCache.removeAll(it.domains) + } + } + + checkTask?.restart() // Off we go again + } + + /** Arguments class for domain-relevant commands. **/ + inner class DomainArgs : Arguments() { + /** Targeted domain string. **/ + val domain by string("domain", "Domain to check") { _, value -> + if ("/" in value) { + throw DiscordRelayedException("Please provide the domain name only, without the protocol or a path.") + } + } + } +} diff --git a/extra-modules/extra-phishing/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt b/extra-modules/extra-phishing/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt new file mode 100644 index 0000000000..417f949f93 --- /dev/null +++ b/extra-modules/extra-phishing/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt @@ -0,0 +1,31 @@ +package com.kotlindiscord.kord.extensions.test.bot + +import com.kotlindiscord.kord.extensions.ExtensibleBot +import com.kotlindiscord.kord.extensions.checks.isNotBot +import com.kotlindiscord.kord.extensions.modules.extra.phishing.extPhishing +import com.kotlindiscord.kord.extensions.utils.env +import org.koin.core.logger.Level + +suspend fun main() { + val bot = ExtensibleBot(env("TOKEN")) { + koinLogLevel = Level.DEBUG + + chatCommands { + check { isNotBot() } + enabled = true + } + + applicationCommands { + enabled = true + } + + extensions { + extPhishing { + appName = "Integration test bot" + logChannelName = "alerts" + } + } + } + + bot.start() +} diff --git a/extra-modules/extra-phishing/src/test/resources/junit-platform.properties b/extra-modules/extra-phishing/src/test/resources/junit-platform.properties new file mode 100644 index 0000000000..1d27b78fbb --- /dev/null +++ b/extra-modules/extra-phishing/src/test/resources/junit-platform.properties @@ -0,0 +1 @@ +junit.jupiter.execution.parallel.enabled=true diff --git a/extra-modules/extra-phishing/src/test/resources/logback.groovy b/extra-modules/extra-phishing/src/test/resources/logback.groovy new file mode 100644 index 0000000000..1bab8d7d2d --- /dev/null +++ b/extra-modules/extra-phishing/src/test/resources/logback.groovy @@ -0,0 +1,30 @@ +import ch.qos.logback.core.joran.spi.ConsoleTarget + +def environment = System.getenv().getOrDefault("ENVIRONMENT", "production") + +def defaultLevel = DEBUG + +if (environment == "spam") { + logger("dev.kord.rest.DefaultGateway", TRACE) +} else { + // Silence warning about missing native PRNG + logger("io.ktor.util.random", ERROR) +} + +appender("CONSOLE", ConsoleAppender) { + encoder(PatternLayoutEncoder) { + pattern = "%d{yyyy-MM-dd HH:mm:ss:SSS Z} | %5level | %40.40logger{40} | %msg%n" + } + + target = ConsoleTarget.SystemErr +} + +appender("FILE", FileAppender) { + file = "output.log" + + encoder(PatternLayoutEncoder) { + pattern = "%d{yyyy-MM-dd HH:mm:ss:SSS Z} | %5level | %40.40logger{40} | %msg%n" + } +} + +root(defaultLevel, ["CONSOLE", "FILE"]) diff --git a/gradle.properties b/gradle.properties index 9e583586fa..ebb2d0ed50 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,4 +3,7 @@ kotlin.incremental = true ksp.incremental = false -projectVersion = 1.4.4-RC4 +projectVersion = 1.5.1-RC1 + +#dokka will run out of memory with the default meta space +org.gradle.jvmargs=-XX:MaxMetaspaceSize=1024m diff --git a/kord-extensions/build.gradle.kts b/kord-extensions/build.gradle.kts index 9235379a9b..7801faa26f 100644 --- a/kord-extensions/build.gradle.kts +++ b/kord-extensions/build.gradle.kts @@ -1,5 +1,4 @@ -import java.io.ByteArrayOutputStream -import java.net.URL +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile buildscript { repositories { @@ -11,13 +10,11 @@ buildscript { } plugins { - `maven-publish` - - kotlin("jvm") - - id("com.google.devtools.ksp") - id("io.gitlab.arturbosch.detekt") - id("org.jetbrains.dokka") + `kordex-module` + `published-module` + `dokka-module` + `tested-module` + `ksp-module` } dependencies { @@ -46,152 +43,12 @@ dependencies { ksp(project(":annotation-processor")) } -val sourceJar = task("sourceJar", Jar::class) { - dependsOn(tasks["classes"]) - archiveClassifier.set("sources") - from(sourceSets.main.get().allSource) -} - -val javadocJar = task("javadocJar", Jar::class) { - dependsOn("dokkaJavadoc") - archiveClassifier.set("javadoc") - from(tasks.javadoc) -} - -java { - sourceCompatibility = JavaVersion.VERSION_1_9 - targetCompatibility = JavaVersion.VERSION_1_9 -} - -kotlin { - explicitApi() -} - -sourceSets { - main { - java { - srcDir(file("$buildDir/generated/ksp/main/kotlin/")) - } - } - - test { - java { - srcDir(file("$buildDir/generated/ksp/test/kotlin/")) - } - } -} - -detekt { - buildUponDefaultConfig = true - config = files("../detekt.yml") - - autoCorrect = true -} - -publishing { - repositories { - maven { - name = "KotDis" - - url = if (project.version.toString().contains("SNAPSHOT")) { - uri("https://maven.kotlindiscord.com/repository/maven-snapshots/") - } else { - uri("https://maven.kotlindiscord.com/repository/maven-releases/") - } - - credentials { - username = project.findProperty("kotdis.user") as String? - ?: System.getenv("KOTLIN_DISCORD_USER") - - password = project.findProperty("kotdis.password") as String? - ?: System.getenv("KOTLIN_DISCORD_PASSWORD") - } - - version = project.version - } - } - - publications { - create("maven") { - from(components.getByName("java")) - - artifact(sourceJar) - artifact(javadocJar) - } - } -} - -fun runCommand(command: String): String { - val output = ByteArrayOutputStream() - - project.exec { - commandLine(command.split(" ")) - standardOutput = output - } - - return output.toString().trim() -} - -fun getCurrentGitBranch(): String { // https://gist.github.com/lordcodes/15b2a4aecbeff7c3238a70bfd20f0931 - var gitBranch = "Unknown branch" - - try { - gitBranch = runCommand("git rev-parse --abbrev-ref HEAD") - } catch (t: Throwable) { - println(t) - } - - return gitBranch -} - -tasks.dokkaHtml.configure { - moduleName.set("Kord Extensions") - - dokkaSourceSets { - configureEach { - includeNonPublic.set(false) - skipDeprecated.set(false) - - displayName.set("Kord Extensions") - includes.from("packages.md") - jdkVersion.set(8) - - sourceLink { - localDirectory.set(file("${project.projectDir}/src/main/kotlin")) - - remoteUrl.set( - URL( - "https://github.com/Kotlin-Discord/kord-extensions/" + - "tree/${getCurrentGitBranch()}/kord-extensions/src/main/kotlin" - ) - ) - - remoteLineSuffix.set("#L") - } - - externalDocumentationLink { - url.set(URL("http://kordlib.github.io/kord/common/common/")) - } - - externalDocumentationLink { - url.set(URL("http://kordlib.github.io/kord/core/core/")) - } - } - } -} - -tasks.test { - useJUnitPlatform() - - testLogging.showStandardStreams = true - - testLogging { - events("PASSED", "FAILED", "SKIPPED", "STANDARD_OUT", "STANDARD_ERROR") - } +val compileKotlin: KotlinCompile by tasks - systemProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug") +compileKotlin.kotlinOptions { + languageVersion = "1.5" } -tasks.build { - this.finalizedBy(sourceJar, javadocJar) +dokkaModule { + includes.add("packages.md") } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/Exceptions.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/Exceptions.kt index f4c25bb7e8..af6b8d68e6 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/Exceptions.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/Exceptions.kt @@ -1,8 +1,11 @@ package com.kotlindiscord.kord.extensions -import com.kotlindiscord.kord.extensions.commands.MessageCommand +import com.kotlindiscord.kord.extensions.commands.Argument +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.chat.ChatCommand import com.kotlindiscord.kord.extensions.events.EventHandler import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.parser.StringParser import kotlin.reflect.KClass /** @@ -50,10 +53,10 @@ public class EventHandlerRegistrationException(public val reason: String) : Exte } /** - * Thrown when a [MessageCommand] could not be validated. + * Thrown when a [ChatCommand] could not be validated. * - * @param name The [MessageCommand] name - * @param reason Why this [MessageCommand] is considered invalid. + * @param name The [ChatCommand] name + * @param reason Why this [ChatCommand] is considered invalid. */ public class InvalidCommandException(public val name: String?, public val reason: String) : ExtensionsException() { override fun toString(): String { @@ -66,10 +69,10 @@ public class InvalidCommandException(public val name: String?, public val reason } /** - * Thrown when an attempt to register a [MessageCommand] fails. + * Thrown when an attempt to register a [ChatCommand] fails. * - * @param name The [MessageCommand] name - * @param reason Why this [MessageCommand] could not be registered. + * @param name The [ChatCommand] name + * @param reason Why this [ChatCommand] could not be registered. */ public class CommandRegistrationException(public val name: String?, public val reason: String) : ExtensionsException() { override fun toString(): String { @@ -82,14 +85,40 @@ public class CommandRegistrationException(public val name: String?, public val r } /** - * Thrown when something bad happens during command processing. + * Thrown when something exceptional happens that the actioning user on Discord needs to be aware of. * * Provided [reason] will be returned to the user verbatim. * - * @param reason Human-readable reason for the failure. + * @param reason Human-readable reason for the failure. May be translated. + * @param translationKey Translation key used to create the [reason] string, if any. */ -public open class CommandException(public var reason: String) : ExtensionsException() { - public constructor(other: CommandException) : this(other.reason) +public open class DiscordRelayedException( + public open val reason: String, + public open val translationKey: String? = null +) : ExtensionsException() { + public constructor(other: DiscordRelayedException) : this(other.reason) + + override fun toString(): String = reason +} + +/** + * Thrown when something happens during argument parsing. + * + * @param reason Human-readable reason for the failure. May be translated. + * @param translationKey Translation key used to create the [reason] string, if any. + * @param argument Current Argument object, if any. + * @param arguments Arguments object for the command. + * @param parser Tokenizing string parser used for this parse attempt, if this was a chat command. + */ +public open class ArgumentParsingException( + public override val reason: String, + public override val translationKey: String?, + public val argument: Argument<*>?, + public val arguments: Arguments, + public val parser: StringParser? +) : DiscordRelayedException(reason, translationKey) { + public constructor(other: ArgumentParsingException) : + this(other.reason, other.translationKey, other.argument, other.arguments, other.parser) override fun toString(): String = reason } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/ExtensibleBot.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/ExtensibleBot.kt index 10c3014c64..7ad781da63 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/ExtensibleBot.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/ExtensibleBot.kt @@ -3,12 +3,11 @@ package com.kotlindiscord.kord.extensions import com.kotlindiscord.kord.extensions.builders.ExtensibleBotBuilder -import com.kotlindiscord.kord.extensions.commands.MessageCommand -import com.kotlindiscord.kord.extensions.commands.MessageCommandRegistry -import com.kotlindiscord.kord.extensions.commands.parser.Arguments -import com.kotlindiscord.kord.extensions.commands.slash.SlashCommandRegistry +import com.kotlindiscord.kord.extensions.commands.application.ApplicationCommandRegistry +import com.kotlindiscord.kord.extensions.commands.chat.ChatCommandRegistry +import com.kotlindiscord.kord.extensions.components.ComponentRegistry import com.kotlindiscord.kord.extensions.events.EventHandler -import com.kotlindiscord.kord.extensions.events.ExtensionEvent +import com.kotlindiscord.kord.extensions.events.KordExEvent import com.kotlindiscord.kord.extensions.extensions.Extension import com.kotlindiscord.kord.extensions.extensions.impl.HelpExtension import com.kotlindiscord.kord.extensions.extensions.impl.SentryExtension @@ -20,7 +19,7 @@ import dev.kord.core.event.Event import dev.kord.core.event.gateway.DisconnectEvent import dev.kord.core.event.gateway.ReadyEvent import dev.kord.core.event.guild.GuildCreateEvent -import dev.kord.core.event.interaction.InteractionCreateEvent +import dev.kord.core.event.interaction.* import dev.kord.core.event.message.MessageCreateEvent import dev.kord.core.on import dev.kord.gateway.Intents @@ -33,7 +32,6 @@ import kotlinx.coroutines.launch import mu.KLogger import mu.KotlinLogging import org.koin.core.component.KoinComponent -import org.koin.core.component.inject import org.koin.dsl.bind /** @@ -49,50 +47,6 @@ import org.koin.dsl.bind * @param token Token for connecting to Discord. */ public open class ExtensibleBot(public val settings: ExtensibleBotBuilder, private val token: String) : KoinComponent { - /** - * @suppress - */ - @Deprecated( - "Use Koin to get this instead. This will be private in future.", - - ReplaceWith( - "getKoin().get()", - - "com.kotlindiscord.kord.extensions.utils.getKoin", - "dev.kord.core.Kord" - ), - level = DeprecationLevel.ERROR - ) - public val kord: Kord by inject() - - /** Message command registry, keeps track of and executes message commands. **/ - @Deprecated( - "Use Koin to get this instead. This will be made private in future.", - - ReplaceWith( - "getKoin().get()", - - "com.kotlindiscord.kord.extensions.utils.getKoin", - "com.kotlindiscord.kord.extensions.commands.MessageCommandRegistry" - ), - level = DeprecationLevel.ERROR - ) - public open val messageCommands: MessageCommandRegistry by inject() - - /** Slash command registry, keeps track of and executes slash commands. **/ - @Deprecated( - "Use Koin to get this instead. This will be made private in future.", - - ReplaceWith( - "getKoin().get()", - - "com.kotlindiscord.kord.extensions.utils.getKoin", - "com.kotlindiscord.kord.extensions.commands.slash.SlashCommandRegistry" - ), - level = DeprecationLevel.ERROR - ) - public open val slashCommands: SlashCommandRegistry by inject() - /** * A list of all registered event handlers. */ @@ -117,45 +71,44 @@ public open class ExtensibleBot(public val settings: ExtensibleBotBuilder, priva /** @suppress Function that sets up the bot early on, called by the builder. **/ public open suspend fun setup() { - val kord = Kord(token) { + val kord = settings.kordBuilder(token) { cache { settings.cacheBuilder.builder.invoke(this, it) } defaultStrategy = settings.cacheBuilder.defaultStrategy - if (settings.intentsBuilder != null) { - this.intents = Intents(settings.intentsBuilder!!) - } - if (settings.shardingBuilder != null) { sharding(settings.shardingBuilder!!) } enableShutdownHook = settings.hooksBuilder.kordShutdownHook - settings.kordBuilders.forEach { it() } + settings.kordHooks.forEach { it() } } loadModule { single { kord } bind Kord::class } settings.cacheBuilder.dataCacheBuilder.invoke(kord, kord.cache) - registerListeners() - addDefaultExtensions() - kord.on { - kord.launch { + this.launch { send(this@on) } } + + addDefaultExtensions() } /** Start up the bot and log into Discord. **/ public open suspend fun start() { settings.hooksBuilder.runBeforeStart(this) + registerListeners() - getKoin().get().login(settings.presenceBuilder) + getKoin().get().login { + this.presence(settings.presenceBuilder) + this.intents = Intents(settings.intentsBuilder!!) + } } /** This function sets up all of the bot's default event listeners. **/ @@ -178,39 +131,61 @@ public open class ExtensibleBot(public val settings: ExtensibleBotBuilder, priva logger.warn { "Disconnected: $closeCode" } } - on { - if (!initialized) { // We do this because a reconnect will cause this event to happen again. - initialized = true - - if (settings.slashCommandsBuilder.enabled) { - getKoin().get().syncAll() - } else { - logger.info { - "Slash command support is disabled - set `enabled` to `true` in the `slashCommands` builder" + - " if you want to use them." - } - } - } + on { + getKoin().get().handle(this) + } - logger.info { "Ready!" } + on { + getKoin().get().handle(this) } - if (settings.messageCommandsBuilder.enabled) { + if (settings.chatCommandsBuilder.enabled) { on { - getKoin().get().handleEvent(this) + getKoin().get().handleEvent(this) + } + } else { + logger.debug { + "Chat command support is disabled - set `enabled` to `true` in the `chatCommands` builder" + + " if you want to use them." } } - if (settings.slashCommandsBuilder.enabled) { - on { - getKoin().get().handle(this) + if (settings.applicationCommandsBuilder.enabled) { + on { + getKoin().get().handle(this) + } + + on { + getKoin().get().handle(this) + } + + on { + getKoin().get().handle(this) } + + getKoin().get().initialRegistration() + } else { + logger.debug { + "Application command support is disabled - set `enabled` to `true` in the " + + "`applicationCommands` builder if you want to use them." + } + } + + if (!initialized) { + eventHandlers.forEach { handler -> + handler.listenerRegistrationCallable?.invoke() ?: logger.error { + "Event handler $handler does not have a listener registration callback. This should never happen!" + } + } + + initialized = true } } /** This function adds all of the default extensions when the bot is being set up. **/ public open suspend fun addDefaultExtensions() { val extBuilder = settings.extensionsBuilder + if (extBuilder.helpExtensionBuilder.enableBundledExtension) { this.addExtension(::HelpExtension) } @@ -224,13 +199,13 @@ public open class ExtensibleBot(public val settings: ExtensibleBotBuilder, priva * Subscribe to an event. You shouldn't need to use this directly, but it's here just in case. * * You can subscribe to any type, realistically - but this is intended to be used only with Kord - * [Event] subclasses, and our own [ExtensionEvent]s. + * [Event] subclasses, and our own [KordExEvent]s. * * @param T Types of event to subscribe to. * @param scope Coroutine scope to run the body of your callback under. * @param consumer The callback to run when the event is fired. */ - public inline fun on( + public inline fun on( launch: Boolean = true, scope: CoroutineScope = this.getKoin().get(), noinline consumer: suspend T.() -> Unit @@ -239,7 +214,7 @@ public open class ExtensibleBot(public val settings: ExtensibleBotBuilder, priva .filterIsInstance() .onEach { runCatching { - if (launch) scope.launch { consumer(it) } else consumer(it) + if (launch) it.launch { consumer(it) } else consumer(it) }.onFailure { logger.catching(it) } }.catch { logger.catching(it) } .launchIn(scope) @@ -336,55 +311,32 @@ public open class ExtensibleBot(public val settings: ExtensibleBotBuilder, priva } /** - * Directly register a [MessageCommand] to this bot. + * Directly register an [EventHandler] to this bot. * * Generally speaking, you shouldn't call this directly - instead, create an [Extension] and - * call the [Extension.command] function in your [Extension.setup] function. - * - * This function will throw a [CommandRegistrationException] if the command has already been registered, if - * a command with the same name exists, or if a command with one of the same aliases exists. - * - * @param command The command to be registered. - * @throws CommandRegistrationException Thrown if the command could not be registered. - */ - @Deprecated( - "Use the equivalent function within `MessageCommandRegistry` instead.", - - ReplaceWith( - "getKoin().get().add(command)", - - "org.koin.core.component.KoinComponent.getKoin", - "com.kotlindiscord.kord.extensions.commands.MessageCommand" - ), - level = DeprecationLevel.ERROR - ) - @Throws(CommandRegistrationException::class) - public open fun addCommand(command: MessageCommand): Unit = getKoin() - .get() - .add(command) - - /** - * Directly remove a registered [MessageCommand] from this bot. + * call the [Extension.event] function in your [Extension.setup] function. * - * This function is used when extensions are unloaded, in order to clear out their commands. - * No exception is thrown if the command wasn't registered. + * This function will throw an [EventHandlerRegistrationException] if the event handler has already been registered. * - * @param command The command to be removed. + * @param handler The event handler to be registered. + * @throws EventHandlerRegistrationException Thrown if the event handler could not be registered. */ - @Deprecated( - "Use the equivalent function within `MessageCommandRegistry` instead.", + @Throws(EventHandlerRegistrationException::class) + public inline fun addEventHandler(handler: EventHandler) { + if (eventHandlers.contains(handler)) { + throw EventHandlerRegistrationException( + "Event handler already registered in '${handler.extension.name}' extension." + ) + } - ReplaceWith( - "getKoin().get().remove(command)", + if (initialized) { + handler.listenerRegistrationCallable?.invoke() ?: error( + "Event handler $handler does not have a listener registration callback. This should never happen!" + ) + } - "org.koin.core.component.KoinComponent.getKoin", - "com.kotlindiscord.kord.extensions.commands.MessageCommand" - ), - level = DeprecationLevel.ERROR - ) - public open fun removeCommand(command: MessageCommand): Boolean = getKoin() - .get() - .remove(command) + eventHandlers.add(handler) + } /** * Directly register an [EventHandler] to this bot. @@ -398,18 +350,10 @@ public open class ExtensibleBot(public val settings: ExtensibleBotBuilder, priva * @throws EventHandlerRegistrationException Thrown if the event handler could not be registered. */ @Throws(EventHandlerRegistrationException::class) - public inline fun addEventHandler(handler: EventHandler): Job { - if (eventHandlers.contains(handler)) { - throw EventHandlerRegistrationException( - "Event handler already registered in '${handler.extension.name}' extension." - ) + public inline fun registerListenerForHandler(handler: EventHandler): Job { + return on { + handler.call(this) } - - val job = on { handler.call(this) } - - eventHandlers.add(handler) - - return job } /** diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/builders/ExtensibleBotBuilder.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/builders/ExtensibleBotBuilder.kt index 661862b1df..e528b581ec 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/builders/ExtensibleBotBuilder.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/builders/ExtensibleBotBuilder.kt @@ -6,13 +6,21 @@ import com.kotlindiscord.kord.extensions.DISCORD_BLURPLE import com.kotlindiscord.kord.extensions.ExtensibleBot import com.kotlindiscord.kord.extensions.annotations.BotBuilderDSL import com.kotlindiscord.kord.extensions.checks.types.Check -import com.kotlindiscord.kord.extensions.commands.MessageCommandRegistry -import com.kotlindiscord.kord.extensions.commands.slash.SlashCommandRegistry +import com.kotlindiscord.kord.extensions.checks.types.MessageCommandCheck +import com.kotlindiscord.kord.extensions.checks.types.SlashCommandCheck +import com.kotlindiscord.kord.extensions.checks.types.UserCommandCheck +import com.kotlindiscord.kord.extensions.commands.application.ApplicationCommandRegistry +import com.kotlindiscord.kord.extensions.commands.application.DefaultApplicationCommandRegistry +import com.kotlindiscord.kord.extensions.commands.chat.ChatCommandRegistry +import com.kotlindiscord.kord.extensions.components.ComponentRegistry +import com.kotlindiscord.kord.extensions.components.callbacks.ComponentCallbackRegistry import com.kotlindiscord.kord.extensions.extensions.Extension import com.kotlindiscord.kord.extensions.i18n.ResourceBundleTranslations import com.kotlindiscord.kord.extensions.i18n.SupportedLocales import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider import com.kotlindiscord.kord.extensions.sentry.SentryAdapter +import com.kotlindiscord.kord.extensions.types.FailureReason +import com.kotlindiscord.kord.extensions.utils.getKoin import com.kotlindiscord.kord.extensions.utils.loadModule import dev.kord.cache.api.DataCache import dev.kord.common.Color @@ -25,14 +33,14 @@ import dev.kord.core.behavior.GuildBehavior import dev.kord.core.behavior.UserBehavior import dev.kord.core.behavior.channel.ChannelBehavior import dev.kord.core.builder.kord.KordBuilder -import dev.kord.core.builder.kord.Shards import dev.kord.core.cache.KordCacheBuilder -import dev.kord.core.event.interaction.InteractionCreateEvent import dev.kord.core.event.message.MessageCreateEvent import dev.kord.core.supplier.EntitySupplier import dev.kord.core.supplier.EntitySupplyStrategy import dev.kord.gateway.Intents import dev.kord.gateway.builder.PresenceBuilder +import dev.kord.gateway.builder.Shards +import dev.kord.rest.builder.message.create.MessageCreateBuilder import mu.KLogger import mu.KotlinLogging import org.koin.core.context.startKoin @@ -49,6 +57,9 @@ internal typealias LocaleResolver = suspend ( user: UserBehavior? ) -> Locale? +internal typealias FailureResponseBuilder = + suspend (MessageCreateBuilder).(message: String, type: FailureReason<*>) -> Unit + /** * Builder class used for configuring and creating an [ExtensibleBot]. * @@ -60,6 +71,14 @@ public open class ExtensibleBotBuilder { /** @suppress Builder that shouldn't be set directly by the user. **/ public val cacheBuilder: CacheBuilder = CacheBuilder() + /** @suppress Builder that shouldn't be set directly by the user. **/ + public val componentsBuilder: ComponentsBuilder = ComponentsBuilder() + + /** + * @suppress Builder that shouldn't be set directly by the user. + */ + public var failureResponseBuilder: FailureResponseBuilder = { message, _ -> content = message } + /** @suppress Builder that shouldn't be set directly by the user. **/ public open val extensionsBuilder: ExtensionsBuilder = ExtensionsBuilder() @@ -70,13 +89,21 @@ public open class ExtensibleBotBuilder { public val i18nBuilder: I18nBuilder = I18nBuilder() /** @suppress Builder that shouldn't be set directly by the user. **/ - public var intentsBuilder: (Intents.IntentsBuilder.() -> Unit)? = null + public var intentsBuilder: (Intents.IntentsBuilder.() -> Unit)? = { + +Intents.nonPrivileged + + getKoin().get().extensions.values.forEach { extension -> + extension.intents.forEach { + +it + } + } + } /** @suppress Builder that shouldn't be set directly by the user. **/ public val membersBuilder: MembersBuilder = MembersBuilder() /** @suppress Builder that shouldn't be set directly by the user. **/ - public val messageCommandsBuilder: MessageCommandsBuilder = MessageCommandsBuilder() + public val chatCommandsBuilder: ChatCommandsBuilder = ChatCommandsBuilder() /** @suppress Builder that shouldn't be set directly by the user. **/ public var presenceBuilder: PresenceBuilder.() -> Unit = { status = PresenceStatus.Online } @@ -85,10 +112,15 @@ public open class ExtensibleBotBuilder { public var shardingBuilder: ((recommended: Int) -> Shards)? = null /** @suppress Builder that shouldn't be set directly by the user. **/ - public val slashCommandsBuilder: SlashCommandsBuilder = SlashCommandsBuilder() + public val applicationCommandsBuilder: ApplicationCommandsBuilder = ApplicationCommandsBuilder() /** @suppress List of Kord builders, shouldn't be set directly by the user. **/ - public val kordBuilders: MutableList Unit> = mutableListOf() + public val kordHooks: MutableList Unit> = mutableListOf() + + /** @suppress Kord builder, creates a Kord instance. **/ + public var kordBuilder: suspend (String, suspend KordBuilder.() -> Unit) -> Kord = { token, builder -> + Kord(token) { builder() } + } /** Logging level Koin should use, defaulting to ERROR. **/ public var koinLogLevel: Level = Level.ERROR @@ -103,6 +135,25 @@ public open class ExtensibleBotBuilder { builder(cacheBuilder) } + /** + * DSL function used to configure the bot's components system. + * + * @see ComponentsBuilder + */ + @BotBuilderDSL + public suspend fun components(builder: suspend ComponentsBuilder.() -> Unit) { + builder(componentsBuilder) + } + + /** + * Register the message builder responsible for formatting error responses, which are sent to users during command + * and component body execution. + */ + @BotBuilderDSL + public fun errorResponse(builder: FailureResponseBuilder) { + failureResponseBuilder = builder + } + /** * DSL function used to insert code at various points in the bot's lifecycle. * @@ -114,8 +165,8 @@ public open class ExtensibleBotBuilder { } /** - * DSL function allowing for additional Kord builders to be specified, allowing for direct customisation of the - * Kord object. + * DSL function allowing for additional Kord configuration builders to be specified, allowing for direct + * customisation of the Kord object. * * Multiple builders may be registered, and they'll be called in the order they were registered here. Builders are * called after Kord Extensions has applied its own builder actions - so you can override the changes it makes here @@ -125,27 +176,39 @@ public open class ExtensibleBotBuilder { */ @BotBuilderDSL public fun kord(builder: suspend KordBuilder.() -> Unit) { - kordBuilders.add(builder) + kordHooks.add(builder) } /** - * DSL function used to configure the bot's message command options. + * Function allowing you to specify a callable that constructs and returns a Kord instance. This can be used + * to specify your own Kord subclass, if you need to - but shouldn't be a replacement for registering a [kord] + * configuration builder. * - * @see MessageCommandsBuilder + * @see Kord */ @BotBuilderDSL - public suspend fun messageCommands(builder: suspend MessageCommandsBuilder.() -> Unit) { - builder(messageCommandsBuilder) + public fun customKordBuilder(builder: suspend (String, suspend KordBuilder.() -> Unit) -> Kord) { + kordBuilder = builder } /** - * DSL function used to configure the bot's slash command options. + * DSL function used to configure the bot's chat command options. * - * @see SlashCommandsBuilder + * @see ChatCommandsBuilder */ @BotBuilderDSL - public suspend fun slashCommands(builder: suspend SlashCommandsBuilder.() -> Unit) { - builder(slashCommandsBuilder) + public suspend fun chatCommands(builder: suspend ChatCommandsBuilder.() -> Unit) { + builder(chatCommandsBuilder) + } + + /** + * DSL function used to configure the bot's application command options. + * + * @see ApplicationCommandsBuilder + */ + @BotBuilderDSL + public suspend fun applicationCommands(builder: suspend ApplicationCommandsBuilder.() -> Unit) { + builder(applicationCommandsBuilder) } /** @@ -161,11 +224,33 @@ public open class ExtensibleBotBuilder { /** * DSL function used to configure the bot's intents. * + * @param addDefaultIntents Whether to automatically add all non-privileged intents to the builder before running + * the given lambda. + * @param addDefaultIntents Whether to automatically add the required intents defined within each loaded extension + * * @see Intents.IntentsBuilder */ @BotBuilderDSL - public fun intents(builder: Intents.IntentsBuilder.() -> Unit) { - this.intentsBuilder = builder + public fun intents( + addDefaultIntents: Boolean = true, + addExtensionIntents: Boolean = true, + builder: Intents.IntentsBuilder.() -> Unit + ) { + this.intentsBuilder = { + if (addDefaultIntents) { + +Intents.nonPrivileged + } + + if (addExtensionIntents) { + getKoin().get().extensions.values.forEach { extension -> + extension.intents.forEach { + +it + } + } + } + + builder() + } } /** @@ -225,8 +310,15 @@ public open class ExtensibleBotBuilder { loadModule { single { this@ExtensibleBotBuilder } bind ExtensibleBotBuilder::class } loadModule { single { i18nBuilder.translationsProvider } bind TranslationsProvider::class } - loadModule { single { messageCommandsBuilder.registryBuilder() } bind MessageCommandRegistry::class } - loadModule { single { slashCommandsBuilder.slashRegistryBuilder() } bind SlashCommandRegistry::class } + loadModule { single { chatCommandsBuilder.registryBuilder() } bind ChatCommandRegistry::class } + loadModule { single { componentsBuilder.registryBuilder() } bind ComponentRegistry::class } + loadModule { single { componentsBuilder.callbackRegistryBuilder() } bind ComponentCallbackRegistry::class } + + loadModule { + single { + applicationCommandsBuilder.applicationCommandRegistryBuilder() + } bind ApplicationCommandRegistry::class + } loadModule { single { @@ -252,11 +344,14 @@ public open class ExtensibleBotBuilder { loadModule { single { bot } bind ExtensibleBot::class } hooksBuilder.runCreated(bot) + bot.setup() - hooksBuilder.runSetup(bot) + hooksBuilder.runSetup(bot) hooksBuilder.runBeforeExtensionsAdded(bot) + extensionsBuilder.extensions.forEach { bot.addExtension(it) } + hooksBuilder.runAfterExtensionsAdded(bot) return bot @@ -269,7 +364,7 @@ public open class ExtensibleBotBuilder { * Number of messages to keep in the cache. Defaults to 10,000. * * To disable automatic configuration of the message cache, set this to `null` or `0`. You can configure the - * cache yourself using the [kord] function, and interact with the resulting [DataCache] object using the + * cache yourself using the [kordHook] function, and interact with the resulting [DataCache] object using the * [transformCache] function. */ @Suppress("MagicNumber") @@ -306,6 +401,32 @@ public open class ExtensibleBotBuilder { } } + /** Builder used to configure the bot's components settings. **/ + @BotBuilderDSL + public class ComponentsBuilder { + /** @suppress Component callback registry builder. **/ + public var callbackRegistryBuilder: () -> ComponentCallbackRegistry = ::ComponentCallbackRegistry + + /** @suppress Component registry builder. **/ + public var registryBuilder: () -> ComponentRegistry = ::ComponentRegistry + + /** + * Register a builder (usually a constructor) returning a [ComponentCallbackRegistry] instance, which may + * be useful if you need to register a custom subclass. + */ + public fun callbackRegistry(builder: () -> ComponentCallbackRegistry) { + callbackRegistryBuilder = builder + } + + /** + * Register a builder (usually a constructor) returning a [ComponentRegistry] instance, which may be useful + * if you need to register a custom subclass. + */ + public fun registry(builder: () -> ComponentRegistry) { + registryBuilder = builder + } + } + /** Builder used for configuring the bot's extension options, and registering custom extensions. **/ @BotBuilderDSL public open class ExtensionsBuilder { @@ -318,14 +439,6 @@ public open class ExtensibleBotBuilder { /** @suppress Sentry extension builder. **/ public open val sentryExtensionBuilder: SentryExtensionBuilder = SentryExtensionBuilder() - /** Whether to enable the bundled Sentry extension. Defaults to `true`. **/ - @Deprecated("Use the sentry { } builder instead.", level = DeprecationLevel.ERROR) - public var sentry: Boolean - get() = sentryExtensionBuilder.debug - set(value) { - sentryExtensionBuilder.debug = value - } - /** Add a custom extension to the bot via a builder - probably the extension constructor. **/ public open fun add(builder: () -> Extension) { extensions.add(builder) @@ -517,6 +630,7 @@ public open class ExtensibleBotBuilder { * Register a lambda to be called after all the extensions in the [ExtensionsBuilder] have been added. This * will be called regardless of how many were successfully set up. */ + @BotBuilderDSL public fun afterExtensionsAdded(body: suspend ExtensibleBot.() -> Unit): Boolean = afterExtensionsAddedList.add(body) @@ -524,6 +638,7 @@ public open class ExtensibleBotBuilder { * Register a lambda to be called after Koin has been set up. You can use this to register overriding modules * via `loadModule` before the modules are actually accessed. */ + @BotBuilderDSL public fun afterKoinSetup(body: () -> Unit): Boolean = afterKoinSetupList.add(body) @@ -531,18 +646,21 @@ public open class ExtensibleBotBuilder { * Register a lambda to be called before Koin has been set up. You can use this to register Koin modules * early, if needed. */ + @BotBuilderDSL public fun beforeKoinSetup(body: () -> Unit): Boolean = beforeKoinSetupList.add(body) /** * Register a lambda to be called before all the extensions in the [ExtensionsBuilder] have been added. */ + @BotBuilderDSL public fun beforeExtensionsAdded(body: suspend ExtensibleBot.() -> Unit): Boolean = beforeExtensionsAddedList.add(body) /** * Register a lambda to be called just before the bot tries to connect to Discord. */ + @BotBuilderDSL public fun beforeStart(body: suspend ExtensibleBot.() -> Unit): Boolean = beforeStartList.add(body) @@ -550,18 +668,21 @@ public open class ExtensibleBotBuilder { * Register a lambda to be called right after the [ExtensibleBot] object has been created, before it gets set * up. */ + @BotBuilderDSL public fun created(body: suspend ExtensibleBot.() -> Unit): Boolean = createdList.add(body) /** * Register a lambda to be called after any extension is successfully added to the bot. */ + @BotBuilderDSL public fun extensionAdded(body: suspend ExtensibleBot.(extension: Extension) -> Unit): Boolean = extensionAddedList.add(body) /** * Register a lambda to be called after the [ExtensibleBot] object has been created and set up. */ + @BotBuilderDSL public fun setup(body: suspend ExtensibleBot.() -> Unit): Boolean = setupList.add(body) @@ -730,7 +851,7 @@ public open class ExtensibleBotBuilder { * Requires the `GUILD_MEMBERS` privileged intent. Make sure you've enabled it for your bot! */ @JvmName("fillLongs") // These are the same for the JVM - public fun fill(ids: Collection): Boolean? = + public fun fill(ids: Collection): Boolean? = guildsToFill?.addAll(ids.map { Snowflake(it) }) /** @@ -755,7 +876,7 @@ public open class ExtensibleBotBuilder { * * Requires the `GUILD_MEMBERS` privileged intent. Make sure you've enabled it for your bot! */ - public fun fill(id: Long): Boolean? = + public fun fill(id: ULong): Boolean? = guildsToFill?.add(Snowflake(id)) /** @@ -783,26 +904,23 @@ public open class ExtensibleBotBuilder { } } - /** Builder used for configuring the bot's message command options. **/ + /** Builder used for configuring the bot's chat command options. **/ @BotBuilderDSL - public class MessageCommandsBuilder { - /** Whether to invoke commands on bot mentions, in addition to using message prefixes. Defaults to `true`. **/ + public class ChatCommandsBuilder { + /** Whether to invoke commands on bot mentions, in addition to using chat prefixes. Defaults to `true`. **/ public var invokeOnMention: Boolean = true /** Prefix to require for command invocations on Discord. Defaults to `"!"`. **/ public var defaultPrefix: String = "!" - /** Whether to register and process message commands. Defaults to `true`. **/ - public var enabled: Boolean = true - - /** Number of threads to use for command execution. Defaults to twice the number of CPU threads. **/ - public var threads: Int = Runtime.getRuntime().availableProcessors() * 2 + /** Whether to register and process chat commands. Defaults to `false`. **/ + public var enabled: Boolean = false /** @suppress Builder that shouldn't be set directly by the user. **/ public var prefixCallback: suspend (MessageCreateEvent).(String) -> String = { defaultPrefix } /** @suppress Builder that shouldn't be set directly by the user. **/ - public var registryBuilder: () -> MessageCommandRegistry = { MessageCommandRegistry() } + public var registryBuilder: () -> ChatCommandRegistry = { ChatCommandRegistry() } /** * List of command checks. @@ -815,7 +933,7 @@ public open class ExtensibleBotBuilder { * Register a lambda that takes a [MessageCreateEvent] object and the default prefix, and returns the * command prefix to be made use of for that message event. * - * This is intended to allow for different message command prefixes in different contexts - for example, + * This is intended to allow for different chat command prefixes in different contexts - for example, * guild-specific prefixes. */ public fun prefix(builder: suspend (MessageCreateEvent).(String) -> String) { @@ -823,10 +941,10 @@ public open class ExtensibleBotBuilder { } /** - * Register the builder used to create the [MessageCommandRegistry]. You can change this if you need to make - * use of a subclass. + * Register the builder used to create the [ChatCommandRegistry]. You can change this if you need to + * make use of a subclass. */ - public fun registry(builder: () -> MessageCommandRegistry) { + public fun registry(builder: () -> ChatCommandRegistry) { registryBuilder = builder } @@ -855,39 +973,54 @@ public open class ExtensibleBotBuilder { } } - /** Builder used for configuring the bot's slash command options. **/ + /** Builder used for configuring the bot's application command options. **/ @BotBuilderDSL - public class SlashCommandsBuilder { - /** Whether to register and process slash commands. Defaults to `false`. **/ - public var enabled: Boolean = false + public class ApplicationCommandsBuilder { + /** Whether to register and process application commands. Defaults to `true`. **/ + public var enabled: Boolean = true - /** The guild ID to use for all global slash commands. Intended for testing. **/ + /** The guild ID to use for all global application commands. Intended for testing. **/ public var defaultGuild: Snowflake? = null - /** Whether to attempt to register the bot's slash commands. Intended for multi-instance sharded bots. **/ + /** Whether to attempt to register the bot's application commands. Intended for multi-instance sharded bots. **/ public var register: Boolean = true /** @suppress Builder that shouldn't be set directly by the user. **/ - public var slashRegistryBuilder: () -> SlashCommandRegistry = { SlashCommandRegistry() } + public var applicationCommandRegistryBuilder: () -> ApplicationCommandRegistry = + { DefaultApplicationCommandRegistry() } + + /** + * List of message command checks. + * + * These checks will be checked against all message commands. + */ + public val messageCommandChecks: MutableList = mutableListOf() /** * List of slash command checks. * * These checks will be checked against all slash commands. */ - public val checkList: MutableList> = mutableListOf() + public val slashCommandChecks: MutableList = mutableListOf() + + /** + * List of user command checks. + * + * These checks will be checked against all user commands. + */ + public val userCommandChecks: MutableList = mutableListOf() - /** Set a guild ID to use for all global slash commands. Intended for testing. **/ + /** Set a guild ID to use for all global application commands. Intended for testing. **/ public fun defaultGuild(id: Snowflake) { defaultGuild = id } - /** Set a guild ID to use for all global slash commands. Intended for testing. **/ - public fun defaultGuild(id: Long) { + /** Set a guild ID to use for all global application commands. Intended for testing. **/ + public fun defaultGuild(id: ULong) { defaultGuild = Snowflake(id) } - /** Set a guild ID to use for all global slash commands. Intended for testing. **/ + /** Set a guild ID to use for all global application commands. Intended for testing. **/ public fun defaultGuild(id: String) { defaultGuild = Snowflake(id) } @@ -896,15 +1029,40 @@ public open class ExtensibleBotBuilder { * Register the builder used to create the [SlashCommandRegistry]. You can change this if you need to make * use of a subclass. */ - public fun slashRegistry(builder: () -> SlashCommandRegistry) { - slashRegistryBuilder = builder + public fun applicationCommandRegistry(builder: () -> ApplicationCommandRegistry) { + applicationCommandRegistryBuilder = builder + } + + /** + * Define a check which must pass for a message command to be executed. This check will be applied to all + * message commands. + * + * A message command may have multiple checks - all checks must pass for the command to be executed. + * Checks will be run in the order that they're defined. + * + * This function can be used DSL-style with a given body, or it can be passed one or more + * predefined functions. See the samples for more information. + * + * @param checks Checks to apply to all slash commands. + */ + public fun messageCommandCheck(vararg checks: MessageCommandCheck) { + checks.forEach { messageCommandChecks.add(it) } } /** - * Define a check which must pass for a command to be executed. This check will be applied to all + * Overloaded message command check function to allow for DSL syntax. + * + * @param check Check to apply to all slash commands. + */ + public fun messageCommandCheck(check: MessageCommandCheck) { + messageCommandChecks.add(check) + } + + /** + * Define a check which must pass for a slash command to be executed. This check will be applied to all * slash commands. * - * A command may have multiple checks - all checks must pass for the command to be executed. + * A slash command may have multiple checks - all checks must pass for the command to be executed. * Checks will be run in the order that they're defined. * * This function can be used DSL-style with a given body, or it can be passed one or more @@ -912,17 +1070,42 @@ public open class ExtensibleBotBuilder { * * @param checks Checks to apply to all slash commands. */ - public fun check(vararg checks: Check) { - checks.forEach { checkList.add(it) } + public fun slashCommandCheck(vararg checks: SlashCommandCheck) { + checks.forEach { slashCommandChecks.add(it) } } /** - * Overloaded check function to allow for DSL syntax. + * Overloaded slash command check function to allow for DSL syntax. * * @param check Check to apply to all slash commands. */ - public fun check(check: Check) { - checkList.add(check) + public fun slashCommandCheck(check: SlashCommandCheck) { + slashCommandChecks.add(check) + } + + /** + * Define a check which must pass for a user command to be executed. This check will be applied to all + * user commands. + * + * A user command may have multiple checks - all checks must pass for the command to be executed. + * Checks will be run in the order that they're defined. + * + * This function can be used DSL-style with a given body, or it can be passed one or more + * predefined functions. See the samples for more information. + * + * @param checks Checks to apply to all slash commands. + */ + public fun userCommandCheck(vararg checks: UserCommandCheck) { + checks.forEach { userCommandChecks.add(it) } + } + + /** + * Overloaded user command check function to allow for DSL syntax. + * + * @param check Check to apply to all slash commands. + */ + public fun userCommandCheck(check: UserCommandCheck) { + userCommandChecks.add(check) } } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/ChannelChecks.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/ChannelChecks.kt index c6c4cf7cca..19791fbacd 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/ChannelChecks.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/ChannelChecks.kt @@ -2,7 +2,7 @@ package com.kotlindiscord.kord.extensions.checks -import com.kotlindiscord.kord.extensions.checks.types.Check +import com.kotlindiscord.kord.extensions.checks.types.CheckContext import dev.kord.common.entity.Snowflake import dev.kord.core.behavior.channel.CategoryBehavior import dev.kord.core.behavior.channel.ChannelBehavior @@ -21,7 +21,11 @@ import mu.KotlinLogging * * @param builder Lambda returning the channel to compare to. */ -public fun inChannel(builder: suspend () -> ChannelBehavior): Check<*> = { +public suspend fun CheckContext.inChannel(builder: suspend (T) -> ChannelBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.inChannel") val eventChannel = channelFor(event) @@ -30,7 +34,7 @@ public fun inChannel(builder: suspend () -> ChannelBehavior): Check<*> = { fail() } else { - val channel = builder() + val channel = builder(event) if (eventChannel.id == channel.id) { logger.passed() @@ -57,7 +61,11 @@ public fun inChannel(builder: suspend () -> ChannelBehavior): Check<*> = { * * @param builder Lambda returning the channel to compare to. */ -public fun notInChannel(builder: suspend () -> ChannelBehavior): Check<*> = { +public suspend fun CheckContext.notInChannel(builder: suspend (T) -> ChannelBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.notInChannel") val eventChannel = channelFor(event) @@ -66,7 +74,7 @@ public fun notInChannel(builder: suspend () -> ChannelBehavior): Check<*> = { pass() } else { - val channel = builder() + val channel = builder(event) if (eventChannel.id != channel.id) { logger.passed() @@ -93,7 +101,11 @@ public fun notInChannel(builder: suspend () -> ChannelBehavior): Check<*> = { * * @param builder Lambda returning the category to compare to. */ -public fun inCategory(builder: suspend () -> CategoryBehavior): Check<*> = { +public suspend fun CheckContext.inCategory(builder: suspend (T) -> CategoryBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.inCategory") val eventChannel = topChannelFor(event) @@ -102,7 +114,7 @@ public fun inCategory(builder: suspend () -> CategoryBehavior): Check<*> = { fail() } else { - val category = builder() + val category = builder(event) val channels = category.channels.toList().map { it.id } if (channels.contains(eventChannel.id)) { @@ -130,7 +142,11 @@ public fun inCategory(builder: suspend () -> CategoryBehavior): Check<*> = { * * @param builder Lambda returning the category to compare to. */ -public fun notInCategory(builder: suspend () -> CategoryBehavior): Check<*> = { +public suspend fun CheckContext.notInCategory(builder: suspend (T) -> CategoryBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.notInCategory") val eventChannel = topChannelFor(event) @@ -139,7 +155,7 @@ public fun notInCategory(builder: suspend () -> CategoryBehavior): Check<*> = { pass() } else { - val category = builder() + val category = builder(event) val channels = category.channels.toList().map { it.id } if (channels.contains(eventChannel.id)) { @@ -167,7 +183,11 @@ public fun notInCategory(builder: suspend () -> CategoryBehavior): Check<*> = { * * @param builder Lambda returning the channel to compare to. */ -public fun channelHigher(builder: suspend () -> ChannelBehavior): Check<*> = { +public suspend fun CheckContext.channelHigher(builder: suspend (T) -> ChannelBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.channelHigher") val eventChannel = channelFor(event) @@ -176,7 +196,7 @@ public fun channelHigher(builder: suspend () -> ChannelBehavior): Check<*> = { fail() } else { - val channel = builder() + val channel = builder(event) if (eventChannel > channel) { logger.passed() @@ -203,7 +223,11 @@ public fun channelHigher(builder: suspend () -> ChannelBehavior): Check<*> = { * * @param builder Lambda returning the channel to compare to. */ -public fun channelLower(builder: suspend () -> ChannelBehavior): Check<*> = { +public suspend fun CheckContext.channelLower(builder: suspend (T) -> ChannelBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.channelLower") val eventChannel = channelFor(event) @@ -212,7 +236,7 @@ public fun channelLower(builder: suspend () -> ChannelBehavior): Check<*> = { fail() } else { - val channel = builder() + val channel = builder(event) if (eventChannel < channel) { logger.passed() @@ -239,7 +263,11 @@ public fun channelLower(builder: suspend () -> ChannelBehavior): Check<*> = { * * @param builder Lambda returning the channel to compare to. */ -public fun channelHigherOrEqual(builder: suspend () -> ChannelBehavior): Check<*> = { +public suspend fun CheckContext.channelHigherOrEqual(builder: suspend (T) -> ChannelBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.channelHigherOrEqual") val eventChannel = channelFor(event) @@ -248,7 +276,7 @@ public fun channelHigherOrEqual(builder: suspend () -> ChannelBehavior): Check<* fail() } else { - val channel = builder() + val channel = builder(event) if (eventChannel >= channel) { logger.passed() @@ -275,7 +303,11 @@ public fun channelHigherOrEqual(builder: suspend () -> ChannelBehavior): Check<* * * @param builder Lambda returning the channel to compare to. */ -public fun channelLowerOrEqual(builder: suspend () -> ChannelBehavior): Check<*> = { +public suspend fun CheckContext.channelLowerOrEqual(builder: suspend (T) -> ChannelBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.channelLowerOrEqual") val eventChannel = channelFor(event) @@ -284,7 +316,7 @@ public fun channelLowerOrEqual(builder: suspend () -> ChannelBehavior): Check<*> fail() } else { - val channel = builder() + val channel = builder(event) if (eventChannel <= channel) { logger.passed() @@ -316,7 +348,11 @@ public fun channelLowerOrEqual(builder: suspend () -> ChannelBehavior): Check<*> * * @param id Channel snowflake to compare to. */ -public fun inChannel(id: Snowflake): Check<*> = { +public suspend fun CheckContext.inChannel(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.inChannel") val channel = event.kord.getChannel(id) @@ -325,7 +361,7 @@ public fun inChannel(id: Snowflake): Check<*> = { fail() } else { - inChannel { channel }() + inChannel { channel } } } @@ -337,7 +373,11 @@ public fun inChannel(id: Snowflake): Check<*> = { * * @param id Channel snowflake to compare to. */ -public fun notInChannel(id: Snowflake): Check<*> = { +public suspend fun CheckContext.notInChannel(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.notInChannel") val channel = event.kord.getChannel(id) @@ -346,7 +386,7 @@ public fun notInChannel(id: Snowflake): Check<*> = { pass() } else { - notInChannel { channel }() + notInChannel { channel } } } @@ -358,7 +398,11 @@ public fun notInChannel(id: Snowflake): Check<*> = { * * @param id Category snowflake to compare to. */ -public fun inCategory(id: Snowflake): Check<*> = { +public suspend fun CheckContext.inCategory(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.inCategory") val category = event.kord.getChannelOf(id) @@ -367,7 +411,7 @@ public fun inCategory(id: Snowflake): Check<*> = { fail() } else { - inCategory { category }() + inCategory { category } } } @@ -379,7 +423,11 @@ public fun inCategory(id: Snowflake): Check<*> = { * * @param id Category snowflake to compare to. */ -public fun notInCategory(id: Snowflake): Check<*> = { +public suspend fun CheckContext.notInCategory(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.notInCategory") val category = event.kord.getChannelOf(id) @@ -388,7 +436,7 @@ public fun notInCategory(id: Snowflake): Check<*> = { pass() } else { - notInCategory { category }() + notInCategory { category } } } @@ -400,7 +448,11 @@ public fun notInCategory(id: Snowflake): Check<*> = { * * @param id Channel snowflake to compare to. */ -public fun channelHigher(id: Snowflake): Check<*> = { +public suspend fun CheckContext.channelHigher(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.channelHigher") val channel = event.kord.getChannel(id) @@ -409,7 +461,7 @@ public fun channelHigher(id: Snowflake): Check<*> = { fail() } else { - channelHigher { channel }() + channelHigher { channel } } } @@ -421,7 +473,11 @@ public fun channelHigher(id: Snowflake): Check<*> = { * * @param id Channel snowflake to compare to. */ -public fun channelLower(id: Snowflake): Check<*> = { +public suspend fun CheckContext.channelLower(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.channelLower") val channel = event.kord.getChannel(id) @@ -430,7 +486,7 @@ public fun channelLower(id: Snowflake): Check<*> = { fail() } else { - channelLower { channel }() + channelLower { channel } } } @@ -442,7 +498,11 @@ public fun channelLower(id: Snowflake): Check<*> = { * * @param id Channel snowflake to compare to. */ -public fun channelHigherOrEqual(id: Snowflake): Check<*> = { +public suspend fun CheckContext.channelHigherOrEqual(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.channelHigherOrEqual") val channel = event.kord.getChannel(id) @@ -451,7 +511,7 @@ public fun channelHigherOrEqual(id: Snowflake): Check<*> = { fail() } else { - channelHigherOrEqual { channel }() + channelHigherOrEqual { channel } } } @@ -463,7 +523,11 @@ public fun channelHigherOrEqual(id: Snowflake): Check<*> = { * * @param id Channel snowflake to compare to. */ -public fun channelLowerOrEqual(id: Snowflake): Check<*> = { +public suspend fun CheckContext.channelLowerOrEqual(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.channelLowerOrEqual") val channel = event.kord.getChannel(id) @@ -472,7 +536,7 @@ public fun channelLowerOrEqual(id: Snowflake): Check<*> = { fail() } else { - channelLowerOrEqual { channel }() + channelLowerOrEqual { channel } } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/ChannelTypeChecks.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/ChannelTypeChecks.kt index a6cc233a07..20134f7c2d 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/ChannelTypeChecks.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/ChannelTypeChecks.kt @@ -2,18 +2,11 @@ package com.kotlindiscord.kord.extensions.checks -import com.kotlindiscord.kord.extensions.builders.ExtensibleBotBuilder -import com.kotlindiscord.kord.extensions.checks.types.Check -import com.kotlindiscord.kord.extensions.utils.getKoin +import com.kotlindiscord.kord.extensions.checks.types.CheckContext import com.kotlindiscord.kord.extensions.utils.translate import dev.kord.common.entity.ChannelType import dev.kord.core.event.Event import mu.KotlinLogging -import java.util.* - -private val defaultLocale: Locale - get() = - getKoin().get().i18nBuilder.defaultLocale /** * Check asserting that the channel an [Event] fired in is of a given set of types. @@ -23,7 +16,11 @@ private val defaultLocale: Locale * * @param channelTypes The channel types to compare to. */ -public fun channelType(vararg channelTypes: ChannelType): Check<*> = { +public suspend fun CheckContext<*>.channelType(vararg channelTypes: ChannelType) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.channelType") val eventChannel = channelFor(event) @@ -59,7 +56,11 @@ public fun channelType(vararg channelTypes: ChannelType): Check<*> = { * * @param channelTypes The channel types to compare to. */ -public fun notChannelType(vararg channelTypes: ChannelType): Check<*> = { +public suspend fun CheckContext<*>.notChannelType(vararg channelTypes: ChannelType) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.notChannelType") val eventChannel = channelFor(event) diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/CheckCombinators.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/CheckCombinators.kt deleted file mode 100644 index 5d3d56150a..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/CheckCombinators.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.kotlindiscord.kord.extensions.checks - -import com.kotlindiscord.kord.extensions.checks.types.Check -import com.kotlindiscord.kord.extensions.checks.types.CheckContext -import mu.KotlinLogging - -/** - * Special check that passes if any of the given checks pass. - * - * You can think of this as an `or` operation - pass it a bunch of checks, and - * this one will return `true` if any of them pass. - * - * @param checks Two or more checks to combine. - * @return Whether any of the checks passed. - */ -public fun or(vararg checks: Check<*>): Check<*> = { - val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.or") - - val contexts = checks.map { - val context = CheckContext(event, locale) - - it(context) - context - } - - if (contexts.any { it.passed }) { - logger.passed() - - pass() - } else { - logger.failed("None of the given checks passed") - - val failedContext = contexts.firstOrNull { !it.passed } - - if (failedContext != null) { - fail(failedContext.message) - } else { - fail() - } - } -} - -/** Infix-function version of [or]. **/ -public infix fun (Check<*>).or(other: Check<*>): Check<*> = or(this, other) - -/** - * Special check that passes if all of the given checks pass. - * - * You can think of this as an `and` operation - pass it a bunch of checks, and - * this one will return `true` if they all pass. - * - * Don't use this unless you're already using combinators. The `check` functions - * can simply be passed multiple checks. - * - * @param checks Two or more checks to combine. - * @return Whether all of the checks passed. - */ -public fun and(vararg checks: Check<*>): Check<*> = { - val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.and") - - val contexts = checks.map { - val context = CheckContext(event, locale) - - it(context) - context - } - - if (contexts.all { it.passed }) { - logger.passed() - - pass() - } else { - logger.failed("At least one of the given checks failed") - - val failedContext = contexts.firstOrNull { !it.passed } - - if (failedContext != null) { - fail(failedContext.message) - } else { - fail() - } - } -} - -/** Infix-function version of [and]. **/ -public infix fun (Check<*>).and(other: Check<*>): Check<*> = and(this, other) diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/CheckUtils.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/CheckUtils.kt index 6cc7e9450a..ff028fccd1 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/CheckUtils.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/CheckUtils.kt @@ -1,15 +1,19 @@ -@file:OptIn(KordPreview::class) +@file:OptIn(KordPreview::class, KordUnsafe::class, KordExperimental::class) package com.kotlindiscord.kord.extensions.checks -import com.kotlindiscord.kord.extensions.checks.types.Check +import com.kotlindiscord.kord.extensions.checks.types.CheckContext +import com.kotlindiscord.kord.extensions.utils.authorId +import dev.kord.common.annotation.KordExperimental import dev.kord.common.annotation.KordPreview +import dev.kord.common.annotation.KordUnsafe import dev.kord.common.entity.Snowflake import dev.kord.core.behavior.* import dev.kord.core.behavior.channel.ChannelBehavior import dev.kord.core.behavior.channel.threads.ThreadChannelBehavior -import dev.kord.core.entity.channel.thread.ThreadChannel -import dev.kord.core.entity.interaction.GuildInteraction +import dev.kord.core.cache.data.toData +import dev.kord.core.entity.Member +import dev.kord.core.entity.interaction.GuildApplicationCommandInteraction import dev.kord.core.event.Event import dev.kord.core.event.channel.* import dev.kord.core.event.channel.thread.* @@ -78,9 +82,9 @@ public suspend fun channelFor(event: Event): ChannelBehavior? { * @return A [ChannelBehavior] representing the channel, or null if there isn't one. */ public suspend fun topChannelFor(event: Event): ChannelBehavior? { - val channel = channelFor(event) ?: return null + val channel = channelFor(event)?.asChannelOrNull() ?: return null - return if (channel is ThreadChannel) { + return if (channel is ThreadChannelBehavior) { channel.parent } else { channel @@ -98,7 +102,7 @@ public suspend fun topChannelFor(event: Event): ChannelBehavior? { * @param event The event concerning to the channel to retrieve. * @return A [Long] representing the channel ID, or null if there isn't one. */ -public suspend fun channelIdFor(event: Event): Long? { +public suspend fun channelIdFor(event: Event): ULong? { return when (event) { is ChannelCreateEvent -> event.channel.id.value is ChannelDeleteEvent -> event.channel.id.value @@ -202,7 +206,7 @@ public suspend fun guildFor(event: Event): GuildBehavior? { if (guildId == null) { null } else { - event.kord.getGuild(guildId) + event.kord.unsafe.guild(guildId) } } @@ -213,9 +217,29 @@ public suspend fun guildFor(event: Event): GuildBehavior? { is MemberLeaveEvent -> event.guild is MemberUpdateEvent -> event.guild is MessageBulkDeleteEvent -> event.guild - is MessageCreateEvent -> event.message.getGuildOrNull() + + is MessageCreateEvent -> { + val guildId = event.message.data.guildId.value + + if (guildId == null) { + guildId + } else { + event.kord.unsafe.guild(guildId) + } + } + is MessageDeleteEvent -> event.guild - is MessageUpdateEvent -> event.getMessage().getGuildOrNull() + + is MessageUpdateEvent -> { + val guildId = event.new.guildId.value + + if (guildId == null) { + guildId + } else { + event.kord.unsafe.guild(guildId) + } + } + is NewsChannelCreateEvent -> event.channel.guild is NewsChannelDeleteEvent -> event.channel.guild is NewsChannelUpdateEvent -> event.channel.guild @@ -258,25 +282,25 @@ public suspend fun guildFor(event: Event): GuildBehavior? { */ public suspend fun memberFor(event: Event): MemberBehavior? { return when { - event is InteractionCreateEvent -> (event.interaction as? GuildInteraction)?.member + event is InteractionCreateEvent -> (event.interaction as? GuildApplicationCommandInteraction)?.member event is MemberJoinEvent -> event.member event is MemberUpdateEvent -> event.member - - event is MessageCreateEvent && event.message.getGuildOrNull() != null -> - event.message.getAuthorAsMember() - - event is MessageDeleteEvent && event.message?.getGuildOrNull() != null -> - event.message?.getAuthorAsMember() - - event is MessageUpdateEvent && event.message.asMessageOrNull()?.getGuildOrNull() != null -> - event.getMessage().getAuthorAsMember() - - event is ReactionAddEvent && event.message.asMessageOrNull()?.getGuildOrNull() != null -> - event.getUserAsMember() - - event is ReactionRemoveEvent && event.message.asMessageOrNull()?.getGuildOrNull() != null -> - event.getUserAsMember() + event is MessageCreateEvent -> event.member + event is MessageDeleteEvent -> event.message?.data?.guildId?.value + ?.let { event.kord.unsafe.member(it, event.message!!.data.authorId) } + + event is MessageUpdateEvent -> { + val message = event.new + if (message.author.value != null && message.member.value != null) { + val userData = message.author.value!!.toData() + val memberData = message.member.value!!.toData(userData.id, event.new.guildId.value!!) + return Member(memberData, userData, event.kord) + } + return null + } + event is ReactionAddEvent -> event.userAsMember + event is ReactionRemoveEvent -> event.userAsMember event is TypingStartEvent -> if (event.guildId != null) { event.getGuild()!!.getMemberOrNull(event.userId) @@ -399,6 +423,7 @@ public suspend fun userFor(event: Event): UserBehavior? { is DMChannelCreateEvent -> event.channel.recipients.first { it.id != event.kord.selfId } is DMChannelDeleteEvent -> event.channel.recipients.first { it.id != event.kord.selfId } is DMChannelUpdateEvent -> event.channel.recipients.first { it.id != event.kord.selfId } + is InteractionCreateEvent -> event.interaction.user is MemberJoinEvent -> event.member is MemberLeaveEvent -> event.user @@ -423,9 +448,7 @@ public suspend fun userFor(event: Event): UserBehavior? { } } -/** Wrap an existing check, calling it but ensuring that no message is produced. **/ -public suspend fun Check<*>.silenced(): Check<*> = { - this@silenced() - +/** Silence the current check by removing any message it may have set. **/ +public fun CheckContext<*>.silence() { message = null } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/GuildChecks.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/GuildChecks.kt index 68075b758b..0880d44021 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/GuildChecks.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/GuildChecks.kt @@ -2,12 +2,11 @@ package com.kotlindiscord.kord.extensions.checks -import com.kotlindiscord.kord.extensions.checks.types.Check +import com.kotlindiscord.kord.extensions.checks.types.CheckContext import dev.kord.common.entity.Snowflake import dev.kord.core.behavior.GuildBehavior import dev.kord.core.event.Event import mu.KotlinLogging -import java.util.* /** * Check asserting an [Event] was fired within a guild. @@ -16,7 +15,11 @@ import java.util.* * that fired within a guild the bot doesn't have access to, or that it can't get the GuildBehavior for (for * example, due to a niche Kord configuration). */ -public val anyGuild: Check<*> = { +public suspend fun CheckContext<*>.anyGuild() { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.anyGuild") if (guildFor(event) != null) { @@ -39,7 +42,11 @@ public val anyGuild: Check<*> = { * that fired within a guild the bot doesn't have access to, or that it can't get the GuildBehavior for (for * example, due to a niche Kord configuration). */ -public val noGuild: Check<*> = { +public suspend fun CheckContext<*>.noGuild() { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.noGuild") if (guildFor(event) == null) { @@ -65,7 +72,11 @@ public val noGuild: Check<*> = { * * @param builder Lambda returning the guild to compare to. */ -public fun inGuild(builder: suspend () -> GuildBehavior): Check<*> = { +public suspend fun CheckContext.inGuild(builder: suspend (T) -> GuildBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.inGuild") val eventGuild = guildFor(event)?.asGuildOrNull() @@ -74,7 +85,7 @@ public fun inGuild(builder: suspend () -> GuildBehavior): Check<*> = { fail() } else { - val guild = builder() + val guild = builder(event) if (eventGuild.id == guild.id) { logger.passed() @@ -101,7 +112,11 @@ public fun inGuild(builder: suspend () -> GuildBehavior): Check<*> = { * * @param builder Lambda returning the guild to compare to. */ -public fun notInGuild(builder: suspend () -> GuildBehavior): Check<*> = { +public suspend fun CheckContext.notInGuild(builder: suspend (T) -> GuildBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.notInGuild") val eventGuild = guildFor(event)?.asGuild() @@ -110,7 +125,7 @@ public fun notInGuild(builder: suspend () -> GuildBehavior): Check<*> = { pass() } else { - val guild = builder() + val guild = builder(event) if (eventGuild.id != guild.id) { logger.passed() @@ -141,7 +156,11 @@ public fun notInGuild(builder: suspend () -> GuildBehavior): Check<*> = { * * @param id Guild snowflake to compare to. */ -public fun inGuild(id: Snowflake): Check<*> = { +public suspend fun CheckContext.inGuild(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.inGuild") val guild = event.kord.getGuild(id) @@ -150,7 +169,7 @@ public fun inGuild(id: Snowflake): Check<*> = { fail() } else { - inGuild { guild }() + inGuild { guild } } } @@ -162,7 +181,11 @@ public fun inGuild(id: Snowflake): Check<*> = { * * @param id Guild snowflake to compare to. */ -public fun notInGuild(id: Snowflake): Check<*> = { +public suspend fun CheckContext.notInGuild(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.notInGuild") val guild = event.kord.getGuild(id) @@ -171,7 +194,7 @@ public fun notInGuild(id: Snowflake): Check<*> = { pass() } else { - notInGuild { guild }() + notInGuild { guild } } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/MemberChecks.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/MemberChecks.kt index 7827e68365..9b0c7a7b34 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/MemberChecks.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/MemberChecks.kt @@ -2,9 +2,7 @@ package com.kotlindiscord.kord.extensions.checks -import com.kotlindiscord.kord.extensions.builders.ExtensibleBotBuilder -import com.kotlindiscord.kord.extensions.checks.types.Check -import com.kotlindiscord.kord.extensions.utils.getKoin +import com.kotlindiscord.kord.extensions.checks.types.CheckContext import com.kotlindiscord.kord.extensions.utils.hasPermission import com.kotlindiscord.kord.extensions.utils.permissionsForMember import com.kotlindiscord.kord.extensions.utils.translate @@ -12,10 +10,6 @@ import dev.kord.common.entity.Permission import dev.kord.core.entity.channel.GuildChannel import dev.kord.core.event.Event import mu.KotlinLogging -import java.util.* - -private val defaultLocale: Locale - get() = getKoin().get().i18nBuilder.defaultLocale /** * Check asserting that the user an [Event] fired for has a given permission, or the Administrator permission. @@ -25,7 +19,11 @@ private val defaultLocale: Locale * * @param perm The permission to check for. */ -public fun hasPermission(perm: Permission): Check<*> = { +public suspend fun CheckContext<*>.hasPermission(perm: Permission) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.hasPermission") val channel = channelFor(event) as? GuildChannel val member = memberFor(event) @@ -70,7 +68,11 @@ public fun hasPermission(perm: Permission): Check<*> = { * * @param perm The permission to check for. */ -public fun notHasPermission(perm: Permission): Check<*> = { +public suspend fun CheckContext<*>.notHasPermission(perm: Permission) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.notHasPermission") val channel = channelFor(event) as? GuildChannel val member = memberFor(event) diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/MiscChecks.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/MiscChecks.kt index fd30fd79c0..88fe60849c 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/MiscChecks.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/MiscChecks.kt @@ -1,17 +1,18 @@ package com.kotlindiscord.kord.extensions.checks -import com.kotlindiscord.kord.extensions.checks.types.Check +import com.kotlindiscord.kord.extensions.checks.types.CheckContext import dev.kord.core.behavior.channel.threads.ThreadChannelBehavior import dev.kord.core.event.Event import mu.KotlinLogging -import java.util.* /** * Check asserting the user for an [Event] is a bot. Will fail if the event doesn't concern a user. - * - * @param event Event object to check. */ -public val isBot: Check<*> = { +public suspend fun CheckContext<*>.isBot() { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.isBot") val user = userFor(event)?.asUserOrNull() @@ -34,10 +35,12 @@ public val isBot: Check<*> = { /** * Check asserting the user for an [Event] is **not** a bot. Will fail if the event doesn't concern a user. - * - * @param event Event object to check. */ -public val isNotbot: Check<*> = { +public suspend fun CheckContext<*>.isNotBot() { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.isNotBot") val user = userFor(event)?.asUserOrNull() @@ -61,7 +64,11 @@ public val isNotbot: Check<*> = { /** * Check asserting that the event was triggered within a thread. */ -public val isInThread: Check<*> = { +public suspend fun CheckContext<*>.isInThread() { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.isInThread") val channel = channelFor(event)?.asChannelOrNull() @@ -86,7 +93,11 @@ public val isInThread: Check<*> = { * Check asserting that the event was **not** triggered within a thread, including events that don't concern any * specific channel. */ -public val isNotInThread: Check<*> = { +public suspend fun CheckContext<*>.isNotInThread() { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.isNotInThread") val channel = channelFor(event)?.asChannelOrNull() diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/RoleChecks.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/RoleChecks.kt index bd1a943f4e..bb316559a8 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/RoleChecks.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/RoleChecks.kt @@ -2,7 +2,7 @@ package com.kotlindiscord.kord.extensions.checks -import com.kotlindiscord.kord.extensions.checks.types.Check +import com.kotlindiscord.kord.extensions.checks.types.CheckContext import com.kotlindiscord.kord.extensions.utils.getTopRole import dev.kord.common.entity.Snowflake import dev.kord.core.behavior.RoleBehavior @@ -20,7 +20,11 @@ import mu.KotlinLogging * * @param builder Lambda returning the role to compare to. */ -public fun hasRole(builder: suspend () -> RoleBehavior): Check<*> = { +public suspend fun CheckContext.hasRole(builder: suspend (T) -> RoleBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.hasRole") val member = memberFor(event) @@ -29,7 +33,7 @@ public fun hasRole(builder: suspend () -> RoleBehavior): Check<*> = { fail() } else { - val role = builder() + val role = builder(event) if (member.asMember().roles.toList().contains(role)) { logger.passed() @@ -56,7 +60,11 @@ public fun hasRole(builder: suspend () -> RoleBehavior): Check<*> = { * * @param builder Lambda returning the role to compare to. */ -public fun notHasRole(builder: suspend () -> RoleBehavior): Check<*> = { +public suspend fun CheckContext.notHasRole(builder: suspend (T) -> RoleBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.notHasRole") val member = memberFor(event) @@ -65,7 +73,7 @@ public fun notHasRole(builder: suspend () -> RoleBehavior): Check<*> = { pass() } else { - val role = builder() + val role = builder(event) if (member.asMember().roles.toList().contains(role)) { logger.failed("Member $member has role $role") @@ -92,7 +100,11 @@ public fun notHasRole(builder: suspend () -> RoleBehavior): Check<*> = { * * @param builder Lambda returning the role to compare to. */ -public fun topRoleEqual(builder: suspend () -> RoleBehavior): Check<*> = { +public suspend fun CheckContext.topRoleEqual(builder: suspend (T) -> RoleBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.topRoleEqual") val member = memberFor(event) @@ -101,7 +113,7 @@ public fun topRoleEqual(builder: suspend () -> RoleBehavior): Check<*> = { fail() } else { - val role = builder() + val role = builder(event) val topRole = member.asMember().getTopRole() when { @@ -144,7 +156,11 @@ public fun topRoleEqual(builder: suspend () -> RoleBehavior): Check<*> = { * * @param builder Lambda returning the role to compare to. */ -public fun topRoleNotEqual(builder: suspend () -> RoleBehavior): Check<*> = { +public suspend fun CheckContext.topRoleNotEqual(builder: suspend (T) -> RoleBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.topRoleNotEqual") val member = memberFor(event) @@ -153,7 +169,7 @@ public fun topRoleNotEqual(builder: suspend () -> RoleBehavior): Check<*> = { pass() } else { - val role = builder() + val role = builder(event) when (member.asMember().getTopRole()) { null -> { @@ -190,7 +206,11 @@ public fun topRoleNotEqual(builder: suspend () -> RoleBehavior): Check<*> = { * * @param builder Lambda returning the role to compare to. */ -public fun topRoleHigher(builder: suspend () -> RoleBehavior): Check<*> = { +public suspend fun CheckContext.topRoleHigher(builder: suspend (T) -> RoleBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.topRoleHigher") val member = memberFor(event) @@ -199,7 +219,7 @@ public fun topRoleHigher(builder: suspend () -> RoleBehavior): Check<*> = { fail() } else { - val role = builder() + val role = builder(event) val topRole = member.asMember().getTopRole() when { @@ -244,7 +264,11 @@ public fun topRoleHigher(builder: suspend () -> RoleBehavior): Check<*> = { * * @param builder Lambda returning the role to compare to. */ -public fun topRoleLower(builder: suspend () -> RoleBehavior): Check<*> = { +public suspend fun CheckContext.topRoleLower(builder: suspend (T) -> RoleBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.topRoleLower") val member = memberFor(event) @@ -253,7 +277,7 @@ public fun topRoleLower(builder: suspend () -> RoleBehavior): Check<*> = { fail() } else { - val role = builder() + val role = builder(event) val topRole = member.asMember().getTopRole() when { @@ -292,7 +316,11 @@ public fun topRoleLower(builder: suspend () -> RoleBehavior): Check<*> = { * * @param builder Lambda returning the role to compare to. */ -public fun topRoleHigherOrEqual(builder: suspend () -> RoleBehavior): Check<*> = { +public suspend fun CheckContext.topRoleHigherOrEqual(builder: suspend (T) -> RoleBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.topRoleHigherOrEqual") val member = memberFor(event) @@ -301,7 +329,7 @@ public fun topRoleHigherOrEqual(builder: suspend () -> RoleBehavior): Check<*> = fail() } else { - val role = builder() + val role = builder(event) val topRole = member.asMember().getTopRole() when { @@ -347,7 +375,11 @@ public fun topRoleHigherOrEqual(builder: suspend () -> RoleBehavior): Check<*> = * * @param builder Lambda returning the role to compare to. */ -public fun topRoleLowerOrEqual(builder: suspend () -> RoleBehavior): Check<*> = { +public suspend fun CheckContext.topRoleLowerOrEqual(builder: suspend (T) -> RoleBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.topRoleLowerOrEqual") val member = memberFor(event) @@ -356,7 +388,7 @@ public fun topRoleLowerOrEqual(builder: suspend () -> RoleBehavior): Check<*> = fail() } else { - val role = builder() + val role = builder(event) val topRole = member.asMember().getTopRole() when { @@ -398,7 +430,11 @@ public fun topRoleLowerOrEqual(builder: suspend () -> RoleBehavior): Check<*> = * * @param id Role snowflake to compare to. */ -public fun hasRole(id: Snowflake): Check<*> = { +public suspend fun CheckContext.hasRole(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.hasRole") val role = guildFor(event)?.getRoleOrNull(id) @@ -407,7 +443,7 @@ public fun hasRole(id: Snowflake): Check<*> = { fail() } else { - hasRole { role }() + hasRole { role } } } @@ -419,7 +455,11 @@ public fun hasRole(id: Snowflake): Check<*> = { * * @param id Role snowflake to compare to. */ -public fun notHasRole(id: Snowflake): Check<*> = { +public suspend fun CheckContext.notHasRole(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.notHasRole") val role = guildFor(event)?.getRoleOrNull(id) @@ -428,7 +468,7 @@ public fun notHasRole(id: Snowflake): Check<*> = { pass() } else { - notHasRole { role }() + notHasRole { role } } } @@ -440,7 +480,11 @@ public fun notHasRole(id: Snowflake): Check<*> = { * * @param id Role snowflake to compare to. */ -public fun topRoleEqual(id: Snowflake): Check<*> = { +public suspend fun CheckContext.topRoleEqual(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.topRoleEqual") val role = guildFor(event)?.getRoleOrNull(id) @@ -449,7 +493,7 @@ public fun topRoleEqual(id: Snowflake): Check<*> = { fail() } else { - topRoleEqual { role }() + topRoleEqual { role } } } @@ -461,7 +505,11 @@ public fun topRoleEqual(id: Snowflake): Check<*> = { * * @param id Role snowflake to compare to. */ -public fun topRoleNotEqual(id: Snowflake): Check<*> = { +public suspend fun CheckContext.topRoleNotEqual(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.topRoleNotEqual") val role = guildFor(event)?.getRoleOrNull(id) @@ -470,7 +518,7 @@ public fun topRoleNotEqual(id: Snowflake): Check<*> = { pass() } else { - topRoleNotEqual { role }() + topRoleNotEqual { role } } } @@ -482,7 +530,11 @@ public fun topRoleNotEqual(id: Snowflake): Check<*> = { * * @param id Role snowflake to compare to. */ -public fun topRoleHigher(id: Snowflake): Check<*> = { +public suspend fun CheckContext.topRoleHigher(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.topRoleHigher") val role = guildFor(event)?.getRoleOrNull(id) @@ -491,7 +543,7 @@ public fun topRoleHigher(id: Snowflake): Check<*> = { fail() } else { - topRoleHigher { role }() + topRoleHigher { role } } } @@ -505,7 +557,11 @@ public fun topRoleHigher(id: Snowflake): Check<*> = { * * @param id Role snowflake to compare to. */ -public fun topRoleLower(id: Snowflake): Check<*> = { +public suspend fun CheckContext.topRoleLower(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.topRoleLower") val role = guildFor(event)?.getRoleOrNull(id) @@ -514,7 +570,7 @@ public fun topRoleLower(id: Snowflake): Check<*> = { fail() } else { - topRoleLower { role }() + topRoleLower { role } } } @@ -527,7 +583,11 @@ public fun topRoleLower(id: Snowflake): Check<*> = { * * @param id Role snowflake to compare to. */ -public fun topRoleHigherOrEqual(id: Snowflake): Check<*> = { +public suspend fun CheckContext.topRoleHigherOrEqual(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.topRoleHigherOrEqual") val role = guildFor(event)?.getRoleOrNull(id) @@ -536,7 +596,7 @@ public fun topRoleHigherOrEqual(id: Snowflake): Check<*> = { fail() } else { - topRoleHigherOrEqual { role }() + topRoleHigherOrEqual { role } } } @@ -551,7 +611,11 @@ public fun topRoleHigherOrEqual(id: Snowflake): Check<*> = { * * @param id Role snowflake to compare to. */ -public fun topRoleLowerOrEqual(id: Snowflake): Check<*> = { +public suspend fun CheckContext.topRoleLowerOrEqual(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.topRoleLowerOrEqual") val role = guildFor(event)?.getRoleOrNull(id) @@ -560,7 +624,7 @@ public fun topRoleLowerOrEqual(id: Snowflake): Check<*> = { fail() } else { - topRoleLowerOrEqual { role }() + topRoleLowerOrEqual { role } } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/TopChannelChecks.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/TopChannelChecks.kt index 5891d5a4b4..4d4c88848d 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/TopChannelChecks.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/TopChannelChecks.kt @@ -2,7 +2,7 @@ package com.kotlindiscord.kord.extensions.checks -import com.kotlindiscord.kord.extensions.checks.types.Check +import com.kotlindiscord.kord.extensions.checks.types.CheckContext import dev.kord.common.entity.Snowflake import dev.kord.core.behavior.channel.ChannelBehavior import dev.kord.core.entity.channel.thread.ThreadChannel @@ -20,7 +20,11 @@ import mu.KotlinLogging * * @param builder Lambda returning the channel to compare to. */ -public fun inTopChannel(builder: suspend () -> ChannelBehavior): Check<*> = { +public suspend fun CheckContext.inTopChannel(builder: suspend (T) -> ChannelBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.inChannel") val eventChannel = topChannelFor(event) @@ -29,7 +33,7 @@ public fun inTopChannel(builder: suspend () -> ChannelBehavior): Check<*> = { fail() } else { - val channel = builder() + val channel = builder(event) if (eventChannel.id == channel.id) { logger.passed() @@ -57,7 +61,11 @@ public fun inTopChannel(builder: suspend () -> ChannelBehavior): Check<*> = { * * @param builder Lambda returning the channel to compare to. */ -public fun notInTopChannel(builder: suspend () -> ChannelBehavior): Check<*> = { +public suspend fun CheckContext.notInTopChannel(builder: suspend (T) -> ChannelBehavior) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.notInChannel") val eventChannel = topChannelFor(event) @@ -66,7 +74,7 @@ public fun notInTopChannel(builder: suspend () -> ChannelBehavior): Check<*> = { pass() } else { - val channel = builder() + val channel = builder(event) if (eventChannel.id != channel.id) { logger.passed() @@ -98,7 +106,11 @@ public fun notInTopChannel(builder: suspend () -> ChannelBehavior): Check<*> = { * * @param id Channel snowflake to compare to. */ -public fun inTopChannel(id: Snowflake): Check<*> = { +public suspend fun CheckContext.inTopChannel(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.inChannel") var channel = event.kord.getChannel(id) @@ -111,7 +123,7 @@ public fun inTopChannel(id: Snowflake): Check<*> = { fail() } else { - inChannel { channel }() + inTopChannel { channel } } } @@ -124,7 +136,11 @@ public fun inTopChannel(id: Snowflake): Check<*> = { * * @param id Channel snowflake to compare to. */ -public fun notInTopChannel(id: Snowflake): Check<*> = { +public suspend fun CheckContext.notInTopChannel(id: Snowflake) { + if (!passed) { + return + } + val logger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.checks.notInChannel") var channel = event.kord.getChannel(id) @@ -137,7 +153,7 @@ public fun notInTopChannel(id: Snowflake): Check<*> = { pass() } else { - notInChannel { channel }() + notInTopChannel { channel } } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/types/CheckContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/types/CheckContext.kt index 954ce02326..e25bb886b6 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/types/CheckContext.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/types/CheckContext.kt @@ -1,5 +1,6 @@ package com.kotlindiscord.kord.extensions.checks.types +import com.kotlindiscord.kord.extensions.DiscordRelayedException import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider import dev.kord.core.event.Event import org.koin.core.component.KoinComponent @@ -17,6 +18,19 @@ public class CheckContext(public val event: T, public val locale: /** Translations provider. **/ public val translations: TranslationsProvider by inject() + /** + * Translation key to use for the error response message, if not the default. + * + * The string pointed to by this variable must accept one replacement value, which is the error message itself. + * + * **Note:** This *must* be a translation key. A bare string may not work, as the error response function uses + * the replacement functionality of the translations system. + */ + public var errorResponseKey: String = "checks.responseTemplate" + + /** Translation bundle used by [translate] by default and the error response translation, if not the default. **/ + public var defaultBundle: String? = null + /** Human-readable message for the user, if any. **/ public var message: String? = null @@ -73,6 +87,45 @@ public class CheckContext(public val event: T, public val locale: public suspend fun failIfNot(message: String? = null, callback: suspend () -> Boolean): Boolean = failIfNot(callback(), message) + /** + * If [value] is `true`, mark this check as having passed. + * + * Returns `true` if the check was marked as having passed, `false` otherwise. + */ + public fun passIf(value: Boolean): Boolean { + if (value) { + pass() + + return true + } + + return false + } + + /** + * If [callback] returns `true`, mark this check as having passed. + * + * Returns `true` if the check was marked as having passed, `false` otherwise. + */ + public suspend fun passIf(callback: suspend () -> Boolean): Boolean = + passIf(callback()) + + /** + * If [value] is `true`, mark this check as having passed. + * + * Returns `true` if the check was marked as having passed, `false` otherwise. + */ + public fun passIfNot(value: Boolean): Boolean = + passIf(!value) + + /** + * If [callback] returns `true`, mark this check as having passed. + * + * Returns `true` if the check was marked as having passed, `false` otherwise. + */ + public suspend fun passIfNot(callback: suspend () -> Boolean): Boolean = + passIfNot(callback()) + /** Call the given block if the Boolean receiver is `true`. **/ public inline fun Boolean.whenTrue(body: () -> T?): T? { if (this) { @@ -92,6 +145,30 @@ public class CheckContext(public val event: T, public val locale: } /** Quick access to translate strings using this check context's [locale]. **/ - public fun translate(key: String, bundle: String? = null, replacements: Array = arrayOf()): String = + public fun translate( + key: String, + bundle: String? = defaultBundle, + replacements: Array = arrayOf() + ): String = translations.translate(key, locale, bundleName = bundle, replacements = replacements) + + /** + * If this check has failed and a message is set, throw a [DiscordRelayedException] with the translated message. + */ + @Throws(DiscordRelayedException::class) + public fun throwIfFailedWithMessage() { + if (passed.not() && message != null) { + throw DiscordRelayedException( + getTranslatedMessage()!! + ) + } + } + + /** Get the translated check failure message, if the check has failed and a message was set. **/ + public fun getTranslatedMessage(): String? = + if (passed.not() && message != null) { + translate(errorResponseKey, defaultBundle, replacements = arrayOf(message)) + } else { + null + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/types/_Types.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/types/_Types.kt index 8cde40f2a2..320dc60fdf 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/types/_Types.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/checks/types/_Types.kt @@ -2,5 +2,22 @@ package com.kotlindiscord.kord.extensions.checks.types +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent +import dev.kord.core.event.interaction.MessageCommandInteractionCreateEvent +import dev.kord.core.event.interaction.UserCommandInteractionCreateEvent +import dev.kord.core.event.message.MessageCreateEvent + /** Types alias representing a check function for a specific event type. **/ public typealias Check = suspend CheckContext.() -> Unit + +/** Check type for chat commands. **/ +public typealias ChatCommandCheck = Check + +/** Check type for message commands. **/ +public typealias MessageCommandCheck = Check + +/** Check type for slash commands. **/ +public typealias SlashCommandCheck = Check + +/** Check type for user commands. **/ +public typealias UserCommandCheck = Check diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/parser/Argument.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/Argument.kt similarity index 90% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/parser/Argument.kt rename to kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/Argument.kt index 032dd7018f..338b6777d6 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/parser/Argument.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/Argument.kt @@ -1,4 +1,4 @@ -package com.kotlindiscord.kord.extensions.commands.parser +package com.kotlindiscord.kord.extensions.commands import com.kotlindiscord.kord.extensions.commands.converters.Converter diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/parser/Arguments.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/Arguments.kt similarity index 92% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/parser/Arguments.kt rename to kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/Arguments.kt index 0741bc168f..e581502b5f 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/parser/Arguments.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/Arguments.kt @@ -1,9 +1,6 @@ -@file:OptIn(KordPreview::class) - -package com.kotlindiscord.kord.extensions.commands.parser +package com.kotlindiscord.kord.extensions.commands import com.kotlindiscord.kord.extensions.commands.converters.* -import dev.kord.common.annotation.KordPreview /** * Abstract base class for a class containing a set of command arguments. @@ -162,4 +159,19 @@ public open class Arguments { return converter } + + /** Validation function that will throw an error if there's a problem with this Arguments class/subclass. **/ + public open fun validate() { + val names: MutableSet = mutableSetOf() + + args.forEach { + val name = it.displayName.lowercase() + + if (name in names) { + error("Duplicate argument name: ${it.displayName}") + } + + names.add(name) + } + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/Command.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/Command.kt index cbe9cead88..3de1601ab8 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/Command.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/Command.kt @@ -1,9 +1,15 @@ +@file:Suppress("UnnecessaryAbstractClass") // No idea why we're getting this + package com.kotlindiscord.kord.extensions.commands import com.kotlindiscord.kord.extensions.InvalidCommandException import com.kotlindiscord.kord.extensions.annotations.ExtensionDSL -import com.kotlindiscord.kord.extensions.commands.parser.ArgumentParser +import com.kotlindiscord.kord.extensions.commands.events.CommandEvent import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.types.Lockable +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex /** * Abstract base class representing the few things that command objects can have in common. @@ -13,26 +19,36 @@ import com.kotlindiscord.kord.extensions.extensions.Extension * @property extension The extension object this command belongs to. */ @ExtensionDSL -public abstract class Command(public val extension: Extension) { +public abstract class Command(public val extension: Extension) : Lockable { /** * The name of this command, for invocation and help commands. */ public open lateinit var name: String - /** - * Argument parser object responsible for transforming arguments into Kotlin types. - */ - public abstract val parser: ArgumentParser + /** Set this to `true` to lock command execution with a Mutex. **/ + public override var locking: Boolean = false + + override var mutex: Mutex? = null /** - * An internal function used to ensure that all of a command's required arguments are present. + * An internal function used to ensure that all of a command's required arguments are present and correct. * - * @throws InvalidCommandException Thrown when a required argument hasn't been set. + * @throws InvalidCommandException Thrown when a required argument hasn't been set or is invalid. */ @Throws(InvalidCommandException::class) public open fun validate() { - if (!::name.isInitialized) { + if (!::name.isInitialized || name.isEmpty()) { throw InvalidCommandException(null, "No command name given.") } + + if (locking && mutex == null) { + mutex = Mutex() + } } + + /** Quick shortcut for emitting a command event without blocking. **/ + public open suspend fun emitEventAsync(event: CommandEvent<*, *>): Job = + event.launch { + extension.bot.send(event) + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/CommandContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/CommandContext.kt index d25f290942..3bfe3e9de5 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/CommandContext.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/CommandContext.kt @@ -5,16 +5,12 @@ import com.kotlindiscord.kord.extensions.checks.channelFor import com.kotlindiscord.kord.extensions.checks.guildFor import com.kotlindiscord.kord.extensions.checks.userFor import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider -import com.kotlindiscord.kord.extensions.parser.StringParser -import com.kotlindiscord.kord.extensions.sentry.SentryAdapter +import com.kotlindiscord.kord.extensions.sentry.SentryContext import dev.kord.core.behavior.GuildBehavior import dev.kord.core.behavior.MemberBehavior -import dev.kord.core.behavior.MessageBehavior import dev.kord.core.behavior.UserBehavior import dev.kord.core.behavior.channel.ChannelBehavior import dev.kord.core.event.Event -import io.sentry.Breadcrumb -import io.sentry.SentryLevel import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.util.* @@ -28,23 +24,18 @@ import java.util.* * @param command Respective command for this context object. * @param eventObj Event that triggered this command. * @param commandName Command name given by the user to invoke the command - lower-cased. - * @param parser String parser instance, if any - will be `null` if this isn't a message command. */ @ExtensionDSL public abstract class CommandContext( public open val command: Command, public open val eventObj: Event, public open val commandName: String, - public open val parser: StringParser?, ) : KoinComponent { /** Translations provider, for retrieving translations. **/ public val translationsProvider: TranslationsProvider by inject() - /** Sentry adapter, for easy access to Sentry functions. **/ - public val sentry: SentryAdapter by inject() - - /** A list of Sentry breadcrumbs created during command execution. **/ - public open val breadcrumbs: MutableList = mutableListOf() + /** Current Sentry context, containing breadcrumbs and other goodies. **/ + public val sentry: SentryContext = SentryContext() /** Cached locale variable, stored and retrieved by [getLocale]. **/ public open var resolvedLocale: Locale? = null @@ -61,34 +52,9 @@ public abstract class CommandContext( /** Extract member information from event data, if that context is available. **/ public abstract suspend fun getMember(): MemberBehavior? - /** Extract message information from event data, if that context is available. **/ - public abstract suspend fun getMessage(): MessageBehavior? - /** Extract user information from event data, if that context is available. **/ public abstract suspend fun getUser(): UserBehavior? - /** - * Add a Sentry breadcrumb to this command context. - * - * This should be used for the purposes of tracing what exactly is happening during your - * command processing. If the bot administrator decides to enable Sentry integration, the - * breadcrumbs will be sent to Sentry when there's a command processing error. - */ - public fun breadcrumb( - category: String? = null, - level: SentryLevel? = null, - message: String? = null, - type: String? = null, - - data: Map = mapOf() - ): Breadcrumb { - val crumb = sentry.createBreadcrumb(category, level, message, type, data) - - breadcrumbs.add(crumb) - - return crumb - } - /** Resolve the locale for this command context. **/ public suspend fun getLocale(): Locale { var locale: Locale? = resolvedLocale diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/ApplicationCommand.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/ApplicationCommand.kt new file mode 100644 index 0000000000..1e870fb6e8 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/ApplicationCommand.kt @@ -0,0 +1,251 @@ +package com.kotlindiscord.kord.extensions.commands.application + +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.builders.ExtensibleBotBuilder +import com.kotlindiscord.kord.extensions.checks.types.Check +import com.kotlindiscord.kord.extensions.checks.types.CheckContext +import com.kotlindiscord.kord.extensions.commands.Command +import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider +import com.kotlindiscord.kord.extensions.sentry.SentryAdapter +import com.kotlindiscord.kord.extensions.utils.getLocale +import dev.kord.common.entity.ApplicationCommandType +import dev.kord.common.entity.Permission +import dev.kord.common.entity.Snowflake +import dev.kord.core.Kord +import dev.kord.core.any +import dev.kord.core.behavior.GuildBehavior +import dev.kord.core.behavior.RoleBehavior +import dev.kord.core.behavior.UserBehavior +import dev.kord.core.event.interaction.InteractionCreateEvent +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.util.* + +/** + * Abstract class representing an application command - extend this for actual implementations. + * + * @param extension Extension this application command belongs to. + */ +public abstract class ApplicationCommand( + extension: Extension +) : Command(extension), KoinComponent { + /** Translations provider, for retrieving translations. **/ + public val translationsProvider: TranslationsProvider by inject() + + /** Quick access to the command registry. **/ + public val registry: ApplicationCommandRegistry by inject() + + /** Bot settings object. **/ + public val settings: ExtensibleBotBuilder by inject() + + /** Kord instance, backing the ExtensibleBot. **/ + public val kord: Kord by inject() + + /** Sentry adapter, for easy access to Sentry functions. **/ + public val sentry: SentryAdapter by inject() + + /** Discord-side command type, for matching up. **/ + public abstract val type: ApplicationCommandType + + /** @suppress **/ + public open val checkList: MutableList> = mutableListOf() + + /** @suppress **/ + public open var guildId: Snowflake? = settings.applicationCommandsBuilder.defaultGuild + + /** + * Whether to allow everyone to use this command by default. + * + * Leaving this at `true` means that your allowed roles/users sets will effectively be ignored, but your denied + * roles/users won't be. + * + * This will be set to `false` automatically by the `allowX` functions, to ensure that they're applied by Discord. + */ + public open var allowByDefault: Boolean = true + + /** + * List of allowed role IDs. Allows take priority over disallows. + */ + public open val allowedRoles: MutableSet = mutableSetOf() + + /** + * List of allowed invoker IDs. Allows take priority over disallows. + */ + public open val allowedUsers: MutableSet = mutableSetOf() + + /** + * List of disallowed role IDs. Allows take priority over disallows. + */ + public open val disallowedRoles: MutableSet = mutableSetOf() + + /** + * List of disallowed invoker IDs. Allows take priority over disallows. + */ + public open val disallowedUsers: MutableSet = mutableSetOf() + + /** Permissions required to be able to run this command. **/ + public open val requiredPerms: MutableSet = mutableSetOf() + + /** Translation cache, so we don't have to look up translations every time. **/ + public open val nameTranslationCache: MutableMap = mutableMapOf() + + /** Translation cache, so we don't have to look up translations every time. **/ + public open val descriptionTranslationCache: MutableMap = mutableMapOf() + + /** Return this command's name translated for the given locale, cached as required. **/ + public open fun getTranslatedName(locale: Locale): String { + if (!nameTranslationCache.containsKey(locale)) { + nameTranslationCache[locale] = translationsProvider.translate( + this.name, + this.extension.bundle, + locale + ) + } + + return nameTranslationCache[locale]!! + } + + /** If your bot requires permissions to be able to execute the command, add them using this function. **/ + public fun requireBotPermissions(vararg perms: Permission) { + perms.forEach { requiredPerms.add(it) } + } + + /** Specify a specific guild for this application command to be locked to. **/ + public open fun guild(guild: Snowflake) { + this.guildId = guild + } + + /** Specify a specific guild for this application command to be locked to. **/ + public open fun guild(guild: Long) { + this.guildId = Snowflake(guild) + } + + /** Specify a specific guild for this application command to be locked to. **/ + public open fun guild(guild: GuildBehavior) { + this.guildId = guild.id + } + + /** Register an allowed role, and set [allowByDefault] to `false`. **/ + public open fun allowRole(role: Snowflake): Boolean { + allowByDefault = false + + return allowedRoles.add(role) + } + + /** Register an allowed role, and set [allowByDefault] to `false`. **/ + public open fun allowRole(role: RoleBehavior): Boolean = + allowRole(role.id) + + /** Register a disallowed role, and set [allowByDefault] to `false`. **/ + public open fun disallowRole(role: Snowflake): Boolean = + disallowedRoles.add(role) + + /** Register a disallowed role, and set [allowByDefault] to `false`. **/ + public open fun disallowRole(role: RoleBehavior): Boolean = + disallowRole(role.id) + + /** Register an allowed user, and set [allowByDefault] to `false`. **/ + public open fun allowUser(user: Snowflake): Boolean { + allowByDefault = false + + return allowedUsers.add(user) + } + + /** Register an allowed user, and set [allowByDefault] to `false`. **/ + public open fun allowUser(user: UserBehavior): Boolean = + allowUser(user.id) + + /** Register a disallowed user. **/ + public open fun disallowUser(user: Snowflake): Boolean = + disallowedUsers.add(user) + + /** Register a disallowed user. **/ + public open fun disallowUser(user: UserBehavior): Boolean = + disallowUser(user.id) + + /** + * Define a check which must pass for the command to be executed. + * + * A command may have multiple checks - all checks must pass for the command to be executed. + * Checks will be run in the order that they're defined. + * + * This function can be used DSL-style with a given body, or it can be passed one or more + * predefined functions. See the samples for more information. + * + * @param checks Checks to apply to this command. + */ + public open fun check(vararg checks: Check) { + checkList.addAll(checks) + } + + /** + * Overloaded check function to allow for DSL syntax. + * + * @param check Check to apply to this command. + */ + public open fun check(check: Check) { + checkList.add(check) + } + + /** Called in order to execute the command. **/ + public open suspend fun doCall(event: E): Unit = withLock { + if (!runChecks(event)) { + return@withLock + } + + call(event) + } + + /** Runs standard checks that can be handled in a generic way, without worrying about subclass-specific checks. **/ + @Throws(DiscordRelayedException::class) + public open suspend fun runStandardChecks(event: E): Boolean { + val locale = event.getLocale() + + checkList.forEach { check -> + val context = CheckContext(event, locale) + + check(context) + + if (!context.passed) { + context.throwIfFailedWithMessage() + + return false + } + } + + // Handle discord-side perms checks, as they can't be relied on to enforce them + + val guildId = event.interaction.data.guildId.value ?: return allowByDefault + val memberId = event.interaction.user.id + + var isAllowed = memberId in allowedUsers + var isDenied = memberId in disallowedUsers + + if (allowedRoles.isNotEmpty()) { + val member = GuildBehavior(guildId, kord).getMember(memberId) + + isAllowed = isAllowed || member.roles.any { it.id in allowedRoles } + } + + if (disallowedRoles.isNotEmpty()) { + val member = GuildBehavior(guildId, kord).getMember(memberId) + + isDenied = isDenied || member.roles.any { it.id in disallowedRoles } + } + + return if (allowByDefault) { + !isDenied + } else { + isAllowed && !isDenied + } + } + + /** Override this in order to implement any subclass-specific checks. **/ + @Throws(DiscordRelayedException::class) + public open suspend fun runChecks(event: E): Boolean = + runStandardChecks(event) + + /** Override this to implement the calling logic for your subclass. **/ + public abstract suspend fun call(event: E) +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/ApplicationCommandContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/ApplicationCommandContext.kt new file mode 100644 index 0000000000..b023c43abe --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/ApplicationCommandContext.kt @@ -0,0 +1,67 @@ +@file:OptIn(KordUnsafe::class, KordExperimental::class) + +package com.kotlindiscord.kord.extensions.commands.application + +import com.kotlindiscord.kord.extensions.builders.ExtensibleBotBuilder +import com.kotlindiscord.kord.extensions.commands.CommandContext +import dev.kord.common.annotation.KordExperimental +import dev.kord.common.annotation.KordUnsafe +import dev.kord.core.behavior.GuildBehavior +import dev.kord.core.behavior.MemberBehavior +import dev.kord.core.behavior.UserBehavior +import dev.kord.core.behavior.channel.MessageChannelBehavior +import dev.kord.core.entity.interaction.GuildApplicationCommandInteraction +import dev.kord.core.event.interaction.ApplicationInteractionCreateEvent +import org.koin.core.component.inject + +/** + * Base class representing the shared functionality for an application command's context. + * + * @param genericEvent Generic event object to populate data from. + * @param genericCommand Generic command object that this context belongs to. + */ +public abstract class ApplicationCommandContext( + public val genericEvent: ApplicationInteractionCreateEvent, + public val genericCommand: ApplicationCommand<*> +) : CommandContext(genericCommand, genericEvent, genericCommand.name) { + /** Current bot setting object. **/ + public val botSettings: ExtensibleBotBuilder by inject() + + /** Channel this command was executed within. **/ + public open lateinit var channel: MessageChannelBehavior + + /** Guild this command was executed within, if any. **/ + public open var guild: GuildBehavior? = null + + /** Member that executed this command, if on a guild. **/ + public open var member: MemberBehavior? = null + + /** User that executed this command. **/ + public open lateinit var user: UserBehavior + + /** Called before processing, used to populate any extra variables from event data. **/ + public override suspend fun populate() { + // NOTE: This must always be alphabetical, some latter calls rely on earlier ones + + channel = getChannel() + guild = getGuild() + member = getMember() + user = getUser() + } + + /** Extract channel information from event data, if that context is available. **/ + public override suspend fun getChannel(): MessageChannelBehavior = + genericEvent.interaction.channel + + /** Extract guild information from event data, if that context is available. **/ + public override suspend fun getGuild(): GuildBehavior? = + genericEvent.interaction.data.guildId.value?.let { genericEvent.kord.unsafe.guild(it) } + + /** Extract member information from event data, if that context is available. **/ + public override suspend fun getMember(): MemberBehavior? = + (genericEvent.interaction as? GuildApplicationCommandInteraction)?.member + + /** Extract user information from event data, if that context is available. **/ + public override suspend fun getUser(): UserBehavior = + genericEvent.interaction.user +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/ApplicationCommandRegistry.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/ApplicationCommandRegistry.kt new file mode 100644 index 0000000000..e937d8064e --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/ApplicationCommandRegistry.kt @@ -0,0 +1,499 @@ +@file:Suppress( + "UNCHECKED_CAST", + "TooGenericExceptionCaught", + "StringLiteralDuplication", +) +@file:OptIn( + KordUnsafe::class, + KordExperimental::class +) + +package com.kotlindiscord.kord.extensions.commands.application + +import com.kotlindiscord.kord.extensions.ExtensibleBot +import com.kotlindiscord.kord.extensions.commands.application.message.MessageCommand +import com.kotlindiscord.kord.extensions.commands.application.slash.SlashCommand +import com.kotlindiscord.kord.extensions.commands.application.slash.SlashCommandParser +import com.kotlindiscord.kord.extensions.commands.application.user.UserCommand +import com.kotlindiscord.kord.extensions.commands.converters.SlashCommandConverter +import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider +import dev.kord.common.annotation.KordExperimental +import dev.kord.common.annotation.KordUnsafe +import dev.kord.common.entity.ApplicationCommandType +import dev.kord.common.entity.Snowflake +import dev.kord.core.Kord +import dev.kord.core.behavior.createChatInputCommand +import dev.kord.core.behavior.createMessageCommand +import dev.kord.core.behavior.createUserCommand +import dev.kord.core.entity.Guild +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent +import dev.kord.core.event.interaction.MessageCommandInteractionCreateEvent +import dev.kord.core.event.interaction.UserCommandInteractionCreateEvent +import dev.kord.rest.builder.interaction.* +import dev.kord.rest.request.KtorRequestException +import mu.KLogger +import mu.KotlinLogging +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.util.* + +/** + * Abstract class representing common behavior for application command registries. + * + * Deals with the registration and syncing of, and dispatching to, all application commands. + * Subtypes should build their functionality on top of this type. + * + * @see DefaultApplicationCommandRegistry + */ +public abstract class ApplicationCommandRegistry : KoinComponent { + + protected val logger: KLogger = KotlinLogging.logger { } + + /** Current instance of the bot. **/ + public open val bot: ExtensibleBot by inject() + + /** Kord instance, backing the ExtensibleBot. **/ + public open val kord: Kord by inject() + + /** Translations provider, for retrieving translations. **/ + public open val translationsProvider: TranslationsProvider by inject() + + /** Command parser to use for slash commands. **/ + public val argumentParser: SlashCommandParser = SlashCommandParser() + + /** Whether the initial sync has been finished, and commands should be registered directly. **/ + public var initialised: Boolean = false + + /** Quick access to the human-readable name for a Discord application command type. **/ + public val ApplicationCommandType.name: String + get() = when (this) { + is ApplicationCommandType.Unknown -> "unknown" + + ApplicationCommandType.ChatInput -> "slash" + ApplicationCommandType.Message -> "message" + ApplicationCommandType.User -> "user" + } + + /** Handles the initial registration of commands, after extensions have been loaded. **/ + public suspend fun initialRegistration() { + if (initialised) { + return + } + + val commands: MutableList> = mutableListOf() + + bot.extensions.values.forEach { + commands += it.messageCommands + commands += it.slashCommands + commands += it.userCommands + } + + try { + initialize(commands) + } catch (t: Throwable) { + logger.error(t) { "Failed to initialize registry" } + } + + initialised = true + } + + /** Called once the initial registration started and all extensions are loaded. **/ + protected abstract suspend fun initialize(commands: List>) + + /** Register a [SlashCommand] to the registry. + * + * This method is only called after the [initialize] method and allows runtime modifications. + */ + public abstract suspend fun register(command: SlashCommand<*, *>): SlashCommand<*, *>? + + /** + * Register a [MessageCommand] to the registry. + * + * This method is only called after the [initialize] method and allows runtime modifications. + */ + public abstract suspend fun register(command: MessageCommand<*>): MessageCommand<*>? + + /** Register a [UserCommand] to the registry. + * + * This method is only called after the [initialize] method and allows runtime modifications. + */ + public abstract suspend fun register(command: UserCommand<*>): UserCommand<*>? + + /** Event handler for slash commands. **/ + public abstract suspend fun handle(event: ChatInputCommandInteractionCreateEvent) + + /** Event handler for message commands. **/ + public abstract suspend fun handle(event: MessageCommandInteractionCreateEvent) + + /** Event handler for user commands. **/ + public abstract suspend fun handle(event: UserCommandInteractionCreateEvent) + + /** Unregister a slash command. **/ + public abstract suspend fun unregister(command: SlashCommand<*, *>, delete: Boolean = true): SlashCommand<*, *>? + + /** Unregister a message command. **/ + public abstract suspend fun unregister(command: MessageCommand<*>, delete: Boolean = true): MessageCommand<*>? + + /** Unregister a user command. **/ + public abstract suspend fun unregister(command: UserCommand<*>, delete: Boolean = true): UserCommand<*>? + + // region: Utilities + + /** Unregister a generic [ApplicationCommand]. **/ + public open suspend fun unregisterGeneric( + command: ApplicationCommand<*>, + delete: Boolean = true, + ): ApplicationCommand<*>? = + when (command) { + is MessageCommand<*> -> unregister(command, delete) + is SlashCommand<*, *> -> unregister(command, delete) + is UserCommand<*> -> unregister(command, delete) + + else -> error("Unsupported application command type: ${command.type.name}") + } + + /** @suppress Internal function used to delete the given command from Discord. Used by [unregister]. **/ + public open suspend fun deleteGeneric( + command: ApplicationCommand<*>, + discordCommandId: Snowflake, + ) { + try { + if (command.guildId != null) { + kord.unsafe.guildApplicationCommand( + command.guildId!!, + kord.resources.applicationId, + discordCommandId + ).delete() + } else { + kord.unsafe.globalApplicationCommand(kord.resources.applicationId, discordCommandId).delete() + } + } catch (e: KtorRequestException) { + logger.warn(e) { + "Failed to delete ${command.type.name} command ${command.name}" + + if (e.error?.message != null) { + "\n Discord error message: ${e.error?.message}" + } else { + "" + } + } + } + } + + /** Register multiple slash commands. **/ + public open suspend fun > registerAll(vararg commands: T): List = + commands.mapNotNull { + try { + when (it) { + is SlashCommand<*, *> -> register(it) as T + is MessageCommand<*> -> register(it) as T + is UserCommand<*> -> register(it) as T + + else -> throw IllegalArgumentException( + "The registry does not know about this type of ApplicationCommand" + ) + } + } catch (e: KtorRequestException) { + logger.warn(e) { + "Failed to register ${it.type.name} command: ${it.name}" + + if (e.error?.message != null) { + "\n Discord error message: ${e.error?.message}" + } else { + "" + } + } + + null + } catch (t: Throwable) { + logger.warn(t) { "Failed to register ${it.type.name} command: ${it.name}" } + + null + } + } + + /** + * Creates a KordEx [ApplicationCommand] as discord command and returns the created command's id as [Snowflake]. + */ + public open suspend fun createDiscordCommand(command: ApplicationCommand<*>): Snowflake? = when (command) { + is SlashCommand<*, *> -> createDiscordSlashCommand(command) + is UserCommand<*> -> createDiscordUserCommand(command) + is MessageCommand<*> -> createDiscordMessageCommand(command) + + else -> throw IllegalArgumentException("Unknown ApplicationCommand type") + } + + /** + * Creates a KordEx [SlashCommand] as discord command and returns the created command's id as [Snowflake]. + */ + public open suspend fun createDiscordSlashCommand(command: SlashCommand<*, *>): Snowflake? { + val locale = bot.settings.i18nBuilder.defaultLocale + + val guild = if (command.guildId != null) { + kord.getGuild(command.guildId!!) + } else { + null + } + + val name = command.getTranslatedName(locale) + val description = command.getTranslatedDescription(locale) + + val response = if (guild == null) { + // We're registering global commands here, if the guild is null + + kord.createGlobalChatInputCommand(name, description) { + logger.trace { "Adding/updating global ${command.type.name} command: $name" } + + this.register(locale, command) + } + } else { + // We're registering guild-specific commands here, if the guild is available + + guild.createChatInputCommand(name, description) { + logger.trace { "Adding/updating guild-specific ${command.type.name} command: $name" } + + this.register(locale, command) + } + } + + injectPermissions(guild, command, response.id) ?: return null + + return response.id + } + + /** + * Creates a KordEx [UserCommand] as discord command and returns the created command's id as [Snowflake]. + */ + public open suspend fun createDiscordUserCommand(command: UserCommand<*>): Snowflake? { + val locale = bot.settings.i18nBuilder.defaultLocale + + val guild = if (command.guildId != null) { + kord.getGuild(command.guildId!!) + } else { + null + } + + val name = command.getTranslatedName(locale) + + val response = if (guild == null) { + // We're registering global commands here, if the guild is null + + kord.createGlobalUserCommand(name) { + logger.trace { "Adding/updating global ${command.type.name} command: $name" } + + this.register(locale, command) + } + } else { + // We're registering guild-specific commands here, if the guild is available + + guild.createUserCommand(name) { + logger.trace { "Adding/updating guild-specific ${command.type.name} command: $name" } + + this.register(locale, command) + } + } + + injectPermissions(guild, command, response.id) ?: return null + + return response.id + } + + /** + * Creates a KordEx [MessageCommand] as discord command and returns the created command's id as [Snowflake]. + */ + public open suspend fun createDiscordMessageCommand(command: MessageCommand<*>): Snowflake? { + val locale = bot.settings.i18nBuilder.defaultLocale + + val guild = if (command.guildId != null) { + kord.getGuild(command.guildId!!) + } else { + null + } + + val name = command.getTranslatedName(locale) + + val response = if (guild == null) { + // We're registering global commands here, if the guild is null + + kord.createGlobalMessageCommand(name) { + logger.trace { "Adding/updating global ${command.type.name} command: $name" } + + this.register(locale, command) + } + } else { + // We're registering guild-specific commands here, if the guild is available + + guild.createMessageCommand(name) { + logger.trace { "Adding/updating guild-specific ${command.type.name} command: $name" } + + this.register(locale, command) + } + } + + injectPermissions(guild, command, response.id) ?: return null + + return response.id + } + + // endregion + + // region: Permissions + + protected suspend fun > injectPermissions( + guild: Guild?, + command: T, + commandId: Snowflake + ): T? { + try { + if (guild != null) { + kord.editApplicationCommandPermissions(guild.id, commandId) { + injectRawPermissions(this, command) + } + + logger.trace { "Applied permissions for command: ${command.name} ($command)" } + } else { + logger.warn { "Applying permissions to global application commands is currently not supported." } + } + } catch (e: KtorRequestException) { + logger.error(e) { + "Failed to apply application command permissions. This command will not be registered." + + if (e.error?.message != null) { + "\n Discord error message: ${e.error?.message}" + } else { + "" + } + } + } catch (t: Throwable) { + logger.error(t) { + "Failed to apply application command permissions. This command will not be registered." + } + + return null + } + return command + } + + protected fun injectRawPermissions( + builder: ApplicationCommandPermissionsModifyBuilder, + command: ApplicationCommand<*> + ) { + command.allowedUsers.map { builder.user(it, true) } + command.disallowedUsers.map { builder.user(it, false) } + + command.allowedRoles.map { builder.role(it, true) } + command.disallowedRoles.map { builder.role(it, false) } + } + + // endregion + + // region: Extensions + + /** Registration logic for slash commands, extracted for clarity. **/ + public open suspend fun ChatInputCreateBuilder.register(locale: Locale, command: SlashCommand<*, *>) { + this.defaultPermission = command.guildId == null || command.allowByDefault + + if (command.hasBody) { + val args = command.arguments?.invoke() + + if (args != null) { + args.args.forEach { arg -> + val converter = arg.converter + + if (converter !is SlashCommandConverter) { + error("Argument ${arg.displayName} does not support slash commands.") + } + + if (this.options == null) this.options = mutableListOf() + + val option = converter.toSlashOption(arg) + + option.name = translationsProvider + .translate(option.name, locale, converter.bundle) + .lowercase() + + this.options!! += option + } + } + } else { + command.subCommands.forEach { + val args = it.arguments?.invoke()?.args?.map { arg -> + val converter = arg.converter + + if (converter !is SlashCommandConverter) { + error("Argument ${arg.displayName} does not support slash commands.") + } + + val option = converter.toSlashOption(arg) + + option.name = translationsProvider + .translate(option.name, locale, converter.bundle) + .lowercase() + + option + } + + this.subCommand( + it.name, + it.getTranslatedDescription(locale) + ) { + if (args != null) { + if (this.options == null) this.options = mutableListOf() + + this.options!!.addAll(args) + } + } + } + + command.groups.values.forEach { group -> + this.group(group.name, group.getTranslatedDescription(locale)) { + group.subCommands.forEach { + val args = it.arguments?.invoke()?.args?.map { arg -> + val converter = arg.converter + + if (converter !is SlashCommandConverter) { + error("Argument ${arg.displayName} does not support slash commands.") + } + + val option = converter.toSlashOption(arg) + + option.name = translationsProvider + .translate(option.name, locale, converter.bundle) + .lowercase() + + converter.toSlashOption(arg) + } + + this.subCommand( + it.name, + it.getTranslatedDescription(locale) + ) { + if (args != null) { + if (this.options == null) this.options = mutableListOf() + + this.options!!.addAll(args) + } + } + } + } + } + } + } + + /** Registration logic for message commands, extracted for clarity. **/ + @Suppress("UnusedPrivateMember") // Only for now... + public open fun MessageCommandCreateBuilder.register(locale: Locale, command: MessageCommand<*>) { + this.defaultPermission = command.guildId == null || command.allowByDefault + } + + /** Registration logic for user commands, extracted for clarity. **/ + @Suppress("UnusedPrivateMember") // Only for now... + public open fun UserCommandCreateBuilder.register(locale: Locale, command: UserCommand<*>) { + this.defaultPermission = command.guildId == null || command.allowByDefault + } + + /** Check whether the type and name of an extension-registered application command matches a Discord one. **/ + public open fun ApplicationCommand<*>.matches( + locale: Locale, + other: dev.kord.core.entity.application.ApplicationCommand + ): Boolean = type == other.type && getTranslatedName(locale).equals(other.name, true) + + // endregion +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/DefaultApplicationCommandRegistry.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/DefaultApplicationCommandRegistry.kt new file mode 100644 index 0000000000..49dee9d56e --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/DefaultApplicationCommandRegistry.kt @@ -0,0 +1,387 @@ +@file:Suppress( + "TooGenericExceptionCaught", + "StringLiteralDuplication", + "AnnotationSpacing", + "SpacingBetweenAnnotations" +) + +package com.kotlindiscord.kord.extensions.commands.application + +import com.kotlindiscord.kord.extensions.commands.application.message.MessageCommand +import com.kotlindiscord.kord.extensions.commands.application.slash.SlashCommand +import com.kotlindiscord.kord.extensions.commands.application.user.UserCommand +import dev.kord.common.entity.Snowflake +import dev.kord.core.behavior.createApplicationCommands +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent +import dev.kord.core.event.interaction.MessageCommandInteractionCreateEvent +import dev.kord.core.event.interaction.UserCommandInteractionCreateEvent +import dev.kord.rest.json.JsonErrorCode +import dev.kord.rest.request.KtorRequestException +import kotlinx.coroutines.flow.toList + +/** Registry for all Discord application commands. **/ +public open class DefaultApplicationCommandRegistry : ApplicationCommandRegistry() { + + /** Mapping of Discord-side command ID to a message command object. **/ + public open val messageCommands: MutableMap> = mutableMapOf() + + /** Mapping of Discord-side command ID to a slash command object. **/ + public open val slashCommands: MutableMap> = mutableMapOf() + + /** Mapping of Discord-side command ID to a user command object. **/ + public open val userCommands: MutableMap> = mutableMapOf() + + public override suspend fun initialize(commands: List>) { + if (!bot.settings.applicationCommandsBuilder.register) { + logger.debug { + "Application command registration is disabled, pairing existing commands with extension commands" + } + } + + try { + syncAll(true, commands) + } catch (t: Throwable) { + logger.error(t) { "Failed to synchronise application commands" } + } + } + + // region: Untyped sync functions + + /** Register multiple generic application commands. **/ + public open suspend fun syncAll(removeOthers: Boolean = false, commands: List>) { + val groupedCommands = commands.groupBy { it.guildId }.toMutableMap() + + if (removeOthers && !groupedCommands.containsKey(null)) { + groupedCommands[null] = listOf() + } + + groupedCommands.forEach { + try { + sync(removeOthers, it.key, it.value) + } catch (e: KtorRequestException) { + logger.error(e) { + var message = if (it.key == null) { + "Failed to synchronise global application commands" + } else { + "Failed to synchronise application commands for guild with ID: ${it.key!!.asString}" + } + + if (e.error?.message != null) { + message += "\n Discord error message: ${e.error?.message}" + } + + if (e.error?.code == JsonErrorCode.MissingAccess) { + message += "\n Double-check that the bot was added to this guild with the " + + "`application.commands` scope enabled" + } + + message + } + } catch (t: Throwable) { + logger.error(t) { + if (it.key == null) { + "Failed to synchronise global application commands" + } else { + "Failed to synchronise application commands for guild with ID: ${it.key!!.asString}" + } + } + } + } + + val commandsWithPerms = (messageCommands + slashCommands + userCommands) + .filterValues { + it.allowedRoles.isNotEmpty() || + it.allowedUsers.isNotEmpty() || + it.disallowedRoles.isNotEmpty() || + it.disallowedUsers.isNotEmpty() || + !it.allowByDefault + } + .toList() + .groupBy { it.second.guildId } + + try { + commandsWithPerms.forEach { (guildId, commands) -> + if (guildId != null) { + kord.bulkEditApplicationCommandPermissions(guildId) { + commands.forEach { (id, commandObj) -> + command(id) { injectRawPermissions(this, commandObj) } + } + } + + logger.trace { "Applied permissions for ${commands.size} commands." } + } else { + logger.warn { "Applying permissions to global application commands is currently not supported." } + } + } + } catch (e: KtorRequestException) { + logger.error(e) { + "Failed to apply application command permissions - for this reason, all commands with configured" + + "permissions will be disabled." + + if (e.error?.message != null) { + "\n Discord error message: ${e.error?.message}" + } else { + "" + } + } + } catch (t: Throwable) { + logger.error(t) { + "Failed to apply application command permissions - for this reason, all commands with configured" + + "permissions will be disabled." + } + + commandsWithPerms.forEach { (_, commands) -> + commands.forEach { (id, _) -> + messageCommands.remove(id) + slashCommands.remove(id) + userCommands.remove(id) + } + } + } + } + + /** Register multiple generic application commands. **/ + public open suspend fun sync( + removeOthers: Boolean = false, + guildId: Snowflake?, + commands: List> + ) { + // NOTE: Someday, discord will make real i18n possible, we hope... + val locale = bot.settings.i18nBuilder.defaultLocale + + val guild = if (guildId != null) { + kord.getGuild(guildId) + ?: return logger.debug { + "Cannot register application commands for guild ID ${guildId.asString}, " + + "as it seems to be missing." + } + } else { + null + } + + // Get guild commands if we're registering them (guild != null), otherwise get global commands + val registered = guild?.commands?.toList() + ?: kord.globalCommands.toList() + + if (!bot.settings.applicationCommandsBuilder.register) { + commands.forEach { commandObj -> + val existingCommand = registered.firstOrNull { commandObj.matches(locale, it) } + + if (existingCommand != null) { + when (commandObj) { + is MessageCommand<*> -> messageCommands[existingCommand.id] = commandObj + is SlashCommand<*, *> -> slashCommands[existingCommand.id] = commandObj + is UserCommand<*> -> userCommands[existingCommand.id] = commandObj + } + } + } + + return // We're only syncing them up, there's no other API work to do + } + + // Extension commands that haven't been registered yet + val toAdd = commands.filter { aC -> registered.all { dC -> !aC.matches(locale, dC) } } + + // Extension commands that were previously registered + val toUpdate = commands.filter { aC -> registered.any { dC -> aC.matches(locale, dC) } } + + // Registered Discord commands that haven't been provided by extensions + val toRemove = if (removeOthers) { + registered.filter { dC -> commands.all { aC -> !aC.matches(locale, dC) } } + } else { + listOf() + } + + logger.info { + var message = if (guild == null) { + "Global application commands: ${toAdd.size} to add / " + + "${toUpdate.size} to update / " + + "${toRemove.size} to remove" + } else { + "Application commands for guild ${guild.name}: ${toAdd.size} to add / " + + "${toUpdate.size} to update / " + + "${toRemove.size} to remove" + } + + if (!removeOthers) { + message += "\nThe `removeOthers` parameter is `false`, so no commands will be removed." + } + + message + } + + val toCreate = toAdd + toUpdate + + @Suppress("IfThenToElvis") // Ultimately, this is far more readable + val response = if (guild == null) { + // We're registering global commands here, if the guild is null + + kord.createGlobalApplicationCommands { + toCreate.forEach { + val name = it.getTranslatedName(locale) + + logger.trace { "Adding/updating global ${it.type.name} command: $name" } + + when (it) { + is MessageCommand<*> -> message(name) { this.register(locale, it) } + is UserCommand<*> -> user(name) { this.register(locale, it) } + + is SlashCommand<*, *> -> input( + name, it.getTranslatedDescription(locale) + ) { this.register(locale, it) } + } + } + }.toList() + } else { + // We're registering guild-specific commands here, if the guild is available + + guild.createApplicationCommands { + toCreate.forEach { + val name = it.getTranslatedName(locale) + + logger.trace { "Adding/updating guild-specific ${it.type.name} command: $name" } + + when (it) { + is MessageCommand<*> -> message(name) { this.register(locale, it) } + is UserCommand<*> -> user(name) { this.register(locale, it) } + + is SlashCommand<*, *> -> input( + name, it.getTranslatedDescription(locale) + ) { this.register(locale, it) } + } + } + }.toList() + } + + // Next, we need to associate all the commands we just registered with the commands in our extensions + toCreate.forEach { command -> + val match = response.first { command.matches(locale, it) } + + when (command) { + is MessageCommand<*> -> messageCommands[match.id] = command + is SlashCommand<*, *> -> slashCommands[match.id] = command + is UserCommand<*> -> userCommands[match.id] = command + } + } + + // Finally, we can remove anything that needs to be removed + toRemove.forEach { + logger.trace { "Removing ${it.type.name} command: ${it.name}" } + it.delete() + } + + logger.info { + if (guild == null) { + "Finished synchronising global application commands" + } else { + "Finished synchronising application commands for guild ${guild.name}" + } + } + } + + // endregion + + // region: Typed registration functions + + /** Register a message command. **/ + public override suspend fun register(command: MessageCommand<*>): MessageCommand<*>? { + val commandId = createDiscordCommand(command) ?: return null + + messageCommands[commandId] = command + + return command + } + + /** Register a slash command. **/ + public override suspend fun register(command: SlashCommand<*, *>): SlashCommand<*, *>? { + val commandId = createDiscordCommand(command) ?: return null + + slashCommands[commandId] = command + + return command + } + + /** Register a user command. **/ + public override suspend fun register(command: UserCommand<*>): UserCommand<*>? { + val commandId = createDiscordCommand(command) ?: return null + + userCommands[commandId] = command + + return command + } + + // endregion + + // region: Unregistration functions + + /** Unregister a message command. **/ + public override suspend fun unregister(command: MessageCommand<*>, delete: Boolean): MessageCommand<*>? { + val filtered = messageCommands.filter { it.value == command } + val id = filtered.keys.firstOrNull() ?: return null + + if (delete) { + deleteGeneric(command, id) + } + + return messageCommands.remove(id) + } + + /** Unregister a slash command. **/ + public override suspend fun unregister(command: SlashCommand<*, *>, delete: Boolean): SlashCommand<*, *>? { + val filtered = slashCommands.filter { it.value == command } + val id = filtered.keys.firstOrNull() ?: return null + + if (delete) { + deleteGeneric(command, id) + } + + return slashCommands.remove(id) + } + + /** Unregister a user command. **/ + public override suspend fun unregister(command: UserCommand<*>, delete: Boolean): UserCommand<*>? { + val filtered = userCommands.filter { it.value == command } + val id = filtered.keys.firstOrNull() ?: return null + + if (delete) { + deleteGeneric(command, id) + } + + return userCommands.remove(id) + } + + // endregion + + // region: Event handlers + + /** Event handler for message commands. **/ + public override suspend fun handle(event: MessageCommandInteractionCreateEvent) { + val commandId = event.interaction.invokedCommandId + val command = messageCommands[commandId] + + command ?: return logger.warn { "Received interaction for unknown message command: ${commandId.asString}" } + + command.call(event) + } + + /** Event handler for slash commands. **/ + public override suspend fun handle(event: ChatInputCommandInteractionCreateEvent) { + val commandId = event.interaction.command.rootId + val command = slashCommands[commandId] + + command ?: return logger.warn { "Received interaction for unknown slash command: ${commandId.asString}" } + + command.call(event) + } + + /** Event handler for user commands. **/ + public override suspend fun handle(event: UserCommandInteractionCreateEvent) { + val commandId = event.interaction.invokedCommandId + val command = userCommands[commandId] + + command ?: return logger.warn { "Received interaction for unknown user command: ${commandId.asString}" } + + command.call(event) + } + + // endregion +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/StorageAwareApplicationCommandRegistry.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/StorageAwareApplicationCommandRegistry.kt new file mode 100644 index 0000000000..62052996b5 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/StorageAwareApplicationCommandRegistry.kt @@ -0,0 +1,138 @@ +@file:Suppress( + "UNCHECKED_CAST" +) +@file:OptIn( + ExperimentalCoroutinesApi::class +) + +package com.kotlindiscord.kord.extensions.commands.application + +import com.kotlindiscord.kord.extensions.commands.application.message.MessageCommand +import com.kotlindiscord.kord.extensions.commands.application.slash.SlashCommand +import com.kotlindiscord.kord.extensions.commands.application.user.UserCommand +import com.kotlindiscord.kord.extensions.registry.RegistryStorage +import dev.kord.common.entity.Snowflake +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent +import dev.kord.core.event.interaction.MessageCommandInteractionCreateEvent +import dev.kord.core.event.interaction.UserCommandInteractionCreateEvent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.toList + +/** + * [ApplicationCommandRegistry] which acts based off a specified storage interface. + * + * Discord lifecycles may not be implemented in this class and require manual updating. + */ +public open class StorageAwareApplicationCommandRegistry( + builder: () -> RegistryStorage> +) : ApplicationCommandRegistry() { + + protected open val commandRegistry: RegistryStorage> = builder.invoke() + + override suspend fun initialize(commands: List>) { + commands.forEach { commandRegistry.register(it) } + + val registeredCommands = commandRegistry.entryFlow().toList() + + commands.forEach { command -> + if (registeredCommands.none { it.hasCommand(command) }) { + val commandId = createDiscordCommand(command) + + commandId?.let { + commandRegistry.set(it, command) + } + } + } + } + + override suspend fun register(command: SlashCommand<*, *>): SlashCommand<*, *>? { + val commandId = createDiscordCommand(command) ?: return null + + commandRegistry.set(commandId, command) + + return command + } + + override suspend fun register(command: MessageCommand<*>): MessageCommand<*>? { + val commandId = createDiscordCommand(command) ?: return null + + commandRegistry.set(commandId, command) + + return command + } + + override suspend fun register(command: UserCommand<*>): UserCommand<*>? { + val commandId = createDiscordCommand(command) ?: return null + + commandRegistry.set(commandId, command) + + return command + } + + override suspend fun handle(event: ChatInputCommandInteractionCreateEvent) { + val commandId = event.interaction.invokedCommandId + val command = commandRegistry.get(commandId) as? SlashCommand<*, *> + + command ?: return logger.warn { "Received interaction for unknown slash command: ${commandId.asString}" } + + command.call(event) + } + + override suspend fun handle(event: MessageCommandInteractionCreateEvent) { + val commandId = event.interaction.invokedCommandId + val command = commandRegistry.get(commandId) as? MessageCommand<*> + + command ?: return logger.warn { "Received interaction for unknown message command: ${commandId.asString}" } + + command.call(event) + } + + override suspend fun handle(event: UserCommandInteractionCreateEvent) { + val commandId = event.interaction.invokedCommandId + val command = commandRegistry.get(commandId) as? UserCommand<*> + + command ?: return logger.warn { "Received interaction for unknown user command: ${commandId.asString}" } + + command.call(event) + } + + override suspend fun unregister(command: SlashCommand<*, *>, delete: Boolean): SlashCommand<*, *>? = + unregisterApplicationCommand(command, delete) as? SlashCommand<*, *> + + override suspend fun unregister(command: MessageCommand<*>, delete: Boolean): MessageCommand<*>? = + unregisterApplicationCommand(command, delete) as? MessageCommand<*> + + override suspend fun unregister(command: UserCommand<*>, delete: Boolean): UserCommand<*>? = + unregisterApplicationCommand(command, delete) as? UserCommand<*> + + protected open suspend fun unregisterApplicationCommand( + command: ApplicationCommand<*>, + delete: Boolean + ): ApplicationCommand<*>? { + val id = commandRegistry.constructUniqueIdentifier(command) + + val snowflake = commandRegistry.entryFlow() + .firstOrNull { commandRegistry.constructUniqueIdentifier(it.value) == id } + ?.key + + snowflake?.let { + if (delete) { + deleteGeneric(command, it) + } + + return commandRegistry.remove(it) + } + + return null + } + + protected open fun RegistryStorage.StorageEntry>.hasCommand( + command: ApplicationCommand<*> + ): Boolean { + val key = commandRegistry.constructUniqueIdentifier(value) + val other = commandRegistry.constructUniqueIdentifier(command) + + return key == other + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/EphemeralMessageCommand.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/EphemeralMessageCommand.kt new file mode 100644 index 0000000000..5221baf95f --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/EphemeralMessageCommand.kt @@ -0,0 +1,104 @@ +@file:Suppress("TooGenericExceptionCaught") + +package com.kotlindiscord.kord.extensions.commands.application.message + +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.events.EphemeralMessageCommandFailedChecksEvent +import com.kotlindiscord.kord.extensions.commands.events.EphemeralMessageCommandFailedWithExceptionEvent +import com.kotlindiscord.kord.extensions.commands.events.EphemeralMessageCommandInvocationEvent +import com.kotlindiscord.kord.extensions.commands.events.EphemeralMessageCommandSucceededEvent +import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.types.FailureReason +import com.kotlindiscord.kord.extensions.types.respond +import dev.kord.core.behavior.interaction.respondEphemeral +import dev.kord.core.event.interaction.MessageCommandInteractionCreateEvent +import dev.kord.rest.builder.message.create.InteractionResponseCreateBuilder + +public typealias InitialEphemeralMessageResponseBuilder = + (suspend InteractionResponseCreateBuilder.(MessageCommandInteractionCreateEvent) -> Unit)? + +/** Ephemeral message command. **/ +public class EphemeralMessageCommand( + extension: Extension +) : MessageCommand(extension) { + /** @suppress Internal guilder **/ + public var initialResponseBuilder: InitialEphemeralMessageResponseBuilder = null + + /** Call this to open with a response, omit it to ack instead. **/ + public fun initialResponse(body: InitialEphemeralMessageResponseBuilder) { + initialResponseBuilder = body + } + + override suspend fun call(event: MessageCommandInteractionCreateEvent) { + emitEventAsync(EphemeralMessageCommandInvocationEvent(this, event)) + + try { + if (!runChecks(event)) { + emitEventAsync( + EphemeralMessageCommandFailedChecksEvent( + this, + event, + "Checks failed without a message." + ) + ) + + return + } + } catch (e: DiscordRelayedException) { + event.interaction.respondEphemeral { + settings.failureResponseBuilder(this, e.reason, FailureReason.ProvidedCheckFailure(e)) + } + + emitEventAsync(EphemeralMessageCommandFailedChecksEvent(this, event, e.reason)) + + return + } + + val response = if (initialResponseBuilder != null) { + event.interaction.respondEphemeral { initialResponseBuilder!!(event) } + } else { + event.interaction.acknowledgeEphemeral() + } + + val context = EphemeralMessageCommandContext(event, this, response) + + context.populate() + + firstSentryBreadcrumb(context) + + try { + checkBotPerms(context) + } catch (e: DiscordRelayedException) { + respondText(context, e.reason, FailureReason.OwnPermissionsCheckFailure(e)) + emitEventAsync(EphemeralMessageCommandFailedChecksEvent(this, event, e.reason)) + + return + } + + try { + body(context) + } catch (t: Throwable) { + emitEventAsync(EphemeralMessageCommandFailedWithExceptionEvent(this, event, t)) + + if (t is DiscordRelayedException) { + respondText(context, t.reason, FailureReason.RelayedFailure(t)) + + return + } + + handleError(context, t) + + return + } + + emitEventAsync(EphemeralMessageCommandSucceededEvent(this, event)) + } + + override suspend fun respondText( + context: EphemeralMessageCommandContext, + message: String, + failureType: FailureReason<*> + ) { + context.respond { settings.failureResponseBuilder(this, message, failureType) } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/EphemeralMessageCommandContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/EphemeralMessageCommandContext.kt new file mode 100644 index 0000000000..b59fc7d4c7 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/EphemeralMessageCommandContext.kt @@ -0,0 +1,12 @@ +package com.kotlindiscord.kord.extensions.commands.application.message + +import com.kotlindiscord.kord.extensions.types.EphemeralInteractionContext +import dev.kord.core.behavior.interaction.EphemeralInteractionResponseBehavior +import dev.kord.core.event.interaction.MessageCommandInteractionCreateEvent + +/** Ephemeral-only message command context. **/ +public class EphemeralMessageCommandContext( + override val event: MessageCommandInteractionCreateEvent, + override val command: MessageCommand, + override val interactionResponse: EphemeralInteractionResponseBehavior +) : MessageCommandContext(event, command), EphemeralInteractionContext diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/MessageCommand.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/MessageCommand.kt new file mode 100644 index 0000000000..6542683ccd --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/MessageCommand.kt @@ -0,0 +1,188 @@ +package com.kotlindiscord.kord.extensions.commands.application.message + +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.InvalidCommandException +import com.kotlindiscord.kord.extensions.checks.types.CheckContext +import com.kotlindiscord.kord.extensions.commands.application.ApplicationCommand +import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.sentry.BreadcrumbType +import com.kotlindiscord.kord.extensions.sentry.tag +import com.kotlindiscord.kord.extensions.sentry.user +import com.kotlindiscord.kord.extensions.types.FailureReason +import com.kotlindiscord.kord.extensions.utils.getLocale +import com.kotlindiscord.kord.extensions.utils.permissionsForMember +import com.kotlindiscord.kord.extensions.utils.translate +import dev.kord.common.entity.ApplicationCommandType +import dev.kord.core.entity.channel.DmChannel +import dev.kord.core.entity.channel.GuildChannel +import dev.kord.core.entity.channel.GuildMessageChannel +import dev.kord.core.event.interaction.MessageCommandInteractionCreateEvent +import mu.KLogger +import mu.KotlinLogging + +/** Message context command, for right-click actions on messages. **/ +public abstract class MessageCommand>( + extension: Extension +) : ApplicationCommand(extension) { + private val logger: KLogger = KotlinLogging.logger {} + + /** Command body, to be called when the command is executed. **/ + public lateinit var body: suspend C.() -> Unit + + override val type: ApplicationCommandType = ApplicationCommandType.Message + + /** Call this to supply a command [body], to be called when the command is executed. **/ + public fun action(action: suspend C.() -> Unit) { + body = action + } + + override fun validate() { + super.validate() + + if (!::body.isInitialized) { + throw InvalidCommandException(name, "No command body given.") + } + } + + /** Override this to implement your command's calling logic. Check subtypes for examples! **/ + public abstract override suspend fun call(event: MessageCommandInteractionCreateEvent) + + /** Override this to implement a way to respond to the user, regardless of whatever happens. **/ + public abstract suspend fun respondText(context: C, message: String, failureType: FailureReason<*>) + + /** Checks whether the bot has the specified required permissions, throwing if it doesn't. **/ + @Throws(DiscordRelayedException::class) + public open suspend fun checkBotPerms(context: C) { + if (requiredPerms.isEmpty()) { + return // Nothing to check, don't try to hit the cache + } + + if (context.guild != null) { + val perms = (context.channel.asChannel() as GuildChannel) + .permissionsForMember(kord.selfId) + + val missingPerms = requiredPerms.filter { !perms.contains(it) } + + if (missingPerms.isNotEmpty()) { + throw DiscordRelayedException( + context.translate( + "commands.error.missingBotPermissions", + null, + + replacements = arrayOf( + missingPerms.map { it.translate(context.getLocale()) }.joinToString(", ") + ) + ) + ) + } + } + } + + /** If enabled, adds the initial Sentry breadcrumb to the given context. **/ + public open suspend fun firstSentryBreadcrumb(context: C) { + if (sentry.enabled) { + context.sentry.breadcrumb(BreadcrumbType.User) { + category = "command.application.message" + message = "Message command \"$name\" called." + + val channel = context.channel.asChannelOrNull() + val guild = context.guild?.asGuildOrNull() + + data["command"] = name + + if (guildId != null) { + data["command.guild"] = guildId!!.asString + } + + if (channel != null) { + data["channel"] = when (channel) { + is DmChannel -> "Private Message (${channel.id.asString})" + is GuildMessageChannel -> "#${channel.name} (${channel.id.asString})" + + else -> channel.id.asString + } + } + + if (guild != null) { + data["guild"] = "${guild.name} (${guild.id.asString})" + } + } + } + } + + override suspend fun runChecks(event: MessageCommandInteractionCreateEvent): Boolean { + val locale = event.getLocale() + val result = super.runChecks(event) + + if (result) { + settings.applicationCommandsBuilder.messageCommandChecks.forEach { check -> + val context = CheckContext(event, locale) + + check(context) + + if (!context.passed) { + context.throwIfFailedWithMessage() + + return false + } + } + + extension.messageCommandChecks.forEach { check -> + val context = CheckContext(event, locale) + + check(context) + + if (!context.passed) { + context.throwIfFailedWithMessage() + + return false + } + } + } + + return result + } + + /** A general way to handle errors thrown during the course of a command's execution. **/ + public open suspend fun handleError(context: C, t: Throwable) { + logger.error(t) { "Error during execution of $name message command (${context.event})" } + + if (sentry.enabled) { + logger.trace { "Submitting error to sentry." } + + val channel = context.channel + val author = context.user.asUserOrNull() + + val sentryId = context.sentry.captureException(t, "Message command execution failed.") { + if (author != null) { + user(author) + } + + tag("private", "false") + + if (channel is DmChannel) { + tag("private", "true") + } + + tag("command", name) + tag("extension", extension.name) + } + + logger.info { "Error submitted to Sentry: $sentryId" } + + val errorMessage = if (extension.bot.extensions.containsKey("sentry")) { + context.translate("commands.error.user.sentry.slash", null, replacements = arrayOf(sentryId)) + } else { + context.translate("commands.error.user", null) + } + + respondText(context, errorMessage, FailureReason.ExecutionError(t)) + } else { + respondText( + context, + context.translate("commands.error.user", null), + FailureReason.ExecutionError(t) + ) + } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/MessageCommandContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/MessageCommandContext.kt new file mode 100644 index 0000000000..75790702e3 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/MessageCommandContext.kt @@ -0,0 +1,19 @@ +package com.kotlindiscord.kord.extensions.commands.application.message + +import com.kotlindiscord.kord.extensions.commands.application.ApplicationCommandContext +import dev.kord.core.entity.Message +import dev.kord.core.event.interaction.MessageCommandInteractionCreateEvent + +/** + * Message command context, containing everything you need for your message command's execution. + * + * @param event Event that triggered this message command. + * @param command Message command instance. + */ +public abstract class MessageCommandContext>( + public open val event: MessageCommandInteractionCreateEvent, + public override val command: MessageCommand, +) : ApplicationCommandContext(event, command) { + /** Messages that this message command is being executed against. **/ + public val targetMessages: Collection by lazy { event.interaction.messages?.values ?: listOf() } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/PublicMessageCommand.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/PublicMessageCommand.kt new file mode 100644 index 0000000000..67be2de1f2 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/PublicMessageCommand.kt @@ -0,0 +1,105 @@ +@file:Suppress("TooGenericExceptionCaught") + +package com.kotlindiscord.kord.extensions.commands.application.message + +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.events.PublicMessageCommandFailedChecksEvent +import com.kotlindiscord.kord.extensions.commands.events.PublicMessageCommandFailedWithExceptionEvent +import com.kotlindiscord.kord.extensions.commands.events.PublicMessageCommandInvocationEvent +import com.kotlindiscord.kord.extensions.commands.events.PublicMessageCommandSucceededEvent +import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.types.FailureReason +import com.kotlindiscord.kord.extensions.types.respond +import dev.kord.core.behavior.interaction.respondEphemeral +import dev.kord.core.behavior.interaction.respondPublic +import dev.kord.core.event.interaction.MessageCommandInteractionCreateEvent +import dev.kord.rest.builder.message.create.InteractionResponseCreateBuilder + +public typealias InitialPublicMessageResponseBuilder = + (suspend InteractionResponseCreateBuilder.(MessageCommandInteractionCreateEvent) -> Unit)? + +/** Public message command. **/ +public class PublicMessageCommand( + extension: Extension +) : MessageCommand(extension) { + /** @suppress Internal guilder **/ + public var initialResponseBuilder: InitialPublicMessageResponseBuilder = null + + /** Call this to open with a response, omit it to ack instead. **/ + public fun initialResponse(body: InitialPublicMessageResponseBuilder) { + initialResponseBuilder = body + } + + override suspend fun call(event: MessageCommandInteractionCreateEvent) { + emitEventAsync(PublicMessageCommandInvocationEvent(this, event)) + + try { + if (!runChecks(event)) { + emitEventAsync( + PublicMessageCommandFailedChecksEvent( + this, + event, + "Checks failed without a message." + ) + ) + + return + } + } catch (e: DiscordRelayedException) { + event.interaction.respondEphemeral { + settings.failureResponseBuilder(this, e.reason, FailureReason.ProvidedCheckFailure(e)) + } + + emitEventAsync(PublicMessageCommandFailedChecksEvent(this, event, e.reason)) + + return + } + + val response = if (initialResponseBuilder != null) { + event.interaction.respondPublic { initialResponseBuilder!!(event) } + } else { + event.interaction.acknowledgePublic() + } + + val context = PublicMessageCommandContext(event, this, response) + + context.populate() + + firstSentryBreadcrumb(context) + + try { + checkBotPerms(context) + } catch (e: DiscordRelayedException) { + respondText(context, e.reason, FailureReason.OwnPermissionsCheckFailure(e)) + emitEventAsync(PublicMessageCommandFailedChecksEvent(this, event, e.reason)) + + return + } + + try { + body(context) + } catch (t: Throwable) { + emitEventAsync(PublicMessageCommandFailedWithExceptionEvent(this, event, t)) + + if (t is DiscordRelayedException) { + respondText(context, t.reason, FailureReason.RelayedFailure(t)) + + return + } + + handleError(context, t) + + return + } + + emitEventAsync(PublicMessageCommandSucceededEvent(this, event)) + } + + override suspend fun respondText( + context: PublicMessageCommandContext, + message: String, + failureType: FailureReason<*> + ) { + context.respond { settings.failureResponseBuilder(this, message, failureType) } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/PublicMessageCommandContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/PublicMessageCommandContext.kt new file mode 100644 index 0000000000..5d20eb180e --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/message/PublicMessageCommandContext.kt @@ -0,0 +1,12 @@ +package com.kotlindiscord.kord.extensions.commands.application.message + +import com.kotlindiscord.kord.extensions.types.PublicInteractionContext +import dev.kord.core.behavior.interaction.PublicInteractionResponseBehavior +import dev.kord.core.event.interaction.MessageCommandInteractionCreateEvent + +/** Public-only message command context. **/ +public class PublicMessageCommandContext( + override val event: MessageCommandInteractionCreateEvent, + override val command: MessageCommand, + override val interactionResponse: PublicInteractionResponseBehavior +) : MessageCommandContext(event, command), PublicInteractionContext diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/EphemeralSlashCommand.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/EphemeralSlashCommand.kt new file mode 100644 index 0000000000..5b806350c5 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/EphemeralSlashCommand.kt @@ -0,0 +1,160 @@ +@file:Suppress("TooGenericExceptionCaught") + +package com.kotlindiscord.kord.extensions.commands.application.slash + +import com.kotlindiscord.kord.extensions.ArgumentParsingException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.events.* +import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.types.FailureReason +import com.kotlindiscord.kord.extensions.types.respond +import dev.kord.core.behavior.interaction.respondEphemeral +import dev.kord.core.entity.interaction.GroupCommand +import dev.kord.core.entity.interaction.SubCommand +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent +import dev.kord.rest.builder.message.create.InteractionResponseCreateBuilder + +public typealias InitialEphemeralSlashResponseBuilder = + (suspend InteractionResponseCreateBuilder.(ChatInputCommandInteractionCreateEvent) -> Unit)? + +/** Ephemeral slash command. **/ +public class EphemeralSlashCommand( + extension: Extension, + + public override val arguments: (() -> A)? = null, + public override val parentCommand: SlashCommand<*, *>? = null, + public override val parentGroup: SlashGroup? = null +) : SlashCommand, A>(extension) { + /** @suppress Internal guilder **/ + public var initialResponseBuilder: InitialEphemeralSlashResponseBuilder = null + + /** Call this to open with a response, omit it to ack instead. **/ + public fun initialResponse(body: InitialEphemeralSlashResponseBuilder) { + initialResponseBuilder = body + } + + override suspend fun call(event: ChatInputCommandInteractionCreateEvent) { + val eventCommand = event.interaction.command + + val commandObj: SlashCommand<*, *> = when (eventCommand) { + is SubCommand -> { + val firstSubCommandKey = eventCommand.name + + this.subCommands.firstOrNull { it.name == firstSubCommandKey } + ?: error("Unknown subcommand: $firstSubCommandKey") + } + + is GroupCommand -> { + val firstEventGroupKey = eventCommand.groupName + val group = this.groups[firstEventGroupKey] ?: error("Unknown command group: $firstEventGroupKey") + val firstSubCommandKey = eventCommand.name + + group.subCommands.firstOrNull { it.name == firstSubCommandKey } + ?: error("Unknown subcommand: $firstSubCommandKey") + } + + else -> this + } + + commandObj.run(event) + } + + override suspend fun run(event: ChatInputCommandInteractionCreateEvent) { + emitEventAsync(EphemeralSlashCommandInvocationEvent(this, event)) + + try { + if (!runChecks(event)) { + emitEventAsync( + EphemeralSlashCommandFailedChecksEvent( + this, + event, + "Checks failed without a message." + ) + ) + + return + } + } catch (e: DiscordRelayedException) { + event.interaction.respondEphemeral { + settings.failureResponseBuilder(this, e.reason, FailureReason.ProvidedCheckFailure(e)) + } + + emitEventAsync( + EphemeralSlashCommandFailedChecksEvent( + this, + event, + e.reason + ) + ) + + return + } + + val response = if (initialResponseBuilder != null) { + event.interaction.respondEphemeral { initialResponseBuilder!!(event) } + } else { + event.interaction.acknowledgeEphemeral() + } + + val context = EphemeralSlashCommandContext(event, this, response) + + context.populate() + + firstSentryBreadcrumb(context, this) + + try { + checkBotPerms(context) + } catch (e: DiscordRelayedException) { + respondText(context, e.reason, FailureReason.OwnPermissionsCheckFailure(e)) + + emitEventAsync( + EphemeralSlashCommandFailedChecksEvent( + this, + event, + e.reason + ) + ) + + return + } + if (arguments != null) { + try { + val args = registry.argumentParser.parse(arguments, context) + + context.populateArgs(args) + } catch (e: ArgumentParsingException) { + respondText(context, e.reason, FailureReason.ArgumentParsingFailure(e)) + emitEventAsync(EphemeralSlashCommandFailedParsingEvent(this, event, e)) + + return + } + } + + try { + body(context) + } catch (t: Throwable) { + emitEventAsync(EphemeralSlashCommandFailedWithExceptionEvent(this, event, t)) + + if (t is DiscordRelayedException) { + respondText(context, t.reason, FailureReason.RelayedFailure(t)) + + return + } + + handleError(context, t, this) + + return + } + + emitEventAsync(EphemeralSlashCommandSucceededEvent(this, event)) + } + + override suspend fun respondText( + context: EphemeralSlashCommandContext, + message: String, + failureType: FailureReason<*> + ) { + context.respond { settings.failureResponseBuilder(this, message, failureType) } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/EphemeralSlashCommandContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/EphemeralSlashCommandContext.kt new file mode 100644 index 0000000000..bd778257dd --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/EphemeralSlashCommandContext.kt @@ -0,0 +1,13 @@ +package com.kotlindiscord.kord.extensions.commands.application.slash + +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.types.EphemeralInteractionContext +import dev.kord.core.behavior.interaction.EphemeralInteractionResponseBehavior +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent + +/** Ephemeral-only slash command context. **/ +public class EphemeralSlashCommandContext( + override val event: ChatInputCommandInteractionCreateEvent, + override val command: SlashCommand, A>, + override val interactionResponse: EphemeralInteractionResponseBehavior +) : SlashCommandContext, A>(event, command), EphemeralInteractionContext diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/PublicSlashCommand.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/PublicSlashCommand.kt new file mode 100644 index 0000000000..74f08fef87 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/PublicSlashCommand.kt @@ -0,0 +1,148 @@ +@file:Suppress("TooGenericExceptionCaught") + +package com.kotlindiscord.kord.extensions.commands.application.slash + +import com.kotlindiscord.kord.extensions.ArgumentParsingException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.events.* +import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.types.FailureReason +import com.kotlindiscord.kord.extensions.types.respond +import dev.kord.core.behavior.interaction.respondEphemeral +import dev.kord.core.behavior.interaction.respondPublic +import dev.kord.core.entity.interaction.GroupCommand +import dev.kord.core.entity.interaction.SubCommand +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent +import dev.kord.rest.builder.message.create.InteractionResponseCreateBuilder + +public typealias InitialPublicSlashResponseBehavior = + (suspend InteractionResponseCreateBuilder.(ChatInputCommandInteractionCreateEvent) -> Unit)? + +/** Public slash command. **/ +public class PublicSlashCommand( + extension: Extension, + + public override val arguments: (() -> A)? = null, + public override val parentCommand: SlashCommand<*, *>? = null, + public override val parentGroup: SlashGroup? = null +) : SlashCommand, A>(extension) { + /** @suppress Internal builder **/ + public var initialResponseBuilder: InitialPublicSlashResponseBehavior = null + + /** Call this to open with a response, omit it to ack instead. **/ + public fun initialResponse(body: InitialPublicSlashResponseBehavior) { + initialResponseBuilder = body + } + + override suspend fun call(event: ChatInputCommandInteractionCreateEvent) { + val eventCommand = event.interaction.command + + val commandObj: SlashCommand<*, *> = when (eventCommand) { + is SubCommand -> { + val firstSubCommandKey = eventCommand.name + + this.subCommands.firstOrNull { it.name == firstSubCommandKey } + ?: error("Unknown subcommand: $firstSubCommandKey") + } + + is GroupCommand -> { + val firstEventGroupKey = eventCommand.groupName + val group = this.groups[firstEventGroupKey] ?: error("Unknown command group: $firstEventGroupKey") + val firstSubCommandKey = eventCommand.name + + group.subCommands.firstOrNull { it.name == firstSubCommandKey } + ?: error("Unknown subcommand: $firstSubCommandKey") + } + + else -> this + } + + commandObj.run(event) + } + + override suspend fun run(event: ChatInputCommandInteractionCreateEvent) { + emitEventAsync(PublicSlashCommandInvocationEvent(this, event)) + + try { + if (!runChecks(event)) { + emitEventAsync( + PublicSlashCommandFailedChecksEvent( + this, + event, + "Checks failed without a message." + ) + ) + + return + } + } catch (e: DiscordRelayedException) { + event.interaction.respondEphemeral { + settings.failureResponseBuilder(this, e.reason, FailureReason.ProvidedCheckFailure(e)) + } + + emitEventAsync(PublicSlashCommandFailedChecksEvent(this, event, e.reason)) + + return + } + + val response = if (initialResponseBuilder != null) { + event.interaction.respondPublic { initialResponseBuilder!!(event) } + } else { + event.interaction.acknowledgePublic() + } + + val context = PublicSlashCommandContext(event, this, response) + + context.populate() + + firstSentryBreadcrumb(context, this) + + try { + checkBotPerms(context) + } catch (e: DiscordRelayedException) { + respondText(context, e.reason, FailureReason.OwnPermissionsCheckFailure(e)) + emitEventAsync(PublicSlashCommandFailedChecksEvent(this, event, e.reason)) + + return + } + if (arguments != null) { + try { + val args = registry.argumentParser.parse(arguments, context) + + context.populateArgs(args) + } catch (e: ArgumentParsingException) { + respondText(context, e.reason, FailureReason.ArgumentParsingFailure(e)) + emitEventAsync(PublicSlashCommandFailedParsingEvent(this, event, e)) + + return + } + } + + try { + body(context) + } catch (t: Throwable) { + emitEventAsync(PublicSlashCommandFailedWithExceptionEvent(this, event, t)) + + if (t is DiscordRelayedException) { + respondText(context, t.reason, FailureReason.RelayedFailure(t)) + + return + } + + handleError(context, t, this) + + return + } + + emitEventAsync(PublicSlashCommandSucceededEvent(this, event)) + } + + override suspend fun respondText( + context: PublicSlashCommandContext, + message: String, + failureType: FailureReason<*> + ) { + context.respond { settings.failureResponseBuilder(this, message, failureType) } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/PublicSlashCommandContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/PublicSlashCommandContext.kt new file mode 100644 index 0000000000..a698ccb2eb --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/PublicSlashCommandContext.kt @@ -0,0 +1,13 @@ +package com.kotlindiscord.kord.extensions.commands.application.slash + +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.types.PublicInteractionContext +import dev.kord.core.behavior.interaction.PublicInteractionResponseBehavior +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent + +/** Public-only slash command context. **/ +public class PublicSlashCommandContext( + override val event: ChatInputCommandInteractionCreateEvent, + override val command: SlashCommand, A>, + override val interactionResponse: PublicInteractionResponseBehavior +) : SlashCommandContext, A>(event, command), PublicInteractionContext diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/SlashCommand.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/SlashCommand.kt new file mode 100644 index 0000000000..56d6b9ec3e --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/SlashCommand.kt @@ -0,0 +1,284 @@ +package com.kotlindiscord.kord.extensions.commands.application.slash + +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.InvalidCommandException +import com.kotlindiscord.kord.extensions.checks.types.CheckContext +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.application.ApplicationCommand +import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.sentry.BreadcrumbType +import com.kotlindiscord.kord.extensions.sentry.tag +import com.kotlindiscord.kord.extensions.sentry.user +import com.kotlindiscord.kord.extensions.types.FailureReason +import com.kotlindiscord.kord.extensions.utils.getLocale +import com.kotlindiscord.kord.extensions.utils.permissionsForMember +import com.kotlindiscord.kord.extensions.utils.translate +import dev.kord.common.entity.ApplicationCommandType +import dev.kord.common.entity.Snowflake +import dev.kord.core.entity.channel.DmChannel +import dev.kord.core.entity.channel.GuildChannel +import dev.kord.core.entity.channel.GuildMessageChannel +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent +import mu.KLogger +import mu.KotlinLogging +import java.util.* + +/** + * Slash command, executed directly in the chat input. + * + * @param arguments Callable returning an `Arguments` object, if any + * @param parentCommand Parent slash command, if any + * @param parentGroup Parent slash command group, if any + */ +public abstract class SlashCommand, A : Arguments>( + extension: Extension, + + public open val arguments: (() -> A)? = null, + public open val parentCommand: SlashCommand<*, *>? = null, + public open val parentGroup: SlashGroup? = null +) : ApplicationCommand(extension) { + /** @suppress **/ + public val logger: KLogger = KotlinLogging.logger {} + + /** Command description, as displayed on Discord. **/ + public open lateinit var description: String + + /** Command body, to be called when the command is executed. **/ + public lateinit var body: suspend C.() -> Unit + + /** Whether this command has a body/action set. **/ + public open val hasBody: Boolean get() = ::body.isInitialized + + /** Map of group names to slash command groups, if any. **/ + public open val groups: MutableMap = mutableMapOf() + + /** List of subcommands, if any. **/ + public open val subCommands: MutableList> = mutableListOf() + + override val type: ApplicationCommandType = ApplicationCommandType.ChatInput + + override var guildId: Snowflake? = if (parentCommand == null && parentGroup == null) { + settings.applicationCommandsBuilder.defaultGuild + } else { + null + } + + override fun validate() { + super.validate() + + if (!::description.isInitialized) { + throw InvalidCommandException(name, "No command description given.") + } + + if (!::body.isInitialized && groups.isEmpty() && subCommands.isEmpty()) { + throw InvalidCommandException(name, "No command action or subcommands/groups given.") + } + + if (::body.isInitialized && !(groups.isEmpty() && subCommands.isEmpty())) { + throw InvalidCommandException( + name, + + "Command action and subcommands/groups given, but slash commands may not have an action if they have" + + " a subcommand or group." + ) + } + + if (parentCommand != null && guildId != null) { + throw InvalidCommandException( + name, + + "Subcommands may not be limited to specific guilds - set the `guild` property on the parent command " + + "instead." + ) + } + } + + public override fun getTranslatedName(locale: Locale): String { + // Only slash commands need this to be lower-cased. + + if (!nameTranslationCache.containsKey(locale)) { + nameTranslationCache[locale] = translationsProvider.translate( + this.name, + this.extension.bundle, + locale + ).lowercase() + } + + return nameTranslationCache[locale]!! + } + + /** Return this command's description translated for the given locale, cached as required. **/ + public fun getTranslatedDescription(locale: Locale): String { + // Only slash commands need this to be lower-cased. + + if (!descriptionTranslationCache.containsKey(locale)) { + descriptionTranslationCache[locale] = translationsProvider.translate( + this.description, + this.extension.bundle, + locale + ) + } + + return descriptionTranslationCache[locale]!! + } + + /** Call this to supply a command [body], to be called when the command is executed. **/ + public fun action(action: suspend C.() -> Unit) { + body = action + } + + /** Override this to implement your command's calling logic. Check subtypes for examples! **/ + public abstract override suspend fun call(event: ChatInputCommandInteractionCreateEvent) + + /** Override this to implement a way to respond to the user, regardless of whatever happens. **/ + public abstract suspend fun respondText(context: C, message: String, failureType: FailureReason<*>) + + /** + * Override this to implement the final calling logic, including creating the command context and running with it. + */ + public abstract suspend fun run(event: ChatInputCommandInteractionCreateEvent) + + /** Checks whether the bot has the specified required permissions, throwing if it doesn't. **/ + @Throws(DiscordRelayedException::class) + public open suspend fun checkBotPerms(context: C) { + if (requiredPerms.isEmpty()) { + return // Nothing to check, don't try to hit the cache + } + + if (context.guild != null) { + val perms = (context.channel.asChannel() as GuildChannel) + .permissionsForMember(kord.selfId) + + val missingPerms = requiredPerms.filter { !perms.contains(it) } + + if (missingPerms.isNotEmpty()) { + throw DiscordRelayedException( + context.translate( + "commands.error.missingBotPermissions", + null, + + replacements = arrayOf( + missingPerms.map { it.translate(context.getLocale()) }.joinToString(", ") + ) + ) + ) + } + } + } + + /** If enabled, adds the initial Sentry breadcrumb to the given context. **/ + public open suspend fun firstSentryBreadcrumb(context: C, commandObj: SlashCommand<*, *>) { + if (sentry.enabled) { + context.sentry.breadcrumb(BreadcrumbType.User) { + category = "command.application.slash" + message = "Slash command \"${commandObj.name}\" called." + + val channel = context.channel.asChannelOrNull() + val guild = context.guild?.asGuildOrNull() + + data["command"] = commandObj.name + + if (guildId != null) { + data["command.guild"] = guildId!!.asString + } + + if (channel != null) { + data["channel"] = when (channel) { + is DmChannel -> "Private Message (${channel.id.asString})" + is GuildMessageChannel -> "#${channel.name} (${channel.id.asString})" + + else -> channel.id.asString + } + } + + if (guild != null) { + data["guild"] = "${guild.name} (${guild.id.asString})" + } + } + } + } + + override suspend fun runChecks(event: ChatInputCommandInteractionCreateEvent): Boolean { + val locale = event.getLocale() + var result = super.runChecks(event) + + if (result && parentCommand != null) { + result = parentCommand!!.runChecks(event) + } + + if (result && parentGroup != null) { + result = parentGroup!!.parent.runChecks(event) + } + + if (result) { + settings.applicationCommandsBuilder.slashCommandChecks.forEach { check -> + val context = CheckContext(event, locale) + + check(context) + + if (!context.passed) { + context.throwIfFailedWithMessage() + + return false + } + } + + extension.slashCommandChecks.forEach { check -> + val context = CheckContext(event, locale) + + check(context) + + if (!context.passed) { + context.throwIfFailedWithMessage() + + return false + } + } + } + + return result + } + + /** A general way to handle errors thrown during the course of a command's execution. **/ + public open suspend fun handleError(context: C, t: Throwable, commandObj: SlashCommand<*, *>) { + logger.error(t) { "Error during execution of ${commandObj.name} slash command (${context.event})" } + + if (sentry.enabled) { + logger.trace { "Submitting error to sentry." } + + val channel = context.channel + val author = context.user.asUserOrNull() + + val sentryId = context.sentry.captureException(t, "Slash command execution failed.") { + if (author != null) { + user(author) + } + + tag("private", "false") + + if (channel is DmChannel) { + tag("private", "true") + } + + tag("command", commandObj.name) + tag("extension", commandObj.extension.name) + } + + logger.info { "Error submitted to Sentry: $sentryId" } + + val errorMessage = if (extension.bot.extensions.containsKey("sentry")) { + context.translate("commands.error.user.sentry.slash", null, replacements = arrayOf(sentryId)) + } else { + context.translate("commands.error.user", null) + } + + respondText(context, errorMessage, FailureReason.ExecutionError(t)) + } else { + respondText( + context, + context.translate("commands.error.user", null), + FailureReason.ExecutionError(t) + ) + } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/SlashCommandContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/SlashCommandContext.kt new file mode 100644 index 0000000000..1573432ffc --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/SlashCommandContext.kt @@ -0,0 +1,23 @@ +package com.kotlindiscord.kord.extensions.commands.application.slash + +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.application.ApplicationCommandContext +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent + +/** + * Slash command context, containing everything you need for your slash command's execution. + * + * @param event Event that triggered this slash command invocation. + */ +public open class SlashCommandContext, A : Arguments>( + public open val event: ChatInputCommandInteractionCreateEvent, + public override val command: SlashCommand +) : ApplicationCommandContext(event, command) { + /** Object representing this slash command's arguments, if any. **/ + public open lateinit var arguments: A + + /** @suppress Internal function for copying args object in later. **/ + public fun populateArgs(args: A) { + arguments = args + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/parser/SlashCommandParser.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/SlashCommandParser.kt similarity index 59% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/parser/SlashCommandParser.kt rename to kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/SlashCommandParser.kt index eddf2ee117..e88b98ee29 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/parser/SlashCommandParser.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/SlashCommandParser.kt @@ -3,17 +3,15 @@ "StringLiteralDuplication" // Needs cleaning up with polymorphism later anyway ) -package com.kotlindiscord.kord.extensions.commands.slash.parser +package com.kotlindiscord.kord.extensions.commands.application.slash -import com.kotlindiscord.kord.extensions.CommandException -import com.kotlindiscord.kord.extensions.commands.CommandContext +import com.kotlindiscord.kord.extensions.ArgumentParsingException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument -import com.kotlindiscord.kord.extensions.commands.parser.ArgumentParser -import com.kotlindiscord.kord.extensions.commands.parser.Arguments -import com.kotlindiscord.kord.extensions.commands.slash.SlashCommandContext import dev.kord.common.annotation.KordPreview -import dev.kord.core.entity.KordEntity +import dev.kord.core.entity.interaction.OptionValue import mu.KotlinLogging private val logger = KotlinLogging.logger {} @@ -21,48 +19,49 @@ private val logger = KotlinLogging.logger {} /** * Parser in charge of dealing with the arguments for slash commands. * - * This doesn't do anything special with the rich types provided by Discord, as they're useless in most cases. - * Instead, it transforms them to a string and puts them through the usual parsing machinery. - * * This parser does not support multi converters, as there's no good way to represent them with * Discord's API. Coalescing converters will act like single converters. */ -public open class SlashCommandParser : ArgumentParser() { - public override suspend fun parse(builder: () -> T, context: CommandContext): T { - if (context !is SlashCommandContext) { - error("This parser only supports slash commands.") - } - +public open class SlashCommandParser { + /** + * Parse the arguments for this slash command, which have been provided by Discord. + * + * Instead of taking the objects as Discord provides them, this function will stringify all the command's + * arguments. This allows them to be passed through the usual converter system. + */ + public suspend fun parse( + builder: () -> T, + context: SlashCommandContext<*, *> + ): T { val argumentsObj = builder.invoke() + argumentsObj.validate() - logger.debug { "Arguments object: $argumentsObj (${argumentsObj.args.size} args)" } + logger.trace { "Arguments object: $argumentsObj (${argumentsObj.args.size} args)" } val args = argumentsObj.args.toMutableList() - val command = context.interaction.command + val command = context.event.interaction.command val values = command.options.mapValues { - val option = it.value.value - - if (option is KordEntity) { - option.id.asString + if (it.value is OptionValue.StringOptionValue) { + OptionValue.StringOptionValue((it.value.value as String).trim()) } else { - option.toString() - }.trim() - } + it.value + } + } as Map> var currentArg: Argument<*>? - var currentValue: String? + var currentValue: OptionValue<*>? @Suppress("LoopWithTooManyJumpStatements") // Listen here u lil shit while (true) { currentArg = args.removeFirstOrNull() currentArg ?: break // If it's null, we're out of arguments - logger.debug { "Current argument: ${currentArg.displayName}" } + logger.trace { "Current argument: ${currentArg.displayName}" } currentValue = values[currentArg.displayName.lowercase()] - logger.debug { "Current value: $currentValue" } + logger.trace { "Current value: $currentValue" } @Suppress("TooGenericExceptionCaught") when (val converter = currentArg.converter) { @@ -71,13 +70,13 @@ public open class SlashCommandParser : ArgumentParser() { is SingleConverter<*> -> try { val parsed = if (currentValue != null) { - converter.parse(null, context, currentValue) + converter.parseOption(context, currentValue) } else { false } if (converter.required && !parsed) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.invalidValue", replacements = arrayOf( @@ -85,21 +84,34 @@ public open class SlashCommandParser : ArgumentParser() { converter.getErrorString(context), currentValue ) - ) + ), + + "argumentParser.error.invalidValue", + + currentArg, + argumentsObj, + null ) } if (parsed) { - logger.debug { "Argument ${currentArg.displayName} successfully filled." } + logger.trace { "Argument ${currentArg.displayName} successfully filled." } converter.parseSuccess = true currentValue = null converter.validate(context) } - } catch (e: CommandException) { + } catch (e: DiscordRelayedException) { if (converter.required) { - throw CommandException(converter.handleError(e, context)) + throw ArgumentParsingException( + converter.handleError(e, context), + null, + + currentArg, + argumentsObj, + null + ) } } catch (t: Throwable) { logger.debug { "Argument ${currentArg.displayName} threw: $t" } @@ -111,13 +123,13 @@ public open class SlashCommandParser : ArgumentParser() { is CoalescingConverter<*> -> try { val parsed = if (currentValue != null) { - converter.parse(null, context, listOf(currentValue)) > 0 + converter.parseOption(context, currentValue) } else { false } if (converter.required && !parsed) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.invalidValue", replacements = arrayOf( @@ -125,21 +137,34 @@ public open class SlashCommandParser : ArgumentParser() { converter.getErrorString(context), currentValue ) - ) + ), + + "argumentParser.error.invalidValue", + + currentArg, + argumentsObj, + null ) } if (parsed) { - logger.debug { "Argument ${currentArg.displayName} successfully filled." } + logger.trace { "Argument ${currentArg.displayName} successfully filled." } converter.parseSuccess = true currentValue = null converter.validate(context) } - } catch (e: CommandException) { + } catch (e: DiscordRelayedException) { if (converter.required) { - throw CommandException(converter.handleError(e, context)) + throw ArgumentParsingException( + converter.handleError(e, context), + null, + + currentArg, + argumentsObj, + null + ) } } catch (t: Throwable) { logger.debug { "Argument ${currentArg.displayName} threw: $t" } @@ -151,23 +176,28 @@ public open class SlashCommandParser : ArgumentParser() { is OptionalConverter<*> -> try { val parsed = if (currentValue != null) { - converter.parse(null, context, currentValue) + converter.parseOption(context, currentValue) } else { false } if (parsed) { - logger.debug { "Argument ${currentArg.displayName} successfully filled." } + logger.trace { "Argument ${currentArg.displayName} successfully filled." } converter.parseSuccess = true currentValue = null converter.validate(context) } - } catch (e: CommandException) { + } catch (e: DiscordRelayedException) { if (converter.required || converter.outputError) { - throw CommandException( - converter.handleError(e, context) + throw ArgumentParsingException( + converter.handleError(e, context), + null, + + currentArg, + argumentsObj, + null ) } } catch (t: Throwable) { @@ -176,23 +206,28 @@ public open class SlashCommandParser : ArgumentParser() { is OptionalCoalescingConverter<*> -> try { val parsed = if (currentValue != null) { - converter.parse(null, context, listOf(currentValue)) > 0 + converter.parseOption(context, currentValue) } else { false } if (parsed) { - logger.debug { "Argument ${currentArg.displayName} successfully filled." } + logger.trace { "Argument ${currentArg.displayName} successfully filled." } converter.parseSuccess = true currentValue = null converter.validate(context) } - } catch (e: CommandException) { + } catch (e: DiscordRelayedException) { if (converter.required || converter.outputError) { - throw CommandException( - converter.handleError(e, context) + throw ArgumentParsingException( + converter.handleError(e, context), + null, + + currentArg, + argumentsObj, + null ) } } catch (t: Throwable) { @@ -201,23 +236,28 @@ public open class SlashCommandParser : ArgumentParser() { is DefaultingConverter<*> -> try { val parsed = if (currentValue != null) { - converter.parse(null, context, currentValue) + converter.parseOption(context, currentValue) } else { false } if (parsed) { - logger.debug { "Argument ${currentArg.displayName} successfully filled." } + logger.trace { "Argument ${currentArg.displayName} successfully filled." } converter.parseSuccess = true currentValue = null converter.validate(context) } - } catch (e: CommandException) { + } catch (e: DiscordRelayedException) { if (converter.required || converter.outputError) { - throw CommandException( - converter.handleError(e, context) + throw ArgumentParsingException( + converter.handleError(e, context), + null, + + currentArg, + argumentsObj, + null ) } } catch (t: Throwable) { @@ -226,23 +266,28 @@ public open class SlashCommandParser : ArgumentParser() { is DefaultingCoalescingConverter<*> -> try { val parsed = if (currentValue != null) { - converter.parse(null, context, listOf(currentValue)) > 0 + converter.parseOption(context, currentValue) } else { false } if (parsed) { - logger.debug { "Argument ${currentArg.displayName} successfully filled." } + logger.trace { "Argument ${currentArg.displayName} successfully filled." } converter.parseSuccess = true currentValue = null converter.validate(context) } - } catch (e: CommandException) { + } catch (e: DiscordRelayedException) { if (converter.required || converter.outputError) { - throw CommandException( - converter.handleError(e, context) + throw ArgumentParsingException( + converter.handleError(e, context), + null, + + currentArg, + argumentsObj, + null ) } } catch (t: Throwable) { diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/SlashGroup.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/SlashGroup.kt new file mode 100644 index 0000000000..34589d09cb --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/SlashGroup.kt @@ -0,0 +1,65 @@ +package com.kotlindiscord.kord.extensions.commands.application.slash + +import com.kotlindiscord.kord.extensions.InvalidCommandException +import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider +import mu.KLogger +import mu.KotlinLogging +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.util.* + +/** + * Slash command group, containing other slash commands. + * + * @param name Slash command group name + * @param parent Parent slash command that this group belongs to + */ +public class SlashGroup( + public val name: String, + public val parent: SlashCommand<*, *> +) : KoinComponent { + /** Translations provider, for retrieving translations. **/ + public val translationsProvider: TranslationsProvider by inject() + + /** @suppress **/ + public val logger: KLogger = KotlinLogging.logger {} + + /** List of subcommands belonging to this group. **/ + public val subCommands: MutableList> = mutableListOf() + + /** Command group description, which is required and shown on Discord. **/ + public lateinit var description: String + + /** Translation cache, so we don't have to look up translations every time. **/ + public val descriptionTranslationCache: MutableMap = mutableMapOf() + + /** Return this group's description translated for the given locale, cached as required. **/ + public fun getTranslatedDescription(locale: Locale): String { + // Only slash commands need this to be lower-cased. + + if (!descriptionTranslationCache.containsKey(locale)) { + descriptionTranslationCache[locale] = translationsProvider.translate( + this.description, + this.parent.extension.bundle, + locale + ).lowercase() + } + + return descriptionTranslationCache[locale]!! + } + + /** + * Validate this command group, ensuring it has everything it needs. + * + * Throws if not. + */ + public fun validate() { + if (!::description.isInitialized) { + throw InvalidCommandException(name, "No group description given.") + } + + if (subCommands.isEmpty()) { + error("Command groups must contain at least one subcommand.") + } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/_Functions.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/_Functions.kt new file mode 100644 index 0000000000..266c0d8135 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/_Functions.kt @@ -0,0 +1,325 @@ +@file:Suppress("StringLiteralDuplication") + +package com.kotlindiscord.kord.extensions.commands.application.slash + +import com.kotlindiscord.kord.extensions.CommandRegistrationException +import com.kotlindiscord.kord.extensions.InvalidCommandException +import com.kotlindiscord.kord.extensions.commands.Arguments + +private const val SUBCOMMAND_AND_GROUP_LIMIT: Int = 25 + +// region: Group creation + +/** + * Create a command group, using the given name. + * + * Note that only root/top-level commands can contain command groups. An error will be thrown if you try to use + * this with a subcommand. + * + * @param name Name of the command group on Discord. + * @param body Lambda used to build the [SlashGroup] object. + */ +public suspend fun SlashCommand<*, *>.group(name: String, body: suspend SlashGroup.() -> Unit): SlashGroup { + if (parentCommand != null) { + error("Command groups may not be nested inside subcommands.") + } + + if (subCommands.isNotEmpty()) { + error("Commands may only contain subcommands or command groups, not both.") + } + + if (groups.size >= SUBCOMMAND_AND_GROUP_LIMIT) { + error("Commands may only contain up to $SUBCOMMAND_AND_GROUP_LIMIT command groups.") + } + + if (groups[name] != null) { + error("A command group with the name '$name' has already been registered.") + } + + val group = SlashGroup(name, this) + + body(group) + group.validate() + + groups[name] = group + + return group +} + +// endregion + +// region: Slash commands (Ephemeral) + +/** + * DSL function for easily registering an ephemeral subcommand, with arguments. + * + * Use this in your setup function to register a subcommand that may be executed on Discord. + * + * @param arguments Arguments builder (probably a reference to the class constructor). + * @param body Builder lambda used for setting up the slash command object. + */ +public suspend fun SlashCommand<*, *>.ephemeralSubCommand( + arguments: () -> T, + body: suspend EphemeralSlashCommand.() -> Unit +): EphemeralSlashCommand { + val commandObj = EphemeralSlashCommand(extension, arguments, parentCommand, parentGroup) + body(commandObj) + + return ephemeralSubCommand(commandObj) +} + +/** + * Function for registering a custom ephemeral slash command object, for subcommands. + * + * You can use this if you have a custom ephemeral slash command subclass you need to register. + * + * @param commandObj EphemeralSlashCommand object to register as a subcommand. + */ +public fun SlashCommand<*, *>.ephemeralSubCommand( + commandObj: EphemeralSlashCommand +): EphemeralSlashCommand { + commandObj.guildId = null + + if (subCommands.size >= SUBCOMMAND_AND_GROUP_LIMIT) { + throw InvalidCommandException( + commandObj.name, + "Groups may only contain up to $SUBCOMMAND_AND_GROUP_LIMIT commands." + ) + } + + try { + commandObj.validate() + subCommands.add(commandObj) + } catch (e: CommandRegistrationException) { + logger.error(e) { "Failed to register subcommand - $e" } + } catch (e: InvalidCommandException) { + logger.error(e) { "Failed to register subcommand - $e" } + } + + return commandObj +} + +/** + * DSL function for easily registering an ephemeral subcommand, without arguments. + * + * Use this in your slash command function to register a subcommand that may be executed on Discord. + * + * @param body Builder lambda used for setting up the subcommand object. + */ +public suspend fun SlashCommand<*, *>.ephemeralSubCommand( + body: suspend EphemeralSlashCommand.() -> Unit +): EphemeralSlashCommand { + val commandObj = EphemeralSlashCommand(extension, null, parentCommand, parentGroup) + body(commandObj) + + return ephemeralSubCommand(commandObj) +} + +// endregion + +// region: Slash commands (Public) + +/** + * DSL function for easily registering a public subcommand, with arguments. + * + * Use this in your setup function to register a subcommand that may be executed on Discord. + * + * @param arguments Arguments builder (probably a reference to the class constructor). + * @param body Builder lambda used for setting up the slash command object. + */ +public suspend fun SlashCommand<*, *>.publicSubCommand( + arguments: () -> T, + body: suspend PublicSlashCommand.() -> Unit +): PublicSlashCommand { + val commandObj = PublicSlashCommand(extension, arguments, parentCommand, parentGroup) + body(commandObj) + + return publicSubCommand(commandObj) +} + +/** + * Function for registering a custom public slash command object, for subcommands. + * + * You can use this if you have a custom public slash command subclass you need to register. + * + * @param commandObj PublicSlashCommand object to register as a subcommand. + */ +public fun SlashCommand<*, *>.publicSubCommand( + commandObj: PublicSlashCommand +): PublicSlashCommand { + commandObj.guildId = null + + if (subCommands.size >= SUBCOMMAND_AND_GROUP_LIMIT) { + throw InvalidCommandException( + commandObj.name, + "Groups may only contain up to $SUBCOMMAND_AND_GROUP_LIMIT commands." + ) + } + + try { + commandObj.validate() + subCommands.add(commandObj) + } catch (e: CommandRegistrationException) { + logger.error(e) { "Failed to register subcommand - $e" } + } catch (e: InvalidCommandException) { + logger.error(e) { "Failed to register subcommand - $e" } + } + + return commandObj +} + +/** + * DSL function for easily registering a public subcommand, without arguments. + * + * Use this in your slash command function to register a subcommand that may be executed on Discord. + * + * @param body Builder lambda used for setting up the subcommand object. + */ +public suspend fun SlashCommand<*, *>.publicSubCommand( + body: suspend PublicSlashCommand.() -> Unit +): PublicSlashCommand { + val commandObj = PublicSlashCommand(extension, null, parentCommand, parentGroup) + body(commandObj) + + return publicSubCommand(commandObj) +} + +// endregion + +// region: Slash groups (Ephemeral) + +/** + * DSL function for easily registering an ephemeral subcommand, with arguments. + * + * Use this in your setup function to register a subcommand that may be executed on Discord. + * + * @param arguments Arguments builder (probably a reference to the class constructor). + * @param body Builder lambda used for setting up the slash command object. + */ +public suspend fun SlashGroup.ephemeralSubCommand( + arguments: () -> T, + body: suspend EphemeralSlashCommand.() -> Unit +): EphemeralSlashCommand { + val commandObj = EphemeralSlashCommand(parent.extension, arguments, parent, this) + body(commandObj) + + return ephemeralSubCommand(commandObj) +} + +/** + * Function for registering a custom ephemeral slash command object, for subcommands. + * + * You can use this if you have a custom ephemeral slash command subclass you need to register. + * + * @param commandObj EphemeralSlashCommand object to register as a subcommand. + */ +public fun SlashGroup.ephemeralSubCommand( + commandObj: EphemeralSlashCommand +): EphemeralSlashCommand { + commandObj.guildId = null + + if (subCommands.size >= SUBCOMMAND_AND_GROUP_LIMIT) { + throw InvalidCommandException( + commandObj.name, + "Groups may only contain up to $SUBCOMMAND_AND_GROUP_LIMIT commands." + ) + } + + try { + commandObj.validate() + subCommands.add(commandObj) + } catch (e: CommandRegistrationException) { + logger.error(e) { "Failed to register subcommand - $e" } + } catch (e: InvalidCommandException) { + logger.error(e) { "Failed to register subcommand - $e" } + } + + return commandObj +} + +/** + * DSL function for easily registering an ephemeral subcommand, without arguments. + * + * Use this in your slash command function to register a subcommand that may be executed on Discord. + * + * @param body Builder lambda used for setting up the subcommand object. + */ +public suspend fun SlashGroup.ephemeralSubCommand( + body: suspend EphemeralSlashCommand.() -> Unit +): EphemeralSlashCommand { + val commandObj = EphemeralSlashCommand(parent.extension, null, parent, this) + body(commandObj) + + return ephemeralSubCommand(commandObj) +} + +// endregion + +// region: Slash groups (Public) + +/** + * DSL function for easily registering a public subcommand, with arguments. + * + * Use this in your setup function to register a subcommand that may be executed on Discord. + * + * @param arguments Arguments builder (probably a reference to the class constructor). + * @param body Builder lambda used for setting up the slash command object. + */ +public suspend fun SlashGroup.publicSubCommand( + arguments: () -> T, + body: suspend PublicSlashCommand.() -> Unit +): PublicSlashCommand { + val commandObj = PublicSlashCommand(parent.extension, arguments, parent, this) + body(commandObj) + + return publicSubCommand(commandObj) +} + +/** + * Function for registering a custom public slash command object, for subcommands. + * + * You can use this if you have a custom public slash command subclass you need to register. + * + * @param commandObj PublicSlashCommand object to register as a subcommand. + */ +public fun SlashGroup.publicSubCommand( + commandObj: PublicSlashCommand +): PublicSlashCommand { + commandObj.guildId = null + + if (subCommands.size >= SUBCOMMAND_AND_GROUP_LIMIT) { + throw InvalidCommandException( + commandObj.name, + "Groups may only contain up to $SUBCOMMAND_AND_GROUP_LIMIT commands." + ) + } + + try { + commandObj.validate() + subCommands.add(commandObj) + } catch (e: CommandRegistrationException) { + logger.error(e) { "Failed to register subcommand - $e" } + } catch (e: InvalidCommandException) { + logger.error(e) { "Failed to register subcommand - $e" } + } + + return commandObj +} + +/** + * DSL function for easily registering a public subcommand, without arguments. + * + * Use this in your slash command function to register a subcommand that may be executed on Discord. + * + * @param body Builder lambda used for setting up the subcommand object. + */ +public suspend fun SlashGroup.publicSubCommand( + body: suspend PublicSlashCommand.() -> Unit +): PublicSlashCommand { + val commandObj = PublicSlashCommand(parent.extension, null, parent, this) + body(commandObj) + + return publicSubCommand(commandObj) +} + +// endregion diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/converters/ChoiceConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/converters/ChoiceConverter.kt similarity index 89% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/converters/ChoiceConverter.kt rename to kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/converters/ChoiceConverter.kt index 167ed4622a..a8df12a120 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/converters/ChoiceConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/converters/ChoiceConverter.kt @@ -1,4 +1,4 @@ -package com.kotlindiscord.kord.extensions.commands.slash.converters +package com.kotlindiscord.kord.extensions.commands.application.slash.converters import com.kotlindiscord.kord.extensions.commands.converters.SingleConverter import dev.kord.common.annotation.KordPreview diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/converters/ChoiceEnum.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/converters/ChoiceEnum.kt similarity index 51% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/converters/ChoiceEnum.kt rename to kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/converters/ChoiceEnum.kt index d5568cd4fb..f84052d520 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/converters/ChoiceEnum.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/converters/ChoiceEnum.kt @@ -1,6 +1,6 @@ -package com.kotlindiscord.kord.extensions.commands.slash.converters +package com.kotlindiscord.kord.extensions.commands.application.slash.converters -import com.kotlindiscord.kord.extensions.commands.slash.converters.impl.EnumChoiceConverter +import com.kotlindiscord.kord.extensions.commands.application.slash.converters.impl.EnumChoiceConverter /** Interface representing an enum used in the [EnumChoiceConverter]. **/ public interface ChoiceEnum { diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/converters/impl/EnumChoiceConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/converters/impl/EnumChoiceConverter.kt similarity index 81% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/converters/impl/EnumChoiceConverter.kt rename to kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/converters/impl/EnumChoiceConverter.kt index 5fb7a6c123..289c2fca48 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/converters/impl/EnumChoiceConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/converters/impl/EnumChoiceConverter.kt @@ -5,16 +5,17 @@ ConverterToOptional::class ) -package com.kotlindiscord.kord.extensions.commands.slash.converters.impl +package com.kotlindiscord.kord.extensions.commands.application.slash.converters.impl +import com.kotlindiscord.kord.extensions.commands.Argument +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.CommandContext +import com.kotlindiscord.kord.extensions.commands.application.slash.converters.ChoiceConverter +import com.kotlindiscord.kord.extensions.commands.application.slash.converters.ChoiceEnum import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument -import com.kotlindiscord.kord.extensions.commands.parser.Arguments -import com.kotlindiscord.kord.extensions.commands.slash.converters.ChoiceConverter -import com.kotlindiscord.kord.extensions.commands.slash.converters.ChoiceEnum import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder @@ -51,6 +52,18 @@ public class EnumChoiceConverter( this@EnumChoiceConverter.choices.forEach { choice(it.key, it.value.name) } } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val stringOption = option as? OptionValue.StringOptionValue ?: return false + + try { + parsed = getter.invoke(stringOption.value) ?: return false + } catch (e: IllegalArgumentException) { + return false + } + + return true + } } /** diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/converters/impl/NumberChoiceConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/converters/impl/NumberChoiceConverter.kt similarity index 74% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/converters/impl/NumberChoiceConverter.kt rename to kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/converters/impl/NumberChoiceConverter.kt index 18d4f8a30f..c8cd3b7df2 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/converters/impl/NumberChoiceConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/converters/impl/NumberChoiceConverter.kt @@ -5,20 +5,21 @@ ConverterToOptional::class ) -package com.kotlindiscord.kord.extensions.commands.slash.converters.impl +package com.kotlindiscord.kord.extensions.commands.application.slash.converters.impl -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext +import com.kotlindiscord.kord.extensions.commands.application.slash.converters.ChoiceConverter import com.kotlindiscord.kord.extensions.commands.converters.ConverterToDefaulting import com.kotlindiscord.kord.extensions.commands.converters.ConverterToMulti import com.kotlindiscord.kord.extensions.commands.converters.ConverterToOptional import com.kotlindiscord.kord.extensions.commands.converters.Validator -import com.kotlindiscord.kord.extensions.commands.parser.Argument -import com.kotlindiscord.kord.extensions.commands.slash.converters.ChoiceConverter import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.IntChoiceBuilder import dev.kord.rest.builder.interaction.OptionsBuilder @@ -39,16 +40,16 @@ private const val DEFAULT_RADIX = 10 public class NumberChoiceConverter( private val radix: Int = DEFAULT_RADIX, - choices: Map, - override var validator: Validator = null -) : ChoiceConverter(choices) { + choices: Map, + override var validator: Validator = null +) : ChoiceConverter(choices) { override val signatureTypeString: String = "converters.number.signatureType" override suspend fun parse(parser: StringParser?, context: CommandContext, named: String?): Boolean { val arg: String = named ?: parser?.parseNext()?.data ?: return false try { - this.parsed = arg.toInt(radix) + this.parsed = arg.toLong(radix) } catch (e: NumberFormatException) { val errorString = if (radix == DEFAULT_RADIX) { context.translate("converters.number.error.invalid.defaultBase", replacements = arrayOf(arg)) @@ -56,7 +57,7 @@ class NumberChoiceConverter( context.translate("converters.number.error.invalid.otherBase", replacements = arrayOf(arg, radix)) } - throw CommandException(errorString) + throw DiscordRelayedException(errorString) } return true @@ -68,4 +69,11 @@ class NumberChoiceConverter( this@NumberChoiceConverter.choices.forEach { choice(it.key, it.value) } } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.IntOptionValue)?.value ?: return false + this.parsed = optionValue + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/converters/impl/StringChoiceConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/converters/impl/StringChoiceConverter.kt similarity index 77% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/converters/impl/StringChoiceConverter.kt rename to kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/converters/impl/StringChoiceConverter.kt index ebe8707f4f..516f4e8c84 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/converters/impl/StringChoiceConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/slash/converters/impl/StringChoiceConverter.kt @@ -5,19 +5,20 @@ ConverterToOptional::class ) -package com.kotlindiscord.kord.extensions.commands.slash.converters.impl +package com.kotlindiscord.kord.extensions.commands.application.slash.converters.impl +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext +import com.kotlindiscord.kord.extensions.commands.application.slash.converters.ChoiceConverter import com.kotlindiscord.kord.extensions.commands.converters.ConverterToDefaulting import com.kotlindiscord.kord.extensions.commands.converters.ConverterToMulti import com.kotlindiscord.kord.extensions.commands.converters.ConverterToOptional import com.kotlindiscord.kord.extensions.commands.converters.Validator -import com.kotlindiscord.kord.extensions.commands.parser.Argument -import com.kotlindiscord.kord.extensions.commands.slash.converters.ChoiceConverter import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder @@ -50,4 +51,11 @@ public class StringChoiceConverter( this@StringChoiceConverter.choices.forEach { choice(it.key, it.value) } } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + this.parsed = optionValue + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/EphemeralUserCommand.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/EphemeralUserCommand.kt new file mode 100644 index 0000000000..923c6030d6 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/EphemeralUserCommand.kt @@ -0,0 +1,102 @@ +@file:Suppress("TooGenericExceptionCaught") + +package com.kotlindiscord.kord.extensions.commands.application.user + +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.events.EphemeralUserCommandFailedChecksEvent +import com.kotlindiscord.kord.extensions.commands.events.EphemeralUserCommandFailedWithExceptionEvent +import com.kotlindiscord.kord.extensions.commands.events.EphemeralUserCommandInvocationEvent +import com.kotlindiscord.kord.extensions.commands.events.EphemeralUserCommandSucceededEvent +import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.types.FailureReason +import com.kotlindiscord.kord.extensions.types.respond +import dev.kord.core.behavior.interaction.respondEphemeral +import dev.kord.core.event.interaction.UserCommandInteractionCreateEvent +import dev.kord.rest.builder.message.create.InteractionResponseCreateBuilder + +public typealias InitialEphemeralUserResponseBuilder = + (suspend InteractionResponseCreateBuilder.(UserCommandInteractionCreateEvent) -> Unit)? + +/** Ephemeral user command. **/ +public class EphemeralUserCommand( + extension: Extension +) : UserCommand(extension) { + /** @suppress Internal guilder **/ + public var initialResponseBuilder: InitialEphemeralUserResponseBuilder = null + + /** Call this to open with a response, omit it to ack instead. **/ + public fun initialResponse(body: InitialEphemeralUserResponseBuilder) { + initialResponseBuilder = body + } + + override suspend fun call(event: UserCommandInteractionCreateEvent) { + emitEventAsync(EphemeralUserCommandInvocationEvent(this, event)) + + try { + if (!runChecks(event)) { + emitEventAsync( + EphemeralUserCommandFailedChecksEvent( + this, + event, + "Checks failed without a message." + ) + ) + + return + } + } catch (e: DiscordRelayedException) { + event.interaction.respondEphemeral { + settings.failureResponseBuilder(this, e.reason, FailureReason.ProvidedCheckFailure(e)) + } + + emitEventAsync(EphemeralUserCommandFailedChecksEvent(this, event, e.reason)) + + return + } + + val response = if (initialResponseBuilder != null) { + event.interaction.respondEphemeral { initialResponseBuilder!!(event) } + } else { + event.interaction.acknowledgeEphemeral() + } + + val context = EphemeralUserCommandContext(event, this, response) + + context.populate() + + firstSentryBreadcrumb(context) + + try { + checkBotPerms(context) + } catch (e: DiscordRelayedException) { + respondText(context, e.reason, FailureReason.OwnPermissionsCheckFailure(e)) + emitEventAsync(EphemeralUserCommandFailedChecksEvent(this, event, e.reason)) + + return + } + + try { + body(context) + } catch (t: Throwable) { + emitEventAsync(EphemeralUserCommandFailedWithExceptionEvent(this, event, t)) + + if (t is DiscordRelayedException) { + respondText(context, t.reason, FailureReason.RelayedFailure(t)) + + return + } + + handleError(context, t) + } + + emitEventAsync(EphemeralUserCommandSucceededEvent(this, event)) + } + + override suspend fun respondText( + context: EphemeralUserCommandContext, + message: String, + failureType: FailureReason<*> + ) { + context.respond { settings.failureResponseBuilder(this, message, failureType) } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/EphemeralUserCommandContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/EphemeralUserCommandContext.kt new file mode 100644 index 0000000000..f9dd4e51ec --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/EphemeralUserCommandContext.kt @@ -0,0 +1,12 @@ +package com.kotlindiscord.kord.extensions.commands.application.user + +import com.kotlindiscord.kord.extensions.types.EphemeralInteractionContext +import dev.kord.core.behavior.interaction.EphemeralInteractionResponseBehavior +import dev.kord.core.event.interaction.UserCommandInteractionCreateEvent + +/** Ephemeral-only user command context. **/ +public class EphemeralUserCommandContext( + override val event: UserCommandInteractionCreateEvent, + override val command: UserCommand, + override val interactionResponse: EphemeralInteractionResponseBehavior +) : UserCommandContext(event, command), EphemeralInteractionContext diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/PublicUserCommand.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/PublicUserCommand.kt new file mode 100644 index 0000000000..e0b862c903 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/PublicUserCommand.kt @@ -0,0 +1,103 @@ +@file:Suppress("TooGenericExceptionCaught") + +package com.kotlindiscord.kord.extensions.commands.application.user + +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.events.PublicUserCommandFailedChecksEvent +import com.kotlindiscord.kord.extensions.commands.events.PublicUserCommandFailedWithExceptionEvent +import com.kotlindiscord.kord.extensions.commands.events.PublicUserCommandInvocationEvent +import com.kotlindiscord.kord.extensions.commands.events.PublicUserCommandSucceededEvent +import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.types.FailureReason +import com.kotlindiscord.kord.extensions.types.respond +import dev.kord.core.behavior.interaction.respondEphemeral +import dev.kord.core.behavior.interaction.respondPublic +import dev.kord.core.event.interaction.UserCommandInteractionCreateEvent +import dev.kord.rest.builder.message.create.InteractionResponseCreateBuilder + +public typealias InitialPublicUserResponseBuilder = + (suspend InteractionResponseCreateBuilder.(UserCommandInteractionCreateEvent) -> Unit)? + +/** Public user command. **/ +public class PublicUserCommand( + extension: Extension +) : UserCommand(extension) { + /** @suppress Internal guilder **/ + public var initialResponseBuilder: InitialPublicUserResponseBuilder = null + + /** Call this to open with a response, omit it to ack instead. **/ + public fun initialResponse(body: InitialPublicUserResponseBuilder) { + initialResponseBuilder = body + } + + override suspend fun call(event: UserCommandInteractionCreateEvent) { + emitEventAsync(PublicUserCommandInvocationEvent(this, event)) + + try { + if (!runChecks(event)) { + emitEventAsync( + PublicUserCommandFailedChecksEvent( + this, + event, + "Checks failed without a message." + ) + ) + + return + } + } catch (e: DiscordRelayedException) { + event.interaction.respondEphemeral { + settings.failureResponseBuilder(this, e.reason, FailureReason.ProvidedCheckFailure(e)) + } + + emitEventAsync(PublicUserCommandFailedChecksEvent(this, event, e.reason)) + + return + } + + val response = if (initialResponseBuilder != null) { + event.interaction.respondPublic { initialResponseBuilder!!(event) } + } else { + event.interaction.acknowledgePublic() + } + + val context = PublicUserCommandContext(event, this, response) + + context.populate() + + firstSentryBreadcrumb(context) + + try { + checkBotPerms(context) + } catch (e: DiscordRelayedException) { + respondText(context, e.reason, FailureReason.OwnPermissionsCheckFailure(e)) + emitEventAsync(PublicUserCommandFailedChecksEvent(this, event, e.reason)) + + return + } + + try { + body(context) + } catch (t: Throwable) { + emitEventAsync(PublicUserCommandFailedWithExceptionEvent(this, event, t)) + + if (t is DiscordRelayedException) { + respondText(context, t.reason, FailureReason.RelayedFailure(t)) + + return + } + + handleError(context, t) + } + + emitEventAsync(PublicUserCommandSucceededEvent(this, event)) + } + + override suspend fun respondText( + context: PublicUserCommandContext, + message: String, + failureType: FailureReason<*> + ) { + context.respond { settings.failureResponseBuilder(this, message, failureType) } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/PublicUserCommandContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/PublicUserCommandContext.kt new file mode 100644 index 0000000000..4368449d64 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/PublicUserCommandContext.kt @@ -0,0 +1,12 @@ +package com.kotlindiscord.kord.extensions.commands.application.user + +import com.kotlindiscord.kord.extensions.types.PublicInteractionContext +import dev.kord.core.behavior.interaction.PublicInteractionResponseBehavior +import dev.kord.core.event.interaction.UserCommandInteractionCreateEvent + +/** Public-only user command context. **/ +public class PublicUserCommandContext( + override val event: UserCommandInteractionCreateEvent, + override val command: UserCommand, + override val interactionResponse: PublicInteractionResponseBehavior +) : UserCommandContext(event, command), PublicInteractionContext diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/UserCommand.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/UserCommand.kt new file mode 100644 index 0000000000..4bdb353f06 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/UserCommand.kt @@ -0,0 +1,188 @@ +package com.kotlindiscord.kord.extensions.commands.application.user + +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.InvalidCommandException +import com.kotlindiscord.kord.extensions.checks.types.CheckContext +import com.kotlindiscord.kord.extensions.commands.application.ApplicationCommand +import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.sentry.BreadcrumbType +import com.kotlindiscord.kord.extensions.sentry.tag +import com.kotlindiscord.kord.extensions.sentry.user +import com.kotlindiscord.kord.extensions.types.FailureReason +import com.kotlindiscord.kord.extensions.utils.getLocale +import com.kotlindiscord.kord.extensions.utils.permissionsForMember +import com.kotlindiscord.kord.extensions.utils.translate +import dev.kord.common.entity.ApplicationCommandType +import dev.kord.core.entity.channel.DmChannel +import dev.kord.core.entity.channel.GuildChannel +import dev.kord.core.entity.channel.GuildMessageChannel +import dev.kord.core.event.interaction.UserCommandInteractionCreateEvent +import mu.KLogger +import mu.KotlinLogging + +/** User context command, for right-click actions on users. **/ +public abstract class UserCommand>( + extension: Extension +) : ApplicationCommand(extension) { + private val logger: KLogger = KotlinLogging.logger {} + + /** Command body, to be called when the command is executed. **/ + public lateinit var body: suspend C.() -> Unit + + override val type: ApplicationCommandType = ApplicationCommandType.User + + /** Call this to supply a command [body], to be called when the command is executed. **/ + public fun action(action: suspend C.() -> Unit) { + body = action + } + + override fun validate() { + super.validate() + + if (!::body.isInitialized) { + throw InvalidCommandException(name, "No command body given.") + } + } + + /** Override this to implement your command's calling logic. Check subtypes for examples! **/ + public abstract override suspend fun call(event: UserCommandInteractionCreateEvent) + + /** Override this to implement a way to respond to the user, regardless of whatever happens. **/ + public abstract suspend fun respondText(context: C, message: String, failureType: FailureReason<*>) + + /** Checks whether the bot has the specified required permissions, throwing if it doesn't. **/ + @Throws(DiscordRelayedException::class) + public open suspend fun checkBotPerms(context: C) { + if (requiredPerms.isEmpty()) { + return // Nothing to check, don't try to hit the cache + } + + if (context.guild != null) { + val perms = (context.channel.asChannel() as GuildChannel) + .permissionsForMember(kord.selfId) + + val missingPerms = requiredPerms.filter { !perms.contains(it) } + + if (missingPerms.isNotEmpty()) { + throw DiscordRelayedException( + context.translate( + "commands.error.missingBotPermissions", + null, + + replacements = arrayOf( + missingPerms.map { it.translate(context.getLocale()) }.joinToString(", ") + ) + ) + ) + } + } + } + + /** If enabled, adds the initial Sentry breadcrumb to the given context. **/ + public open suspend fun firstSentryBreadcrumb(context: C) { + if (sentry.enabled) { + context.sentry.breadcrumb(BreadcrumbType.User) { + category = "command.application.user" + message = "User command \"$name\" called." + + val channel = context.channel.asChannelOrNull() + val guild = context.guild?.asGuildOrNull() + + data["command"] = name + + if (guildId != null) { + data["command.guild"] = guildId!!.asString + } + + if (channel != null) { + data["channel"] = when (channel) { + is DmChannel -> "Private Message (${channel.id.asString})" + is GuildMessageChannel -> "#${channel.name} (${channel.id.asString})" + + else -> channel.id.asString + } + } + + if (guild != null) { + data["guild"] = "${guild.name} (${guild.id.asString})" + } + } + } + } + + override suspend fun runChecks(event: UserCommandInteractionCreateEvent): Boolean { + val locale = event.getLocale() + val result = super.runChecks(event) + + if (result) { + settings.applicationCommandsBuilder.userCommandChecks.forEach { check -> + val context = CheckContext(event, locale) + + check(context) + + if (!context.passed) { + context.throwIfFailedWithMessage() + + return false + } + } + + extension.userCommandChecks.forEach { check -> + val context = CheckContext(event, locale) + + check(context) + + if (!context.passed) { + context.throwIfFailedWithMessage() + + return false + } + } + } + + return result + } + + /** A general way to handle errors thrown during the course of a command's execution. **/ + public open suspend fun handleError(context: C, t: Throwable) { + logger.error(t) { "Error during execution of $name user command (${context.event})" } + + if (sentry.enabled) { + logger.trace { "Submitting error to sentry." } + + val channel = context.channel + val author = context.user.asUserOrNull() + + val sentryId = context.sentry.captureException(t, "User command execution failed.") { + if (author != null) { + user(author) + } + + tag("private", "false") + + if (channel is DmChannel) { + tag("private", "true") + } + + tag("command", name) + tag("extension", extension.name) + } + + logger.info { "Error submitted to Sentry: $sentryId" } + + val errorMessage = if (extension.bot.extensions.containsKey("sentry")) { + context.translate("commands.error.user.sentry.slash", null, replacements = arrayOf(sentryId)) + } else { + context.translate("commands.error.user", null) + } + + respondText(context, errorMessage, FailureReason.ExecutionError(t)) + } else { + respondText( + context, + context.translate("commands.error.user", null), + FailureReason.ExecutionError(t) + ) + } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/UserCommandContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/UserCommandContext.kt new file mode 100644 index 0000000000..71b69ac070 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/application/user/UserCommandContext.kt @@ -0,0 +1,19 @@ +package com.kotlindiscord.kord.extensions.commands.application.user + +import com.kotlindiscord.kord.extensions.commands.application.ApplicationCommandContext +import dev.kord.core.entity.User +import dev.kord.core.event.interaction.UserCommandInteractionCreateEvent + +/** + * User command context, containing everything you need for your user command's execution. + * + * @param event Event that triggered this message command. + * @param command Message command instance. + */ +public abstract class UserCommandContext>( + public open val event: UserCommandInteractionCreateEvent, + public override val command: UserCommand +) : ApplicationCommandContext(event, command) { + /** Messages that this message command is being executed against. **/ + public val targetUsers: Collection by lazy { event.interaction.users?.values ?: listOf() } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/MessageCommand.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatCommand.kt similarity index 58% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/MessageCommand.kt rename to kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatCommand.kt index 7c43ea8669..6bfedd4ec9 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/MessageCommand.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatCommand.kt @@ -1,21 +1,26 @@ -@file:Suppress("StringLiteralDuplication") +@file:Suppress("StringLiteralDuplication", "TooGenericExceptionCaught") -package com.kotlindiscord.kord.extensions.commands +package com.kotlindiscord.kord.extensions.commands.chat -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.ArgumentParsingException +import com.kotlindiscord.kord.extensions.DiscordRelayedException import com.kotlindiscord.kord.extensions.InvalidCommandException import com.kotlindiscord.kord.extensions.annotations.ExtensionDSL +import com.kotlindiscord.kord.extensions.builders.ExtensibleBotBuilder import com.kotlindiscord.kord.extensions.checks.types.Check import com.kotlindiscord.kord.extensions.checks.types.CheckContext -import com.kotlindiscord.kord.extensions.commands.parser.ArgumentParser -import com.kotlindiscord.kord.extensions.commands.parser.Arguments +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.Command +import com.kotlindiscord.kord.extensions.commands.events.* import com.kotlindiscord.kord.extensions.extensions.Extension import com.kotlindiscord.kord.extensions.i18n.EMPTY_VALUE_STRING import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider import com.kotlindiscord.kord.extensions.parser.StringParser +import com.kotlindiscord.kord.extensions.sentry.BreadcrumbType import com.kotlindiscord.kord.extensions.sentry.SentryAdapter import com.kotlindiscord.kord.extensions.sentry.tag import com.kotlindiscord.kord.extensions.sentry.user +import com.kotlindiscord.kord.extensions.types.FailureReason import com.kotlindiscord.kord.extensions.utils.getLocale import com.kotlindiscord.kord.extensions.utils.permissionsForMember import com.kotlindiscord.kord.extensions.utils.respond @@ -26,8 +31,6 @@ import dev.kord.core.entity.channel.DmChannel import dev.kord.core.entity.channel.GuildChannel import dev.kord.core.entity.channel.GuildMessageChannel import dev.kord.core.event.message.MessageCreateEvent -import io.sentry.Sentry -import io.sentry.protocol.SentryId import mu.KotlinLogging import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -36,25 +39,28 @@ import java.util.* private val logger = KotlinLogging.logger {} /** - * Class representing a message command. + * Class representing a chat command. * * You shouldn't need to use this class directly - instead, create an [Extension] and use the - * [command function][Extension.command] to register your command, by overriding the [Extension.setup] + * `chatCommand` function to register your command, by overriding the [Extension.setup] * function. * * @param extension The [Extension] that registered this command. * @param arguments Arguments object builder for this command, if it has arguments. */ @ExtensionDSL -public open class MessageCommand( +public open class ChatCommand( extension: Extension, public open val arguments: (() -> T)? = null ) : Command(extension), KoinComponent { /** Translations provider, for retrieving translations. **/ public val translationsProvider: TranslationsProvider by inject() + /** Bot settings object. **/ + public val settings: ExtensibleBotBuilder by inject() + /** Message command registry. **/ - public val messageCommandRegistry: MessageCommandRegistry by inject() + public val registry: ChatCommandRegistry by inject() /** Sentry adapter, for easy access to Sentry functions. **/ public val sentry: SentryAdapter by inject() @@ -65,7 +71,7 @@ public open class MessageCommand( /** * @suppress */ - public open lateinit var body: suspend MessageCommandContext.() -> Unit + public open lateinit var body: suspend ChatCommandContext.() -> Unit /** * A description of what this function and how it's intended to be used. @@ -121,8 +127,6 @@ public open class MessageCommand( */ public open val checkList: MutableList> = mutableListOf() - override val parser: ArgumentParser = ArgumentParser() - /** Permissions required to be able to run this command. **/ public open val requiredPerms: MutableSet = mutableSetOf() @@ -141,7 +145,7 @@ public open class MessageCommand( /** * Retrieve the command signature for a locale, which specifies how the command's arguments should be structured. * - * Command signatures are generated automatically by the [ArgumentParser]. + * Command signatures are generated automatically by the [ChatCommandParser]. */ public open suspend fun getSignature(locale: Locale): String { if (this.arguments == null) { @@ -156,7 +160,7 @@ public open class MessageCommand( locale ) } else { - signatureCache[locale] = parser.signature(arguments!!, locale) + signatureCache[locale] = registry.parser.signature(arguments!!, locale) } } @@ -213,7 +217,7 @@ public open class MessageCommand( } /** If your bot requires permissions to be able to execute the command, add them using this function. **/ - public fun requirePermissions(vararg perms: Permission) { + public fun requireBotPermissions(vararg perms: Permission) { perms.forEach { requiredPerms.add(it) } } @@ -224,7 +228,7 @@ public open class MessageCommand( * * @param action The body of your command, which will be executed when your command is invoked. */ - public open fun action(action: suspend MessageCommandContext.() -> Unit) { + public open fun action(action: suspend ChatCommandContext.() -> Unit) { this.body = action } @@ -252,44 +256,6 @@ public open class MessageCommand( checkList.add(check) } - /** - * Define a simple Boolean check which must pass for the command to be executed. - * - * Boolean checks are simple wrappers around the regular check system, allowing you to define a basic check that - * takes an event object and returns a [Boolean] representing whether it passed. This style of check does not have - * the same functionality as a regular check, and cannot return a message. - * - * A command may have multiple checks - all checks must pass for the command to be executed. - * Checks will be run in the order that they're defined. - * - * This function can be used DSL-style with a given body, or it can be passed one or more - * predefined functions. See the samples for more information. - * - * @param checks Checks to apply to this command. - */ - public open fun booleanCheck(vararg checks: suspend (MessageCreateEvent) -> Boolean) { - checks.forEach(::booleanCheck) - } - - /** - * Overloaded simple Boolean check function to allow for DSL syntax. - * - * Boolean checks are simple wrappers around the regular check system, allowing you to define a basic check that - * takes an event object and returns a [Boolean] representing whether it passed. This style of check does not have - * the same functionality as a regular check, and cannot return a message. - * - * @param check Check to apply to this command. - */ - public open fun booleanCheck(check: suspend (MessageCreateEvent) -> Boolean) { - check { - if (check(event)) { - pass() - } else { - fail() - } - } - } - // endregion /** Run checks with the provided [MessageCreateEvent]. Return false if any failed, true otherwise. **/ @@ -297,21 +263,25 @@ public open class MessageCommand( val locale = event.getLocale() // global command checks - for (check in extension.bot.settings.messageCommandsBuilder.checkList) { + for (check in extension.bot.settings.chatCommandsBuilder.checkList) { val context = CheckContext(event, locale) check(context) if (!context.passed) { - val message = context.message + val message = context.getTranslatedMessage() if (message != null && sendMessage) { - event.message.respond( - translationsProvider.translate( - "checks.responseTemplate", - replacements = arrayOf(message) + event.message.respond { + settings.failureResponseBuilder( + this, + message, + + FailureReason.ProvidedCheckFailure( + DiscordRelayedException(message, context.errorResponseKey) + ) ) - ) + } } return false @@ -319,7 +289,7 @@ public open class MessageCommand( } // local extension checks - for (check in extension.commandChecks) { + for (check in extension.chatCommandChecks) { val context = CheckContext(event, locale) check(context) @@ -328,12 +298,16 @@ public open class MessageCommand( val message = context.message if (message != null && sendMessage) { - event.message.respond( - translationsProvider.translate( - "checks.responseTemplate", - replacements = arrayOf(message) + event.message.respond { + settings.failureResponseBuilder( + this, + message, + + FailureReason.ProvidedCheckFailure( + DiscordRelayedException(message, context.errorResponseKey) + ) ) - ) + } } return false @@ -349,12 +323,16 @@ public open class MessageCommand( val message = context.message if (message != null && sendMessage) { - event.message.respond( - translationsProvider.translate( - "checks.responseTemplate", - replacements = arrayOf(message) + event.message.respond { + settings.failureResponseBuilder( + this, + message, + + FailureReason.ProvidedCheckFailure( + DiscordRelayedException(message, context.errorResponseKey) + ) ) - ) + } } return false @@ -364,6 +342,34 @@ public open class MessageCommand( return true } + /** Checks whether the bot has the specified required permissions, throwing if it doesn't. **/ + @Throws(DiscordRelayedException::class) + public open suspend fun checkBotPerms(context: ChatCommandContext) { + if (requiredPerms.isEmpty()) { + return // Nothing to check, don't try to hit the cache + } + + if (context.guild != null) { + val perms = (context.channel.asChannel() as GuildChannel) + .permissionsForMember(kord.selfId) + + val missingPerms = requiredPerms.filter { !perms.contains(it) } + + if (missingPerms.isNotEmpty()) { + throw DiscordRelayedException( + context.translate( + "commands.error.missingBotPermissions", + null, + + replacements = arrayOf( + missingPerms.map { it.translate(context.getLocale()) }.joinToString(", ") + ) + ) + ) + } + } + } + /** * Execute this command, given a [MessageCreateEvent]. * @@ -376,7 +382,8 @@ public open class MessageCommand( * * @param event The message creation event. * @param commandName The name used to invoke this command. - * @param args Array of command arguments. + * @param parser Parser used to parse the command's arguments, available for further parsing. + * @param argString Original string containing the command's arguments. * @param skipChecks Whether to skip testing the command's checks. */ public open suspend fun call( @@ -385,144 +392,179 @@ public open class MessageCommand( parser: StringParser, argString: String, skipChecks: Boolean = false - ) { - if (!skipChecks && !runChecks(event)) { - return + ): Unit = withLock { + emitEventAsync(ChatCommandInvocationEvent(this, event)) + + try { + if (!skipChecks && !runChecks(event)) { + emitEventAsync( + ChatCommandFailedChecksEvent( + this, + event, + "Checks failed without a message." + ) + ) + + return@withLock + } + } catch (e: DiscordRelayedException) { + emitEventAsync(ChatCommandFailedChecksEvent(this, event, e.reason)) + + event.message.respond { + settings.failureResponseBuilder(this, e.reason, FailureReason.ProvidedCheckFailure(e)) + } + + return@withLock } - val context = MessageCommandContext(this, event, commandName, parser, argString) + val context = ChatCommandContext(this, event, commandName, parser, argString) context.populate() - val firstBreadcrumb = if (sentry.enabled) { - val channel = event.message.getChannelOrNull() - val guild = event.message.getGuildOrNull() + if (sentry.enabled) { + context.sentry.breadcrumb(BreadcrumbType.User) { + category = "command.chat" + message = "Command \"$name\" called." - val data = mutableMapOf( - "arguments" to argString, - "message" to event.message.content - ) + val channel = event.message.getChannelOrNull() + val guild = event.message.getGuildOrNull() + + data["arguments"] = argString + data["message"] = event.message.content - if (channel != null) { - data["channel"] = when (channel) { - is DmChannel -> "Private Message (${channel.id.asString})" - is GuildMessageChannel -> "#${channel.name} (${channel.id.asString})" + if (channel != null) { + data["channel"] = when (channel) { + is DmChannel -> "Private Message (${channel.id.asString})" + is GuildMessageChannel -> "#${channel.name} (${channel.id.asString})" - else -> channel.id.asString + else -> channel.id.asString + } } - } - if (guild != null) { - data["guild"] = "${guild.name} (${guild.id.asString})" + if (guild != null) { + data["guild"] = "${guild.name} (${guild.id.asString})" + } } - - sentry.createBreadcrumb( - category = "command", - type = "user", - message = "Command \"$name\" called.", - data = data - ) - } else { - null } - @Suppress("TooGenericExceptionCaught") // Anything could happen here try { - if (context.guild != null) { - val perms = (context.channel.asChannel() as GuildChannel) - .permissionsForMember(kord.selfId) - - val missingPerms = requiredPerms.filter { !perms.contains(it) } - - if (missingPerms.isNotEmpty()) { - throw CommandException( - context.translate( - "commands.error.missingBotPermissions", - null, - replacements = arrayOf( - missingPerms.map { it.translate(context) }.joinToString(", ") - ) - ) - ) - } + checkBotPerms(context) + } catch (e: DiscordRelayedException) { + event.message.respond { + settings.failureResponseBuilder(this, e.reason, FailureReason.OwnPermissionsCheckFailure(e)) } - if (this.arguments != null) { - val parsedArgs = this.parser.parse(this.arguments!!, context) + emitEventAsync(ChatCommandFailedChecksEvent(this, event, e.reason)) + + return@withLock + } + + if (this.arguments != null) { + try { + val parsedArgs = registry.parser.parse(this.arguments!!, context) context.populateArgs(parsedArgs) + } catch (e: ArgumentParsingException) { + event.message.respond { + settings.failureResponseBuilder(this, e.reason, FailureReason.ArgumentParsingFailure(e)) + } + + emitEventAsync(ChatCommandFailedParsingEvent(this, event, e)) + + return@withLock } + } + try { this.body(context) - } catch (e: CommandException) { - event.message.respond(e.toString()) } catch (t: Throwable) { + emitEventAsync(ChatCommandFailedWithExceptionEvent(this, event, t)) + + if (t is DiscordRelayedException) { + event.message.respond { + settings.failureResponseBuilder(this, t.reason, FailureReason.RelayedFailure(t)) + } + + return@withLock + } + if (sentry.enabled) { - logger.debug { "Submitting error to sentry." } + logger.trace { "Submitting error to sentry." } - lateinit var sentryId: SentryId val channel = event.message.getChannelOrNull() val translatedName = when (this) { - is MessageSubCommand -> this.getFullTranslatedName(context.getLocale()) - is GroupCommand -> this.getFullTranslatedName(context.getLocale()) + is ChatSubCommand -> this.getFullTranslatedName(context.getLocale()) + is ChatGroupCommand -> this.getFullTranslatedName(context.getLocale()) else -> this.getTranslatedName(context.getLocale()) } - Sentry.withScope { + val sentryId = context.sentry.captureException(t, "MessageCommand execution failed.") { val author = event.message.author if (author != null) { - it.user(author) + user(author) } - it.tag("private", "false") + tag("private", "false") if (channel is DmChannel) { - it.tag("private", "true") + tag("private", "true") } - it.tag("command", translatedName) - it.tag("extension", extension.name) - - it.addBreadcrumb(firstBreadcrumb!!) - - context.breadcrumbs.forEach { breadcrumb -> it.addBreadcrumb(breadcrumb) } - - sentryId = Sentry.captureException(t, "MessageCommand execution failed.") - - logger.debug { "Error submitted to Sentry: $sentryId" } + tag("command", translatedName) + tag("extension", extension.name) } + logger.info { "Error submitted to Sentry: $sentryId" } + sentry.addEventId(sentryId) logger.error(t) { "Error during execution of $name command ($event)" } if (extension.bot.extensions.containsKey("sentry")) { - val prefix = messageCommandRegistry.getPrefix(event) - - event.message.respond( - context.translate( - "commands.error.user.sentry.message", - null, - replacements = arrayOf( - prefix, - sentryId - ) + val prefix = registry.getPrefix(event) + + event.message.respond { + settings.failureResponseBuilder( + this, + + context.translate( + "commands.error.user.sentry.message", + null, + replacements = arrayOf( + prefix, + sentryId + ) + ), + + FailureReason.ExecutionError(t) ) - ) + } } else { - event.message.respond( - context.translate("commands.error.user", null) - ) + event.message.respond { + settings.failureResponseBuilder( + this, + context.translate("commands.error.user", null), + FailureReason.ExecutionError(t) + ) + } } } else { logger.error(t) { "Error during execution of $name command ($event)" } - event.message.respond( - context.translate("commands.error.user", null) - ) + event.message.respond { + settings.failureResponseBuilder( + this, + context.translate("commands.error.user", null), + FailureReason.ExecutionError(t) + ) + } } + + return@withLock } + + emitEventAsync(ChatCommandSucceededEvent(this, event)) } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/MessageCommandContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatCommandContext.kt similarity index 57% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/MessageCommandContext.kt rename to kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatCommandContext.kt index eda74434d1..f3f43c96da 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/MessageCommandContext.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatCommandContext.kt @@ -1,39 +1,40 @@ -@file:OptIn(KordPreview::class) +@file:OptIn(KordPreview::class, KordUnsafe::class, KordExperimental::class) -package com.kotlindiscord.kord.extensions.commands +package com.kotlindiscord.kord.extensions.commands.chat import com.kotlindiscord.kord.extensions.annotations.ExtensionDSL -import com.kotlindiscord.kord.extensions.commands.parser.Arguments -import com.kotlindiscord.kord.extensions.components.Components +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.extensions.base.HelpProvider import com.kotlindiscord.kord.extensions.pagination.MessageButtonPaginator import com.kotlindiscord.kord.extensions.pagination.builders.PaginatorBuilder import com.kotlindiscord.kord.extensions.parser.StringParser import com.kotlindiscord.kord.extensions.utils.respond +import dev.kord.common.annotation.KordExperimental import dev.kord.common.annotation.KordPreview +import dev.kord.common.annotation.KordUnsafe +import dev.kord.core.behavior.GuildBehavior +import dev.kord.core.behavior.MemberBehavior +import dev.kord.core.behavior.UserBehavior import dev.kord.core.behavior.channel.MessageChannelBehavior -import dev.kord.core.entity.Guild -import dev.kord.core.entity.Member import dev.kord.core.entity.Message -import dev.kord.core.entity.User import dev.kord.core.event.message.MessageCreateEvent -import dev.kord.rest.builder.message.create.MessageCreateBuilder -import dev.kord.rest.builder.message.modify.MessageModifyBuilder /** - * Command context object representing the context given to message commands. + * Command context object representing the context given to chat commands. * - * @property messageCommand Message command object, typed as [MessageCommand] rather than [Command] + * @property messageCommand Chat command object + * @param parser String parser instance, if any - will be `null` if this isn't a chat command. * @property argString String containing the command's unparsed arguments, raw, fresh from Discord itself. */ @ExtensionDSL -public open class MessageCommandContext( - public val messageCommand: MessageCommand, +public open class ChatCommandContext( + public val messageCommand: ChatCommand, eventObj: MessageCreateEvent, commandName: String, - parser: StringParser, + public open val parser: StringParser, public val argString: String -) : CommandContext(messageCommand, eventObj, commandName, parser) { +) : CommandContext(messageCommand, eventObj, commandName) { /** Event that triggered this command execution. **/ public val event: MessageCreateEvent get() = eventObj as MessageCreateEvent @@ -41,13 +42,13 @@ public open class MessageCommandContext( public open lateinit var channel: MessageChannelBehavior /** Guild this command happened in, if any. **/ - public open var guild: Guild? = null + public open var guild: GuildBehavior? = null /** Guild member responsible for executing this command, if any. **/ - public open var member: Member? = null + public open var member: MemberBehavior? = null /** User responsible for executing this command, if any (if `null`, it's a webhook). **/ - public open var user: User? = null + public open var user: UserBehavior? = null /** Message object containing this command invocation. **/ public open lateinit var message: Message @@ -69,11 +70,14 @@ public open class MessageCommandContext( arguments = args } - override suspend fun getChannel(): MessageChannelBehavior = event.message.channel.asChannel() - override suspend fun getGuild(): Guild? = event.getGuild() - override suspend fun getMember(): Member? = event.message.getAuthorAsMember() - override suspend fun getMessage(): Message = event.message - override suspend fun getUser(): User? = event.message.author + override suspend fun getChannel(): MessageChannelBehavior = event.message.channel + override suspend fun getGuild(): GuildBehavior? = event.guildId + ?.let { event.kord.unsafe.guild(it) } + override suspend fun getMember(): MemberBehavior? = event.member + override suspend fun getUser(): UserBehavior? = event.message.author + + /** Extract message information from event data, if that context is available. **/ + public open suspend fun getMessage(): Message = event.message /** * Convenience function to create a button paginator using a builder DSL syntax. Handles the contextual stuff for @@ -88,7 +92,7 @@ public open class MessageCommandContext( body: suspend PaginatorBuilder.() -> Unit ): MessageButtonPaginator { - val builder = PaginatorBuilder(command.extension, getLocale(), defaultGroup = defaultGroup) + val builder = PaginatorBuilder(getLocale(), defaultGroup = defaultGroup) body(builder) @@ -117,45 +121,5 @@ public open class MessageCommandContext( key: String, replacements: Array = arrayOf(), useReply: Boolean = true - ): Message = respond(translate(key, replacements), useReply) - - /** - * Convenience function for adding components to your message via the [Components] class. - * - * @see Components - */ - public suspend fun MessageCreateBuilder.components( - timeoutSeconds: Long? = null, - body: suspend Components.() -> Unit - ): Components { - val components = Components(command.extension) - - body(components) - - with(components) { - setup(timeoutSeconds) - } - - return components - } - - /** - * Convenience function for adding components to your message via the [Components] class. - * - * @see Components - */ - public suspend fun MessageModifyBuilder.components( - timeoutSeconds: Long? = null, - body: suspend Components.() -> Unit - ): Components { - val components = Components(command.extension) - - body(components) - - with(components) { - setup(timeoutSeconds) - } - - return components - } + ): Message = respond(translate(key, command.extension.bundle, replacements), useReply) } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/parser/ArgumentParser.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatCommandParser.kt similarity index 73% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/parser/ArgumentParser.kt rename to kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatCommandParser.kt index 58108a0dcc..da5ebb3cf7 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/parser/ArgumentParser.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatCommandParser.kt @@ -5,11 +5,15 @@ "StringLiteralDuplication" ) -package com.kotlindiscord.kord.extensions.commands.parser +package com.kotlindiscord.kord.extensions.commands.chat -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.ArgumentParsingException +import com.kotlindiscord.kord.extensions.DiscordRelayedException import com.kotlindiscord.kord.extensions.ExtensibleBot +import com.kotlindiscord.kord.extensions.commands.Argument +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.CommandContext +import com.kotlindiscord.kord.extensions.commands.application.slash.converters.ChoiceConverter import com.kotlindiscord.kord.extensions.commands.converters.* import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider import dev.kord.common.annotation.KordPreview @@ -34,7 +38,7 @@ private val logger = KotlinLogging.logger {} * * We recommend reading over the source code if you'd like to get to grips with how this all works. */ -public open class ArgumentParser : KoinComponent { +public open class ChatCommandParser : KoinComponent { /** Current instance of the bot. **/ public open val bot: ExtensibleBot by inject() @@ -61,13 +65,15 @@ public open class ArgumentParser : KoinComponent { * @param context MessageCommand context for this command invocation. * * @return Built [Arguments] object, with converters filled. - * @throws CommandException Thrown based on a lot of possible cases. This is intended for display on Discord. + * @throws DiscordRelayedException Thrown based on a lot of possible cases. This is intended for display on Discord. */ - public open suspend fun parse(builder: () -> T, context: CommandContext): T { + public open suspend fun parse(builder: () -> T, context: ChatCommandContext<*>): T { val argumentsObj = builder.invoke() + argumentsObj.validate() + val parser = context.parser!! - logger.debug { "Arguments object: $argumentsObj (${argumentsObj.args.size} args)" } + logger.trace { "Arguments object: $argumentsObj (${argumentsObj.args.size} args)" } val args = argumentsObj.args.toMutableList() val argsMap = args.map { Pair(it.displayName.lowercase(), it) }.toMap() @@ -80,7 +86,7 @@ public open class ArgumentParser : KoinComponent { keywordArgs[name]!!.add(it.data) } - logger.debug { "Args map: $argsMap" } + logger.trace { "Args map: $argsMap" } var currentArg: Argument<*>? @@ -92,22 +98,30 @@ public open class ArgumentParser : KoinComponent { val kwValue = keywordArgs[currentArg.displayName.lowercase()] val hasKwargs = kwValue != null - logger.debug { "Current argument: ${currentArg.displayName}" } - logger.debug { "Keyword arg ($hasKwargs): $kwValue" } + logger.trace { "Current argument: ${currentArg.displayName}" } + logger.trace { "Keyword arg ($hasKwargs): $kwValue" } if (!parser.cursor.hasNext && !hasKwargs) { continue } when (val converter = currentArg.converter) { + is ChoiceConverter<*> -> error("Choice converters may only be used with slash commands") + is SingleConverter<*> -> try { val parsed = if (hasKwargs) { if (kwValue!!.size != 1) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.requiresOneValue", replacements = arrayOf(currentArg.displayName, kwValue.size) - ) + ), + + "argumentParser.error.requiresOneValue", + + currentArg, + argumentsObj, + parser ) } @@ -117,7 +131,7 @@ public open class ArgumentParser : KoinComponent { } if ((converter.required || hasKwargs) && !parsed) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.invalidValue", @@ -125,20 +139,26 @@ public open class ArgumentParser : KoinComponent { currentArg.displayName, converter.getErrorString(context), ) - ) + ), + + "argumentParser.error.invalidValue", + + currentArg, + argumentsObj, + parser ) } if (parsed) { - logger.debug { "Argument ${currentArg.displayName} successfully filled." } + logger.trace { "Argument ${currentArg.displayName} successfully filled." } converter.parseSuccess = true converter.validate(context) } - } catch (e: CommandException) { + } catch (e: DiscordRelayedException) { if (converter.required || hasKwargs) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.errorInArgument", @@ -147,7 +167,13 @@ public open class ArgumentParser : KoinComponent { converter.handleError(e, context) ) - ) + ), + + "argumentParser.error.errorInArgument", + + currentArg, + argumentsObj, + parser ) } } catch (t: Throwable) { @@ -161,11 +187,17 @@ public open class ArgumentParser : KoinComponent { is DefaultingConverter<*> -> try { val parsed = if (hasKwargs) { if (kwValue!!.size != 1) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.requiresOneValue", replacements = arrayOf(currentArg.displayName, kwValue.size) - ) + ), + + "argumentParser.error.requiresOneValue", + + currentArg, + argumentsObj, + parser ) } @@ -175,15 +207,15 @@ public open class ArgumentParser : KoinComponent { } if (parsed) { - logger.debug { "Argument ${currentArg.displayName} successfully filled." } + logger.trace { "Argument ${currentArg.displayName} successfully filled." } converter.parseSuccess = true converter.validate(context) } - } catch (e: CommandException) { + } catch (e: DiscordRelayedException) { if (converter.required || converter.outputError || hasKwargs) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.errorInArgument", @@ -192,7 +224,13 @@ public open class ArgumentParser : KoinComponent { converter.handleError(e, context) ) - ) + ), + + "argumentParser.error.errorInArgument", + + currentArg, + argumentsObj, + parser ) } } catch (t: Throwable) { @@ -202,11 +240,17 @@ public open class ArgumentParser : KoinComponent { is OptionalConverter<*> -> try { val parsed = if (hasKwargs) { if (kwValue!!.size != 1) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.requiresOneValue", replacements = arrayOf(currentArg.displayName, kwValue.size) - ) + ), + + "argumentParser.error.requiresOneValue", + + currentArg, + argumentsObj, + parser ) } @@ -216,15 +260,15 @@ public open class ArgumentParser : KoinComponent { } if (parsed) { - logger.debug { "Argument ${currentArg.displayName} successfully filled." } + logger.trace { "Argument ${currentArg.displayName} successfully filled." } converter.parseSuccess = true converter.validate(context) } - } catch (e: CommandException) { + } catch (e: DiscordRelayedException) { if (converter.required || converter.outputError || hasKwargs) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.errorInArgument", @@ -233,7 +277,13 @@ public open class ArgumentParser : KoinComponent { converter.handleError(e, context) ) - ) + ), + + "argumentParser.error.errorInArgument", + + currentArg, + argumentsObj, + parser ) } } catch (t: Throwable) { @@ -252,7 +302,7 @@ public open class ArgumentParser : KoinComponent { } if ((converter.required || hasKwargs) && parsedCount <= 0) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.invalidValue", @@ -260,13 +310,19 @@ public open class ArgumentParser : KoinComponent { currentArg.displayName, converter.getErrorString(context) ) - ) + ), + + "argumentParser.error.invalidValue", + + currentArg, + argumentsObj, + parser ) } if (hasKwargs) { if (parsedCount < kwValue!!.size) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.notAllValid", @@ -276,23 +332,29 @@ public open class ArgumentParser : KoinComponent { parsedCount, context.translate(converter.signatureTypeString, bundleName = converter.bundle) ) - ) + ), + + "argumentParser.error.notAllValid", + + currentArg, + argumentsObj, + parser ) } converter.parseSuccess = true } else { if (parsedCount > 0) { - logger.debug { "Argument ${currentArg.displayName} successfully filled." } + logger.trace { "Argument ${currentArg.displayName} successfully filled." } converter.parseSuccess = true converter.validate(context) } } - } catch (e: CommandException) { + } catch (e: DiscordRelayedException) { if (converter.required) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.errorInArgument", @@ -301,7 +363,13 @@ public open class ArgumentParser : KoinComponent { converter.handleError(e, context) ) - ) + ), + + "argumentParser.error.errorInArgument", + + currentArg, + argumentsObj, + parser ) } } catch (t: Throwable) { @@ -320,7 +388,7 @@ public open class ArgumentParser : KoinComponent { } if ((converter.required || hasKwargs) && parsedCount <= 0) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.invalidValue", @@ -328,13 +396,19 @@ public open class ArgumentParser : KoinComponent { currentArg.displayName, converter.getErrorString(context), ) - ) + ), + + "argumentParser.error.invalidValue", + + currentArg, + argumentsObj, + parser ) } if (hasKwargs) { if (parsedCount < kwValue!!.size) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.notAllValid", @@ -344,23 +418,29 @@ public open class ArgumentParser : KoinComponent { parsedCount, context.translate(converter.signatureTypeString, bundleName = converter.bundle) ) - ) + ), + + "argumentParser.error.notAllValid", + + currentArg, + argumentsObj, + parser ) } converter.parseSuccess = true } else { if (parsedCount > 0) { - logger.debug { "Argument '${currentArg.displayName}' successfully filled." } + logger.trace { "Argument '${currentArg.displayName}' successfully filled." } converter.parseSuccess = true converter.validate(context) } } - } catch (e: CommandException) { + } catch (e: DiscordRelayedException) { if (converter.required) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.errorInArgument", @@ -369,7 +449,13 @@ public open class ArgumentParser : KoinComponent { converter.handleError(e, context) ) - ) + ), + + "argumentParser.error.errorInArgument", + + currentArg, + argumentsObj, + parser ) } } catch (t: Throwable) { @@ -388,7 +474,7 @@ public open class ArgumentParser : KoinComponent { } if ((converter.required || hasKwargs) && parsedCount <= 0) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.invalidValue", @@ -396,13 +482,19 @@ public open class ArgumentParser : KoinComponent { currentArg.displayName, converter.getErrorString(context), ) - ) + ), + + "argumentParser.error.invalidValue", + + currentArg, + argumentsObj, + parser ) } if (hasKwargs) { if (parsedCount < kwValue!!.size) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.notAllValid", @@ -412,23 +504,29 @@ public open class ArgumentParser : KoinComponent { parsedCount, context.translate(converter.signatureTypeString, bundleName = converter.bundle) ) - ) + ), + + "argumentParser.error.notAllValid", + + currentArg, + argumentsObj, + parser ) } converter.parseSuccess = true } else { if (parsedCount > 0) { - logger.debug { "Argument '${currentArg.displayName}' successfully filled." } + logger.trace { "Argument '${currentArg.displayName}' successfully filled." } converter.parseSuccess = true converter.validate(context) } } - } catch (e: CommandException) { + } catch (e: DiscordRelayedException) { if (converter.required || converter.outputError || hasKwargs) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.errorInArgument", @@ -437,7 +535,13 @@ public open class ArgumentParser : KoinComponent { converter.handleError(e, context) ) - ) + ), + + "argumentParser.error.errorInArgument", + + currentArg, + argumentsObj, + parser ) } } catch (t: Throwable) { @@ -456,7 +560,7 @@ public open class ArgumentParser : KoinComponent { } if ((converter.required || hasKwargs) && parsedCount <= 0) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.invalidValue", @@ -464,13 +568,19 @@ public open class ArgumentParser : KoinComponent { currentArg.displayName, converter.getErrorString(context), ) - ) + ), + + "argumentParser.error.invalidValue", + + currentArg, + argumentsObj, + parser ) } if (hasKwargs) { if (parsedCount < kwValue!!.size) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.notAllValid", @@ -480,23 +590,29 @@ public open class ArgumentParser : KoinComponent { parsedCount, context.translate(converter.signatureTypeString, bundleName = converter.bundle) ) - ) + ), + + "argumentParser.error.notAllValid", + + currentArg, + argumentsObj, + parser ) } converter.parseSuccess = true } else { if (parsedCount > 0) { - logger.debug { "Argument '${currentArg.displayName}' successfully filled." } + logger.trace { "Argument '${currentArg.displayName}' successfully filled." } converter.parseSuccess = true converter.validate(context) } } - } catch (e: CommandException) { + } catch (e: DiscordRelayedException) { if (converter.required || converter.outputError || hasKwargs) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.errorInArgument", @@ -505,7 +621,13 @@ public open class ArgumentParser : KoinComponent { converter.handleError(e, context) ) - ) + ), + + "argumentParser.error.errorInArgument", + + currentArg, + argumentsObj, + parser ) } } catch (t: Throwable) { @@ -516,7 +638,7 @@ public open class ArgumentParser : KoinComponent { } } - else -> throw CommandException( + else -> throw ArgumentParsingException( context.translate( "argumentParser.error.errorInArgument", @@ -528,7 +650,13 @@ public open class ArgumentParser : KoinComponent { replacements = arrayOf(currentArg.converter) ) ) - ) + ), + + "argumentParser.error.errorInArgument", + + currentArg, + argumentsObj, + parser ) } } @@ -536,21 +664,27 @@ public open class ArgumentParser : KoinComponent { val allRequiredArgs = argsMap.count { it.value.converter.required } val filledRequiredArgs = argsMap.count { it.value.converter.parseSuccess && it.value.converter.required } - logger.debug { "Filled $filledRequiredArgs / $allRequiredArgs arguments." } + logger.trace { "Filled $filledRequiredArgs / $allRequiredArgs arguments." } if (filledRequiredArgs < allRequiredArgs) { if (filledRequiredArgs < 1) { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.noFilledArguments", replacements = arrayOf( allRequiredArgs ) - ) + ), + + "argumentParser.error.noFilledArguments", + + null, + argumentsObj, + parser ) } else { - throw CommandException( + throw ArgumentParsingException( context.translate( "argumentParser.error.someFilledArguments", @@ -558,7 +692,13 @@ public open class ArgumentParser : KoinComponent { allRequiredArgs, filledRequiredArgs ) - ) + ), + + "argumentParser.error.someFilledArguments", + + null, + argumentsObj, + parser ) } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/MessageCommandRegistry.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatCommandRegistry.kt similarity index 85% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/MessageCommandRegistry.kt rename to kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatCommandRegistry.kt index 15125b9499..e959e03952 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/MessageCommandRegistry.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatCommandRegistry.kt @@ -1,53 +1,48 @@ -package com.kotlindiscord.kord.extensions.commands +package com.kotlindiscord.kord.extensions.commands.chat import com.kotlindiscord.kord.extensions.CommandRegistrationException import com.kotlindiscord.kord.extensions.ExtensibleBot import com.kotlindiscord.kord.extensions.builders.ExtensibleBotBuilder -import com.kotlindiscord.kord.extensions.commands.parser.Arguments +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.extensions.Extension import com.kotlindiscord.kord.extensions.parser.StringParser import com.kotlindiscord.kord.extensions.utils.getLocale import dev.kord.common.annotation.KordPreview import dev.kord.core.Kord import dev.kord.core.event.message.MessageCreateEvent -import kotlinx.coroutines.ExecutorCoroutineDispatcher -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.invoke import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import java.util.concurrent.Executors /** * A class for the registration and dispatching of message-based commands. */ @OptIn(KordPreview::class) -public open class MessageCommandRegistry : KoinComponent { +public open class ChatCommandRegistry : KoinComponent { /** Current instance of the bot. **/ public val bot: ExtensibleBot by inject() /** Kord instance, backing the ExtensibleBot. **/ public val kord: Kord by inject() + /** Chat command parser object. **/ + public open val parser: ChatCommandParser = ChatCommandParser() + /** * A list of all registered commands. */ - public open val commands: MutableList> = mutableListOf() + public open val commands: MutableList> = mutableListOf() /** @suppress **/ public val botSettings: ExtensibleBotBuilder by inject() - /** @suppress **/ - public open val commandThreadPool: ExecutorCoroutineDispatcher by lazy { - Executors - .newFixedThreadPool(botSettings.messageCommandsBuilder.threads) - .asCoroutineDispatcher() - } + /** Whether chat commands are enabled in the bot's settings. **/ + public open val enabled: Boolean get() = botSettings.chatCommandsBuilder.enabled /** - * Directly register a [MessageCommand] to this command registry. + * Directly register a [ChatCommand] to this command registry. * * Generally speaking, you shouldn't call this directly - instead, create an [Extension] and - * call the [Extension.command] function in your [Extension.setup] function. + * call the [Extension.messageContentCommand] function in your [Extension.setup] function. * * This function will throw a [CommandRegistrationException] if the command has already been registered, if * a command with the same name exists, or if a command with one of the same aliases exists. @@ -56,7 +51,7 @@ public open class MessageCommandRegistry : KoinComponent { * @throws CommandRegistrationException Thrown if the command could not be registered. */ @Throws(CommandRegistrationException::class) - public open fun add(command: MessageCommand) { + public open fun add(command: ChatCommand) { val existingCommand = commands.any { it.name == command.name } val existingAlias: String? = commands.flatMap { it.aliases.toList() @@ -88,14 +83,14 @@ public open class MessageCommandRegistry : KoinComponent { } /** - * Directly remove a registered [MessageCommand] from this command registry. + * Directly remove a registered [ChatCommand] from this command registry. * * This function is used when extensions are unloaded, in order to clear out their commands. * No exception is thrown if the command wasn't registered. * * @param command The command to be removed. */ - public open fun remove(command: MessageCommand): Boolean = commands.remove(command) + public open fun remove(command: ChatCommand): Boolean = commands.remove(command) /** * Given a [MessageCreateEvent], return the prefix that should be used for a command invocation. @@ -104,7 +99,7 @@ public open class MessageCommandRegistry : KoinComponent { * needed. */ public open suspend fun getPrefix(event: MessageCreateEvent): String = - botSettings.messageCommandsBuilder.prefixCallback(event, botSettings.messageCommandsBuilder.defaultPrefix) + botSettings.chatCommandsBuilder.prefixCallback(event, botSettings.chatCommandsBuilder.defaultPrefix) /** * Check whether the given string starts with a mention referring to the bot. If so, the matching mention string @@ -140,7 +135,7 @@ public open class MessageCommandRegistry : KoinComponent { content = when { // Starts with the right mention and mentions are allowed, so remove it - mention != null && botSettings.messageCommandsBuilder.invokeOnMention -> content.substring(mention.length) + mention != null && botSettings.chatCommandsBuilder.invokeOnMention -> content.substring(mention.length) // Starts with the right prefix, so remove it content.startsWith(prefix) -> content.substring(prefix.length) @@ -233,9 +228,7 @@ public open class MessageCommandRegistry : KoinComponent { val command = getCommand(commandName, event) val parser = StringParser(content) - commandThreadPool.invoke { - command?.call(event, commandName, parser, content) - } + command?.call(event, commandName, parser, content) } /** @@ -243,7 +236,7 @@ public open class MessageCommandRegistry : KoinComponent { * * If a command supports locale fallback, this will also attempt to resolve names via the bot's default locale. */ - public open suspend fun getCommand(name: String, event: MessageCreateEvent): MessageCommand? { + public open suspend fun getCommand(name: String, event: MessageCreateEvent): ChatCommand? { val defaultLocale = botSettings.i18nBuilder.defaultLocale val locale = event.getLocale() diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/MessageGroupCommand.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatGroupCommand.kt similarity index 69% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/MessageGroupCommand.kt rename to kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatGroupCommand.kt index 36f572f03e..911545ca0d 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/MessageGroupCommand.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatGroupCommand.kt @@ -1,10 +1,10 @@ -package com.kotlindiscord.kord.extensions.commands +package com.kotlindiscord.kord.extensions.commands.chat import com.kotlindiscord.kord.extensions.CommandRegistrationException import com.kotlindiscord.kord.extensions.InvalidCommandException import com.kotlindiscord.kord.extensions.annotations.ExtensionDSL import com.kotlindiscord.kord.extensions.builders.ExtensibleBotBuilder -import com.kotlindiscord.kord.extensions.commands.parser.Arguments +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.extensions.Extension import com.kotlindiscord.kord.extensions.parser.StringParser import com.kotlindiscord.kord.extensions.utils.getLocale @@ -22,29 +22,39 @@ private val logger = KotlinLogging.logger {} * `group` function to register your command group, by overriding the `Extension` setup function. * * @param extension The extension that registered this grouped command. - * @param parent The [GroupCommand] this group exists under, if any. + * @param parent The [ChatGroupCommand] this group exists under, if any. */ @Suppress("LateinitVarOverridesLateinitVar") // This is intentional @ExtensionDSL -public open class GroupCommand( +public open class ChatGroupCommand( extension: Extension, arguments: (() -> T)? = null, - public open val parent: GroupCommand? = null -) : MessageCommand(extension, arguments) { + public open val parent: ChatGroupCommand? = null +) : ChatCommand(extension, arguments) { /** @suppress **/ public val botSettings: ExtensibleBotBuilder by inject() /** @suppress **/ - public open val commands: MutableList> = mutableListOf() + public open val commands: MutableList> = mutableListOf() override lateinit var name: String /** @suppress **/ - override var body: suspend MessageCommandContext.() -> Unit = { + override var body: suspend ChatCommandContext.() -> Unit = { sendHelp() } + override suspend fun runChecks(event: MessageCreateEvent, sendMessage: Boolean): Boolean { + var result = parent?.runChecks(event, sendMessage) ?: true + + if (result) { + result = super.runChecks(event, sendMessage) + } + + return result + } + /** * An internal function used to ensure that all of a command group's required arguments are present. * @@ -68,14 +78,15 @@ public open class GroupCommand( * * @param body Builder lambda used for setting up the command object. */ - public open suspend fun command( + @ExtensionDSL + public open suspend fun chatCommand( arguments: (() -> R)?, - body: suspend MessageCommand.() -> Unit - ): MessageCommand { - val commandObj = MessageSubCommand(extension, arguments, this) + body: suspend ChatCommand.() -> Unit + ): ChatCommand { + val commandObj = ChatSubCommand(extension, arguments, this) body.invoke(commandObj) - return command(commandObj) + return chatCommand(commandObj) } /** @@ -85,13 +96,14 @@ public open class GroupCommand( * * @param body Builder lambda used for setting up the command object. */ - public open suspend fun command( - body: suspend MessageCommand.() -> Unit - ): MessageCommand { - val commandObj = MessageSubCommand(extension, parent = this) + @ExtensionDSL + public open suspend fun chatCommand( + body: suspend ChatCommand.() -> Unit + ): ChatCommand { + val commandObj = ChatSubCommand(extension, parent = this) body.invoke(commandObj) - return command(commandObj) + return chatCommand(commandObj) } /** @@ -101,7 +113,10 @@ public open class GroupCommand( * * @param commandObj MessageCommand object to register. */ - public open suspend fun command(commandObj: MessageCommand): MessageCommand { + @ExtensionDSL + public open suspend fun chatCommand( + commandObj: ChatCommand + ): ChatCommand { try { commandObj.validate() commands.add(commandObj) @@ -124,14 +139,16 @@ public open class GroupCommand( * * @param body Builder lambda used for setting up the command object. */ - public open suspend fun group( + @ExtensionDSL + @Suppress("MemberNameEqualsClassName") // Really? + public open suspend fun chatGroupCommand( arguments: (() -> R)?, - body: suspend GroupCommand.() -> Unit - ): GroupCommand { - val commandObj = GroupCommand(extension, arguments, this) + body: suspend ChatGroupCommand.() -> Unit + ): ChatGroupCommand { + val commandObj = ChatGroupCommand(extension, arguments, this) body.invoke(commandObj) - return command(commandObj) as GroupCommand + return chatCommand(commandObj) as ChatGroupCommand } /** @@ -144,17 +161,22 @@ public open class GroupCommand( * * @param body Builder lambda used for setting up the command object. */ - public open suspend fun group( - body: suspend GroupCommand.() -> Unit - ): GroupCommand { - val commandObj = GroupCommand(extension, parent = this) + @ExtensionDSL + @Suppress("MemberNameEqualsClassName") // Really? + public open suspend fun chatGroupCommand( + body: suspend ChatGroupCommand.() -> Unit + ): ChatGroupCommand { + val commandObj = ChatGroupCommand(extension, parent = this) body.invoke(commandObj) - return command(commandObj) as GroupCommand + return chatCommand(commandObj) as ChatGroupCommand } /** @suppress **/ - public open suspend fun getCommand(name: String?, event: MessageCreateEvent): MessageCommand? { + public open suspend fun getCommand( + name: String?, + event: MessageCreateEvent + ): ChatCommand? { name ?: return null val defaultLocale = botSettings.i18nBuilder.defaultLocale @@ -191,14 +213,15 @@ public open class GroupCommand( return } - val command = parser.parseNext()?.data?.lowercase() - val remainingArgs = parser.consumeRemaining() + val command = parser.peekNext()?.data?.lowercase() val subCommand = getCommand(command, event) if (subCommand == null) { super.call(event, commandName, parser, argString, true) } else { - subCommand.call(event, commandName, StringParser(remainingArgs), argString) + parser.parseNext() // Advance the cursor so proper parsing can happen + + subCommand.call(event, commandName, StringParser(parser.consumeRemaining()), argString) } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/MessageSubCommand.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatSubCommand.kt similarity index 61% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/MessageSubCommand.kt rename to kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatSubCommand.kt index b1b18b6352..5475aa8af4 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/MessageSubCommand.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/chat/ChatSubCommand.kt @@ -1,8 +1,9 @@ -package com.kotlindiscord.kord.extensions.commands +package com.kotlindiscord.kord.extensions.commands.chat import com.kotlindiscord.kord.extensions.annotations.ExtensionDSL -import com.kotlindiscord.kord.extensions.commands.parser.Arguments +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.extensions.Extension +import dev.kord.core.event.message.MessageCreateEvent import java.util.* /** @@ -11,14 +12,25 @@ import java.util.* * This is used for group commands, so that subcommands are aware of their parent. * * @param extension The [Extension] that registered this command. - * @param parent The [GroupCommand] this command exists under. + * @param parent The [ChatGroupCommand] this command exists under. */ @ExtensionDSL -public open class MessageSubCommand( +public open class ChatSubCommand( extension: Extension, arguments: (() -> T)? = null, - public open val parent: GroupCommand -) : MessageCommand(extension, arguments) { + public open val parent: ChatGroupCommand +) : ChatCommand(extension, arguments) { + + override suspend fun runChecks(event: MessageCreateEvent, sendMessage: Boolean): Boolean { + var result = parent.runChecks(event, sendMessage) + + if (result) { + result = super.runChecks(event, sendMessage) + } + + return result + } + /** Get the full command name, translated, with parent commands taken into account. **/ public open suspend fun getFullTranslatedName(locale: Locale): String = parent.getFullTranslatedName(locale) + " " + this.getTranslatedName(locale) diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/CoalescingConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/CoalescingConverter.kt index 1698d24153..3ab46e0eeb 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/CoalescingConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/CoalescingConverter.kt @@ -2,7 +2,7 @@ package com.kotlindiscord.kord.extensions.commands.converters -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException import dev.kord.common.annotation.KordPreview /** @@ -19,10 +19,10 @@ import dev.kord.common.annotation.KordPreview * You can create a coalescing converter of your own by extending this class. * * @property shouldThrow Intended only for use if this converter is the last one in a set of arguments, if this is - * `true` then the converter should throw a [CommandException] when an argument can't be parsed, instead of just + * `true` then the converter should throw a [DiscordRelayedException] when an argument can't be parsed, instead of just * stopping and allowing parsing to continue. * - * @property validator Validation lambda, which may throw a [CommandException] if required. + * @property validator Validation lambda, which may throw a [DiscordRelayedException] if required. */ public abstract class CoalescingConverter( public open val shouldThrow: Boolean = false, @@ -54,8 +54,8 @@ public abstract class CoalescingConverter( * provides. * * @param outputError Optionally, provide `true` to fail parsing and return errors if the converter throws a - * [CommandException], instead of continuing. You probably only want to set this if the converter is the last one - * in a set of arguments. + * [DiscordRelayedException], instead of continuing. You probably only want to set this if the converter is the + * last one in a set of arguments. */ @ConverterToOptional public open fun toOptional( diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/CoalescingToDefaultingConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/CoalescingToDefaultingConverter.kt index 96c4d99e49..a65b3c994f 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/CoalescingToDefaultingConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/CoalescingToDefaultingConverter.kt @@ -2,10 +2,11 @@ package com.kotlindiscord.kord.extensions.commands.converters +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder /** @@ -57,4 +58,14 @@ public class CoalescingToDefaultingConverter( return option } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val result = coalescingConverter.parseOption(context, option) + + if (result) { + this.parsed = coalescingConverter.parsed + } + + return result + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/CoalescingToOptionalConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/CoalescingToOptionalConverter.kt index 41680134ce..070f7ff60e 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/CoalescingToOptionalConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/CoalescingToOptionalConverter.kt @@ -2,10 +2,11 @@ package com.kotlindiscord.kord.extensions.commands.converters +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder /** @@ -56,4 +57,14 @@ public class CoalescingToOptionalConverter( return option } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val result = coalescingConverter.parseOption(context, option) + + if (result) { + this.parsed = coalescingConverter.parsed + } + + return result + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/Converter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/Converter.kt index 7ad62c8dbf..d8b6c5fe6d 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/Converter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/Converter.kt @@ -2,11 +2,11 @@ package com.kotlindiscord.kord.extensions.commands.converters -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException import com.kotlindiscord.kord.extensions.ExtensibleBot +import com.kotlindiscord.kord.extensions.commands.Argument +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.CommandContext -import com.kotlindiscord.kord.extensions.commands.parser.Argument -import com.kotlindiscord.kord.extensions.commands.parser.Arguments import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview import dev.kord.core.Kord @@ -47,7 +47,7 @@ public abstract class Converter = null /** This will be set to true by the argument parser if the conversion succeeded. **/ @@ -86,13 +86,13 @@ public abstract class Converter( defaultValue: T, diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/DefaultingConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/DefaultingConverter.kt index 39b0b11c48..4927e73494 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/DefaultingConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/DefaultingConverter.kt @@ -13,7 +13,7 @@ import dev.kord.common.annotation.KordPreview * You can create a defaulting converter of your own by extending this class. * * @property outputError Whether the argument parser should output parsing errors on invalid arguments. - * @property validator Validation lambda, which may throw a CommandException if required. + * @property validator Validation lambda, which may throw a DiscordRelayedException if required. */ public abstract class DefaultingConverter( defaultValue: T, diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/MultiConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/MultiConverter.kt index d93c1207df..9e2d6d1cbc 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/MultiConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/MultiConverter.kt @@ -14,7 +14,7 @@ import dev.kord.common.annotation.KordPreview * * You can create a multi converter of your own by extending this class. * - * @property validator Validation lambda, which may throw a CommandException if required. + * @property validator Validation lambda, which may throw a DiscordRelayedException if required. */ public abstract class MultiConverter( required: Boolean = true, diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/OptionalCoalescingConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/OptionalCoalescingConverter.kt index 8c5e23cd50..7f4bfa8445 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/OptionalCoalescingConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/OptionalCoalescingConverter.kt @@ -17,7 +17,7 @@ import dev.kord.common.annotation.KordPreview * You can create an optional coalescing converter of your own by extending this class. * * @property outputError Whether the argument parser should output parsing errors on invalid arguments. - * @property validator Validation lambda, which may throw a CommandException if required. + * @property validator Validation lambda, which may throw a DiscordRelayedException if required. */ public abstract class OptionalCoalescingConverter( public val outputError: Boolean = false, diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/OptionalConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/OptionalConverter.kt index 9c770cff34..94e0fd9659 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/OptionalConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/OptionalConverter.kt @@ -10,7 +10,7 @@ import dev.kord.common.annotation.KordPreview * This works just like [SingleConverter], but the value can be nullable and it can never be required. * * @property outputError Whether the argument parser should output parsing errors on invalid arguments. - * @property validator Validation lambda, which may throw a CommandException if required. + * @property validator Validation lambda, which may throw a DiscordRelayedException if required. */ public abstract class OptionalConverter( public val outputError: Boolean = false, diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SingleConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SingleConverter.kt index 18dc8ac389..a732a6a58d 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SingleConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SingleConverter.kt @@ -15,7 +15,7 @@ import dev.kord.common.annotation.KordPreview * * You can create a single converter of your own by extending this class. * - * @property validator Validation lambda, which may throw a CommandException if required. + * @property validator Validation lambda, which may throw a DiscordRelayedException if required. */ public abstract class SingleConverter( override var validator: Validator = null @@ -82,7 +82,7 @@ public abstract class SingleConverter( * provides. * * @param outputError Optionally, provide `true` to fail parsing and return errors if the converter throws a - * [CommandException], instead of continuing. + * [DiscordRelayedException] (instead of continuing). */ @ConverterToOptional public open fun toOptional( diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SingleToDefaultingConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SingleToDefaultingConverter.kt index f268922c3d..f52473b7df 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SingleToDefaultingConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SingleToDefaultingConverter.kt @@ -1,9 +1,10 @@ package com.kotlindiscord.kord.extensions.commands.converters +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder /** @@ -61,4 +62,14 @@ public class SingleToDefaultingConverter( return option } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val result = singleConverter.parseOption(context, option) + + if (result) { + this.parsed = singleConverter.parsed + } + + return result + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SingleToMultiConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SingleToMultiConverter.kt index 54b1d45b7b..903d4b9caa 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SingleToMultiConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SingleToMultiConverter.kt @@ -1,8 +1,8 @@ package com.kotlindiscord.kord.extensions.commands.converters -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.CommandContext -import com.kotlindiscord.kord.extensions.commands.parser.Arguments import com.kotlindiscord.kord.extensions.parser.StringParser /** @@ -52,7 +52,7 @@ public class SingleToMultiConverter( values.add(value) parser?.parseNext() // Move the cursor ahead - } catch (e: CommandException) { + } catch (e: DiscordRelayedException) { break } } @@ -68,7 +68,7 @@ public class SingleToMultiConverter( val value = singleConverter.getValue(dummyArgs, singleConverter::parsed) values.add(value) - } catch (e: CommandException) { + } catch (e: DiscordRelayedException) { break } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SingleToOptionalConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SingleToOptionalConverter.kt index 00205d02a1..8330111a7f 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SingleToOptionalConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SingleToOptionalConverter.kt @@ -1,9 +1,10 @@ package com.kotlindiscord.kord.extensions.commands.converters +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder /** @@ -60,4 +61,14 @@ public class SingleToOptionalConverter( return option } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val result = singleConverter.parseOption(context, option) + + if (result) { + this.parsed = singleConverter.parsed + } + + return result + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SlashCommandConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SlashCommandConverter.kt index 2cd2947ecd..3a029d38f7 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SlashCommandConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/SlashCommandConverter.kt @@ -1,7 +1,9 @@ package com.kotlindiscord.kord.extensions.commands.converters -import com.kotlindiscord.kord.extensions.commands.parser.Argument +import com.kotlindiscord.kord.extensions.commands.Argument +import com.kotlindiscord.kord.extensions.commands.CommandContext import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder /** @@ -15,4 +17,7 @@ public interface SlashCommandConverter { * Only applicable to converter types that make sense for slash commands. */ public suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder + + /** Use the given [option] taken straight from the slash command invocation to fill the converter. **/ + public suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/_Types.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/_Types.kt index 31df68305d..16507e5c52 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/_Types.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/_Types.kt @@ -2,8 +2,8 @@ package com.kotlindiscord.kord.extensions.commands.converters +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext -import com.kotlindiscord.kord.extensions.commands.parser.Argument /** Types alias representing a validator callable. Keeps things relatively maintainable. **/ public typealias Validator = (suspend CommandContext.(arg: Argument<*>, value: T) -> Unit)? diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/BooleanConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/BooleanConverter.kt index 425241f655..5dedb0451e 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/BooleanConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/BooleanConverter.kt @@ -1,14 +1,15 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.SingleConverter import com.kotlindiscord.kord.extensions.commands.converters.Validator -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser import com.kotlindiscord.kord.extensions.utils.parseBoolean import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.BooleanBuilder import dev.kord.rest.builder.interaction.OptionsBuilder @@ -40,4 +41,11 @@ public class BooleanConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = BooleanBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.BooleanOptionValue)?.value ?: return false + this.parsed = optionValue + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/ChannelConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/ChannelConverter.kt index 5397c4e20a..aed2a5456c 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/ChannelConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/ChannelConverter.kt @@ -7,10 +7,10 @@ ) package com.kotlindiscord.kord.extensions.commands.converters.impl -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser @@ -18,6 +18,7 @@ import dev.kord.common.annotation.KordPreview import dev.kord.common.entity.Snowflake import dev.kord.core.entity.channel.Channel import dev.kord.core.entity.channel.GuildChannel +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.ChannelBuilder import dev.kord.rest.builder.interaction.OptionsBuilder import kotlinx.coroutines.FlowPreview @@ -58,7 +59,7 @@ public class ChannelConverter( val arg: String = named ?: parser?.parseNext()?.data ?: return false val channel: Channel = findChannel(arg, context) - ?: throw CommandException( + ?: throw DiscordRelayedException( context.translate( "converters.channel.error.missing", replacements = arrayOf(arg) @@ -76,7 +77,7 @@ public class ChannelConverter( try { kord.getChannel(Snowflake(id.toLong())) } catch (e: NumberFormatException) { - throw CommandException( + throw DiscordRelayedException( context.translate( "converters.channel.error.invalid", replacements = arrayOf(id) @@ -113,4 +114,11 @@ public class ChannelConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = ChannelBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.ChannelOptionValue)?.value ?: return false + this.parsed = optionValue + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/ColorConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/ColorConverter.kt index 245ca13c21..79aa5fb753 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/ColorConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/ColorConverter.kt @@ -9,16 +9,17 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser import com.kotlindiscord.kord.extensions.parsers.ColorParser import dev.kord.common.Color import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder @@ -47,14 +48,14 @@ public class ColorConverter( arg.startsWith("0x") -> this.parsed = Color(arg.substring(2).toInt(16)) arg.all { it.isDigit() } -> this.parsed = Color(arg.toInt()) - else -> this.parsed = ColorParser.parse(arg, context.getLocale()) ?: throw CommandException( + else -> this.parsed = ColorParser.parse(arg, context.getLocale()) ?: throw DiscordRelayedException( context.translate("converters.color.error.unknown", replacements = arrayOf(arg)) ) } - } catch (e: CommandException) { + } catch (e: DiscordRelayedException) { throw e } catch (t: Throwable) { - throw CommandException( + throw DiscordRelayedException( context.translate("converters.color.error.unknownOrFailed", replacements = arrayOf(arg)) ) } @@ -64,4 +65,34 @@ public class ColorConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + + try { + when { + optionValue.startsWith("#") -> this.parsed = + Color(optionValue.substring(1).toInt(16)) + + optionValue.startsWith("0x") -> this.parsed = + Color(optionValue.substring(2).toInt(16)) + + optionValue.all { it.isDigit() } -> this.parsed = + Color(optionValue.toInt()) + + else -> this.parsed = + ColorParser.parse(optionValue, context.getLocale()) ?: throw DiscordRelayedException( + context.translate("converters.color.error.unknown", replacements = arrayOf(optionValue)) + ) + } + } catch (e: DiscordRelayedException) { + throw e + } catch (t: Throwable) { + throw DiscordRelayedException( + context.translate("converters.color.error.unknownOrFailed", replacements = arrayOf(optionValue)) + ) + } + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/DecimalConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/DecimalConverter.kt index 9a506ee578..955515d5ea 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/DecimalConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/DecimalConverter.kt @@ -7,16 +7,17 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue +import dev.kord.rest.builder.interaction.NumberChoiceBuilder import dev.kord.rest.builder.interaction.OptionsBuilder -import dev.kord.rest.builder.interaction.StringChoiceBuilder /** * Argument converter for decimal arguments, converting them into [Double]. @@ -41,7 +42,7 @@ public class DecimalConverter( try { this.parsed = arg.toDouble() } catch (e: NumberFormatException) { - throw CommandException( + throw DiscordRelayedException( context.translate("converters.decimal.error.invalid", replacements = arrayOf(arg)) ) } @@ -50,5 +51,12 @@ public class DecimalConverter( } override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = - StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + NumberChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.NumberOptionValue)?.value ?: return false + this.parsed = optionValue + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/DurationCoalescingConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/DurationCoalescingConverter.kt index a6dd90ca97..37afd150c9 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/DurationCoalescingConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/DurationCoalescingConverter.kt @@ -1,10 +1,10 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.CoalescingConverter import com.kotlindiscord.kord.extensions.commands.converters.Validator -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser @@ -13,10 +13,12 @@ import com.kotlindiscord.kord.extensions.parsers.DurationParser import com.kotlindiscord.kord.extensions.parsers.DurationParserException import com.kotlindiscord.kord.extensions.parsers.InvalidTimeUnitException import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder import kotlinx.datetime.* import mu.KotlinLogging +import kotlin.time.ExperimentalTime /** * Argument converter for Kotlin [DateTimePeriod] arguments. You can apply these to an `Instant` using `plus` and a @@ -36,7 +38,7 @@ import mu.KotlinLogging "shouldThrow: Boolean = false" ], ) -@OptIn(KordPreview::class) +@OptIn(KordPreview::class, ExperimentalTime::class) public class DurationCoalescingConverter( public val longHelp: Boolean = true, public val positiveOnly: Boolean = true, @@ -47,10 +49,23 @@ public class DurationCoalescingConverter( private val logger = KotlinLogging.logger {} override suspend fun parse(parser: StringParser?, context: CommandContext, named: List?): Int { - val durations: MutableList = mutableListOf() + // Check if it's a discord-formatted timestamp first + val timestamp = + (named?.getOrNull(0) ?: parser?.peekNext()?.data)?.let { TimestampConverter.parseFromString(it) } + if (timestamp != null) { + val result = (timestamp.instant - Clock.System.now()).toDateTimePeriod() + + checkPositive(context, result, positiveOnly) + + this.parsed = result + + return 1 + } + + val durations = mutableListOf() val ignoredWords: List = context.translate("utils.durations.ignoredWords").split(",") - var skipNext: Boolean = false + var skipNext = false val args: List = named ?: parser?.run { val tokens: MutableList = mutableListOf() @@ -120,14 +135,7 @@ public class DurationCoalescingConverter( context.getLocale() ) - if (positiveOnly) { - val now: Instant = Clock.System.now() - val applied: Instant = now.plus(result, TimeZone.UTC) - - if (now > applied) { - throw CommandException(context.translate("converters.duration.error.positiveOnly")) - } - } + checkPositive(context, result, positiveOnly) parsed = result } catch (e: InvalidTimeUnitException) { @@ -139,9 +147,6 @@ public class DurationCoalescingConverter( return durations.size } - override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = - StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } - private suspend fun throwIfNecessary( e: Exception, context: CommandContext, @@ -154,14 +159,58 @@ public class DurationCoalescingConverter( replacements = arrayOf(e.unit) ) + if (longHelp) "\n\n" + context.translate("converters.duration.help") else "" - throw CommandException(message) + throw DiscordRelayedException(message) } - is DurationParserException -> throw CommandException(e.error) + is DurationParserException -> throw DiscordRelayedException(e.error) else -> throw e } } else { logger.debug(e) { "Error thrown during duration parsing" } } + + private suspend inline fun checkPositive(context: CommandContext, result: DateTimePeriod, positiveOnly: Boolean) { + if (positiveOnly) { + val now: Instant = Clock.System.now() + val applied: Instant = now.plus(result, TimeZone.UTC) + + if (now > applied) { + throw DiscordRelayedException(context.translate("converters.duration.error.positiveOnly")) + } + } + } + + override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = + StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + + try { + val result: DateTimePeriod = DurationParser.parse(optionValue, context.getLocale()) + + if (positiveOnly) { + val now: Instant = Clock.System.now() + val applied: Instant = now.plus(result, TimeZone.UTC) + + if (now > applied) { + throw DiscordRelayedException(context.translate("converters.duration.error.positiveOnly")) + } + } + + parsed = result + } catch (e: InvalidTimeUnitException) { + val message: String = context.translate( + "converters.duration.error.invalidUnit", + replacements = arrayOf(e.unit) + ) + if (longHelp) "\n\n" + context.translate("converters.duration.help") else "" + + throw DiscordRelayedException(message) + } catch (e: DurationParserException) { + throw DiscordRelayedException(e.error) + } + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/DurationConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/DurationConverter.kt index 547aaee312..9a6ef6902a 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/DurationConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/DurationConverter.kt @@ -1,10 +1,10 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.SingleConverter import com.kotlindiscord.kord.extensions.commands.converters.Validator -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser @@ -12,13 +12,16 @@ import com.kotlindiscord.kord.extensions.parsers.DurationParser import com.kotlindiscord.kord.extensions.parsers.DurationParserException import com.kotlindiscord.kord.extensions.parsers.InvalidTimeUnitException import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder import kotlinx.datetime.* +import kotlin.time.ExperimentalTime /** * Argument converter for Kotlin [DateTimePeriod] arguments. You can apply these to an `Instant` using `plus` and a * timezone. + * Also accepts discord-formatted timestamps, in which case the DateTimePeriod will be the time until the timestamp. * * @param longHelp Whether to send the user a long help message with specific information on how to specify durations. * @param positiveOnly Whether a positive duration is required - `true` by default. @@ -33,7 +36,7 @@ import kotlinx.datetime.* "positiveOnly: Boolean = true" ], ) -@OptIn(KordPreview::class) +@OptIn(KordPreview::class, ExperimentalTime::class) public class DurationConverter( public val longHelp: Boolean = true, public val positiveOnly: Boolean = true, @@ -45,14 +48,20 @@ public class DurationConverter( val arg: String = named ?: parser?.parseNext()?.data ?: return false try { - val result: DateTimePeriod = DurationParser.parse(arg, context.getLocale()) + // Check if it's a discord-formatted timestamp first + val timestamp = TimestampConverter.parseFromString(arg) + val result: DateTimePeriod = if (timestamp == null) { + DurationParser.parse(arg, context.getLocale()) + } else { + (timestamp.instant - Clock.System.now()).toDateTimePeriod() + } if (positiveOnly) { val now: Instant = Clock.System.now() val applied: Instant = now.plus(result, TimeZone.UTC) if (now > applied) { - throw CommandException(context.translate("converters.duration.error.positiveOnly")) + throw DiscordRelayedException(context.translate("converters.duration.error.positiveOnly")) } } @@ -63,9 +72,9 @@ public class DurationConverter( replacements = arrayOf(e.unit) ) + if (longHelp) "\n\n" + context.translate("converters.duration.help") else "" - throw CommandException(message) + throw DiscordRelayedException(message) } catch (e: DurationParserException) { - throw CommandException(e.error) + throw DiscordRelayedException(e.error) } return true @@ -73,4 +82,34 @@ public class DurationConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + + try { + val result: DateTimePeriod = DurationParser.parse(optionValue, context.getLocale()) + + if (positiveOnly) { + val now: Instant = Clock.System.now() + val applied: Instant = now.plus(result, TimeZone.UTC) + + if (now > applied) { + throw DiscordRelayedException(context.translate("converters.duration.error.positiveOnly")) + } + } + + parsed = result + } catch (e: InvalidTimeUnitException) { + val message: String = context.translate( + "converters.duration.error.invalidUnit", + replacements = arrayOf(e.unit) + ) + if (longHelp) "\n\n" + context.translate("converters.duration.help") else "" + + throw DiscordRelayedException(message) + } catch (e: DurationParserException) { + throw DiscordRelayedException(e.error) + } + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/EmailConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/EmailConverter.kt index 03e3a64a03..584811859c 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/EmailConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/EmailConverter.kt @@ -7,14 +7,15 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder import org.apache.commons.validator.routines.EmailValidator @@ -37,7 +38,7 @@ public class EmailConverter( val arg: String = named ?: parser?.parseNext()?.data ?: return false if (!EmailValidator.getInstance().isValid(arg)) { - throw CommandException( + throw DiscordRelayedException( context.translate("converters.email.error.invalid", replacements = arrayOf(arg)) ) } @@ -49,4 +50,18 @@ public class EmailConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + + if (!EmailValidator.getInstance().isValid(optionValue)) { + throw DiscordRelayedException( + context.translate("converters.email.error.invalid", replacements = arrayOf(optionValue)) + ) + } + + this.parsed = optionValue + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/EmojiConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/EmojiConverter.kt index 4900d0d082..d55f7fa873 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/EmojiConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/EmojiConverter.kt @@ -7,16 +7,17 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview import dev.kord.common.entity.Snowflake import dev.kord.core.entity.GuildEmoji +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder import kotlinx.coroutines.flow.first @@ -51,7 +52,7 @@ public class EmojiConverter( val arg: String = named ?: parser?.parseNext()?.data ?: return false val emoji: GuildEmoji = findEmoji(arg, context) - ?: throw CommandException( + ?: throw DiscordRelayedException( context.translate("converters.emoji.error.missing", replacements = arrayOf(arg)) ) @@ -70,7 +71,7 @@ public class EmojiConverter( it.getEmojiOrNull(snowflake) }.firstOrNull() } catch (e: NumberFormatException) { - throw CommandException( + throw DiscordRelayedException( context.translate("converters.emoji.error.invalid", replacements = arrayOf(id)) ) } @@ -92,4 +93,16 @@ public class EmojiConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + + val emoji: GuildEmoji = findEmoji(optionValue, context) + ?: throw DiscordRelayedException( + context.translate("converters.emoji.error.missing", replacements = arrayOf(optionValue)) + ) + + parsed = emoji + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/EnumConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/EnumConverter.kt index e8d486bfd5..db52446b94 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/EnumConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/EnumConverter.kt @@ -7,13 +7,14 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder @@ -63,6 +64,18 @@ public class EnumConverter>( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + + try { + parsed = getter.invoke(optionValue) ?: return false + } catch (e: IllegalArgumentException) { + return false + } + + return true + } } /** diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/GuildConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/GuildConverter.kt index 774954de16..6932d9ba13 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/GuildConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/GuildConverter.kt @@ -7,16 +7,17 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview import dev.kord.common.entity.Snowflake import dev.kord.core.entity.Guild +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder import kotlinx.coroutines.flow.firstOrNull @@ -46,7 +47,7 @@ public class GuildConverter( val arg: String = named ?: parser?.parseNext()?.data ?: return false this.parsed = findGuild(arg) - ?: throw CommandException( + ?: throw DiscordRelayedException( context.translate("converters.guild.error.missing", replacements = arrayOf(arg)) ) @@ -64,4 +65,15 @@ public class GuildConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + + this.parsed = findGuild(optionValue) + ?: throw DiscordRelayedException( + context.translate("converters.guild.error.missing", replacements = arrayOf(optionValue)) + ) + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/IntConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/IntConverter.kt index 14520b1938..cae32c27a6 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/IntConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/IntConverter.kt @@ -7,14 +7,15 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.IntChoiceBuilder import dev.kord.rest.builder.interaction.OptionsBuilder @@ -48,7 +49,7 @@ public class IntConverter( context.translate("converters.number.error.invalid.otherBase", replacements = arrayOf(arg, radix)) } - throw CommandException(errorString) + throw DiscordRelayedException(errorString) } return true @@ -56,76 +57,11 @@ public class IntConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = IntChoiceBuilder(arg.displayName, arg.description).apply { required = true } -} -// /** -// * Create an integer converter, for single arguments. -// * -// * @see IntConverter -// */ -// public fun Arguments.int( -// displayName: String, -// description: String, -// radix: Int = 10, -// validator: Validator = null, -// ): SingleConverter = -// arg(displayName, description, IntConverter(radix, validator)) -// -// /** -// * Create an optional integer converter, for single arguments. -// * -// * @see IntConverter -// */ -// public fun Arguments.optionalInt( -// displayName: String, -// description: String, -// outputError: Boolean = false, -// radix: Int = 10, -// validator: Validator = null, -// ): OptionalConverter = -// arg( -// displayName, -// description, -// IntConverter(radix) -// .toOptional(outputError = outputError, nestedValidator = validator) -// ) -// -// /** -// * Create a defaulting integer converter, for single arguments. -// * -// * @see IntConverter -// */ -// public fun Arguments.defaultingInt( -// displayName: String, -// description: String, -// defaultValue: Int, -// radix: Int = 10, -// validator: Validator = null, -// ): DefaultingConverter = -// arg( -// displayName, -// description, -// IntConverter(radix) -// .toDefaulting(defaultValue, nestedValidator = validator) -// ) -// -// /** -// * Create an integer converter, for lists of arguments. -// * -// * @param required Whether command parsing should fail if no arguments could be converted. -// * -// * @see IntConverter -// */ -// public fun Arguments.intList( -// displayName: String, -// description: String, -// required: Boolean = true, -// radix: Int = 10, -// validator: Validator> = null, -// ): MultiConverter = -// arg( -// displayName, -// description, -// IntConverter(radix) -// .toMulti(required, signatureTypeString = "numbers", nestedValidator = validator) -// ) + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.IntOptionValue)?.value ?: return false + this.parsed = optionValue.toInt() + + return true + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/LongConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/LongConverter.kt index c10b5749cb..468e234e1a 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/LongConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/LongConverter.kt @@ -7,14 +7,15 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.IntChoiceBuilder import dev.kord.rest.builder.interaction.OptionsBuilder @@ -48,7 +49,7 @@ public class LongConverter( context.translate("converters.number.error.invalid.otherBase", replacements = arrayOf(arg, radix)) } - throw CommandException(errorString) + throw DiscordRelayedException(errorString) } return true @@ -56,4 +57,11 @@ public class LongConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = IntChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.IntOptionValue)?.value ?: return false + this.parsed = optionValue.toLong() + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/MemberConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/MemberConverter.kt index fd2008bfeb..6533a4665a 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/MemberConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/MemberConverter.kt @@ -7,10 +7,11 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext +import com.kotlindiscord.kord.extensions.commands.chat.ChatCommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser @@ -19,6 +20,7 @@ import dev.kord.common.annotation.KordPreview import dev.kord.common.entity.Snowflake import dev.kord.core.entity.Member import dev.kord.core.entity.User +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.UserBuilder import kotlinx.coroutines.flow.firstOrNull @@ -54,8 +56,8 @@ public class MemberConverter( override val signatureTypeString: String = "converters.member.signatureType" override suspend fun parse(parser: StringParser?, context: CommandContext, named: String?): Boolean { - if (useReply) { - val messageReference = context.getMessage()?.asMessage()?.messageReference + if (useReply && context is ChatCommandContext<*>) { + val messageReference = context.message.asMessage().messageReference if (messageReference != null) { val member = messageReference.message?.asMessage()?.getAuthorAsMember() @@ -70,7 +72,7 @@ public class MemberConverter( val arg: String = named ?: parser?.parseNext()?.data ?: return false parsed = findMember(arg, context) - ?: throw CommandException( + ?: throw DiscordRelayedException( context.translate("converters.member.error.missing", replacements = arrayOf(arg)) ) @@ -84,7 +86,7 @@ public class MemberConverter( try { kord.getUser(Snowflake(id)) } catch (e: NumberFormatException) { - throw CommandException( + throw DiscordRelayedException( context.translate("converters.member.error.invalid", replacements = arrayOf(id)) ) } @@ -113,4 +115,11 @@ public class MemberConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = UserBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.MemberOptionValue)?.value as? Member ?: return false + this.parsed = optionValue + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/MessageConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/MessageConverter.kt index 8fbc0c396e..21db41ca52 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/MessageConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/MessageConverter.kt @@ -7,10 +7,11 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext +import com.kotlindiscord.kord.extensions.commands.chat.ChatCommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser @@ -22,6 +23,7 @@ import dev.kord.core.entity.channel.DmChannel import dev.kord.core.entity.channel.GuildChannel import dev.kord.core.entity.channel.GuildMessageChannel import dev.kord.core.entity.channel.MessageChannel +import dev.kord.core.entity.interaction.OptionValue import dev.kord.core.exception.EntityNotFoundException import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder @@ -62,8 +64,8 @@ public class MessageConverter( override val signatureTypeString: String = "converters.message.signatureType" override suspend fun parse(parser: StringParser?, context: CommandContext, named: String?): Boolean { - if (useReply) { - val messageReference = context.getMessage()?.asMessage()?.messageReference + if (useReply && context is ChatCommandContext<*>) { + val messageReference = context.message.asMessage().messageReference if (messageReference != null) { val message = messageReference.message?.asMessageOrNull() @@ -95,7 +97,7 @@ public class MessageConverter( @Suppress("MagicNumber") if (split.size < 3) { - throw CommandException( + throw DiscordRelayedException( context.translate("converters.message.error.invalidUrl", replacements = arrayOf(arg)) ) } @@ -104,13 +106,13 @@ public class MessageConverter( val gid: Snowflake = try { Snowflake(split[0]) } catch (e: NumberFormatException) { - throw CommandException( + throw DiscordRelayedException( context.translate("converters.message.error.invalidGuildId", replacements = arrayOf(split[0])) ) } if (requireGuild && requiredGid != gid) { - logger.debug { "Matching guild ($requiredGid) required, but guild ($gid) doesn't match." } + logger.trace { "Matching guild ($requiredGid) required, but guild ($gid) doesn't match." } errorNoMessage(arg, context) } @@ -119,7 +121,7 @@ public class MessageConverter( val cid: Snowflake = try { Snowflake(split[1]) } catch (e: NumberFormatException) { - throw CommandException( + throw DiscordRelayedException( context.translate( "converters.message.error.invalidChannelId", replacements = arrayOf(split[1]) @@ -130,13 +132,13 @@ public class MessageConverter( val channel: GuildChannel? = kord.getGuild(gid)?.getChannel(cid) if (channel == null) { - logger.debug { "Unable to find channel ($cid) for guild ($gid)." } + logger.trace { "Unable to find channel ($cid) for guild ($gid)." } errorNoMessage(arg, context) } if (channel !is GuildMessageChannel) { - logger.debug { "Specified channel ($cid) is not a guild message channel." } + logger.trace { "Specified channel ($cid) is not a guild message channel." } errorNoMessage(arg, context) } @@ -145,7 +147,7 @@ public class MessageConverter( val mid: Snowflake = try { Snowflake(split[2]) } catch (e: NumberFormatException) { - throw CommandException( + throw DiscordRelayedException( context.translate( "converters.message.error.invalidMessageId", replacements = arrayOf(split[2]) @@ -162,13 +164,13 @@ public class MessageConverter( val channel: ChannelBehavior? = context.getChannel() if (channel !is GuildMessageChannel && channel !is DmChannel) { - logger.debug { "Current channel is not a guild message channel or DM channel." } + logger.trace { "Current channel is not a guild message channel or DM channel." } errorNoMessage(arg, context) } if (channel !is MessageChannel) { - logger.debug { "Current channel is not a message channel, so it can't contain messages." } + logger.trace { "Current channel is not a message channel, so it can't contain messages." } errorNoMessage(arg, context) } @@ -176,7 +178,7 @@ public class MessageConverter( try { channel.getMessage(Snowflake(arg)) } catch (e: NumberFormatException) { - throw CommandException( + throw DiscordRelayedException( context.translate( "converters.message.error.invalidMessageId", replacements = arrayOf(arg) @@ -188,10 +190,20 @@ public class MessageConverter( } } + private suspend fun errorNoMessage(arg: String, context: CommandContext): Nothing { + throw DiscordRelayedException( + context.translate("converters.message.error.missing", replacements = arrayOf(arg)) + ) + } + override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } - private suspend fun errorNoMessage(arg: String, context: CommandContext): Nothing { - throw CommandException(context.translate("converters.message.error.missing", replacements = arrayOf(arg))) + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + + parsed = findMessage(optionValue, context) + + return true } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/RegexCoalescingConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/RegexCoalescingConverter.kt index 7eb6db5030..ac45168b70 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/RegexCoalescingConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/RegexCoalescingConverter.kt @@ -7,13 +7,14 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder @@ -54,4 +55,11 @@ public class RegexCoalescingConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + this.parsed = optionValue.toRegex(options) + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/RegexConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/RegexConverter.kt index 31e8fd259c..edafdbe4e0 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/RegexConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/RegexConverter.kt @@ -7,13 +7,14 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder @@ -51,4 +52,11 @@ public class RegexConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + this.parsed = optionValue.toRegex(options) + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/RoleConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/RoleConverter.kt index 114dee3507..87967e8768 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/RoleConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/RoleConverter.kt @@ -7,10 +7,10 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser @@ -18,6 +18,7 @@ import dev.kord.common.annotation.KordPreview import dev.kord.common.entity.Snowflake import dev.kord.core.entity.Guild import dev.kord.core.entity.Role +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.RoleBuilder import kotlinx.coroutines.flow.firstOrNull @@ -51,7 +52,7 @@ public class RoleConverter( val arg: String = named ?: parser?.parseNext()?.data ?: return false parsed = findRole(arg, context) - ?: throw CommandException( + ?: throw DiscordRelayedException( context.translate("converters.role.error.missing", replacements = arrayOf(arg)) ) @@ -74,7 +75,7 @@ public class RoleConverter( try { guild.getRole(Snowflake(id)) } catch (e: NumberFormatException) { - throw CommandException( + throw DiscordRelayedException( context.translate("converters.role.error.invalid", replacements = arrayOf(id)) ) } @@ -91,4 +92,11 @@ public class RoleConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = RoleBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.RoleOptionValue)?.value ?: return false + this.parsed = optionValue + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/SnowflakeConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/SnowflakeConverter.kt index bdabe5e602..37bba10b19 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/SnowflakeConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/SnowflakeConverter.kt @@ -7,15 +7,16 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview import dev.kord.common.entity.Snowflake +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder @@ -42,7 +43,7 @@ public class SnowflakeConverter( try { this.parsed = Snowflake(arg) } catch (e: NumberFormatException) { - throw CommandException( + throw DiscordRelayedException( context.translate("converters.snowflake.error.invalid", replacements = arrayOf(arg)) ) } @@ -52,4 +53,18 @@ public class SnowflakeConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + + try { + this.parsed = Snowflake(optionValue) + } catch (e: NumberFormatException) { + throw DiscordRelayedException( + context.translate("converters.snowflake.error.invalid", replacements = arrayOf(optionValue)) + ) + } + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/StringCoalescingConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/StringCoalescingConverter.kt index 16820a50dc..3e5fe30742 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/StringCoalescingConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/StringCoalescingConverter.kt @@ -7,13 +7,14 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder @@ -44,4 +45,11 @@ public class StringCoalescingConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + this.parsed = optionValue + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/StringConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/StringConverter.kt index 36415d85dc..50d8e44284 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/StringConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/StringConverter.kt @@ -1,13 +1,14 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.SingleConverter import com.kotlindiscord.kord.extensions.commands.converters.Validator -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder @@ -38,4 +39,11 @@ public class StringConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + this.parsed = optionValue + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/SupportedLocale.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/SupportedLocaleConverter.kt similarity index 73% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/SupportedLocale.kt rename to kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/SupportedLocaleConverter.kt index 7b2e87f5a2..b36218911b 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/SupportedLocale.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/SupportedLocaleConverter.kt @@ -9,15 +9,16 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.i18n.SupportedLocales import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder import java.util.* @@ -30,14 +31,14 @@ import java.util.* * with them, rather than a more general converter. * * If the locale you want to use isn't supported yet, feel free to contribute translations for it to - * [our CrowdIn project](https://crowdin.com/project/kordex). + * [our Weblate project](https://hosted.weblate.org/projects/kord-extensions/main/). */ @Converter( "supportedLocale", types = [ConverterType.DEFAULTING, ConverterType.LIST, ConverterType.OPTIONAL, ConverterType.SINGLE], ) @OptIn(KordPreview::class) -public class SupportedLocale( +public class SupportedLocaleConverter( override var validator: Validator = null ) : SingleConverter() { override val signatureTypeString: String = "converters.supportedLocale.signatureType" @@ -45,7 +46,7 @@ public class SupportedLocale( override suspend fun parse(parser: StringParser?, context: CommandContext, named: String?): Boolean { val arg: String = named ?: parser?.parseNext()?.data ?: return false - this.parsed = SupportedLocales.ALL_LOCALES[arg.lowercase().trim()] ?: throw CommandException( + this.parsed = SupportedLocales.ALL_LOCALES[arg.lowercase().trim()] ?: throw DiscordRelayedException( context.translate("converters.supportedLocale.error.unknown", replacements = arrayOf(arg)) ) @@ -54,4 +55,14 @@ public class SupportedLocale( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + + this.parsed = SupportedLocales.ALL_LOCALES[optionValue.lowercase().trim()] ?: throw DiscordRelayedException( + context.translate("converters.supportedLocale.error.unknown", replacements = arrayOf(optionValue)) + ) + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/TimestampConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/TimestampConverter.kt new file mode 100644 index 0000000000..d22292bd29 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/TimestampConverter.kt @@ -0,0 +1,92 @@ +package com.kotlindiscord.kord.extensions.commands.converters.impl + +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument +import com.kotlindiscord.kord.extensions.commands.CommandContext +import com.kotlindiscord.kord.extensions.commands.converters.SingleConverter +import com.kotlindiscord.kord.extensions.commands.converters.Validator +import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter +import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType +import com.kotlindiscord.kord.extensions.parser.StringParser +import com.kotlindiscord.kord.extensions.time.TimestampType +import com.kotlindiscord.kord.extensions.time.toDiscord +import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue +import dev.kord.rest.builder.interaction.OptionsBuilder +import dev.kord.rest.builder.interaction.StringChoiceBuilder +import kotlinx.datetime.Instant + +private const val TIMESTAMP_PREFIX = " = null +) : SingleConverter() { + override val signatureTypeString: String = "converters.timestamp.signatureType" + + override suspend fun parse(parser: StringParser?, context: CommandContext, named: String?): Boolean { + val arg: String = named ?: parser?.parseNext()?.data ?: return false + this.parsed = parseFromString(arg) ?: throw DiscordRelayedException( + context.translate( + "converters.timestamp.error.invalid", + replacements = arrayOf(arg) + ) + ) + + return true + } + + override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = + StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + this.parsed = parseFromString(optionValue) ?: throw DiscordRelayedException( + context.translate( + "converters.timestamp.error.invalid", + replacements = arrayOf(optionValue) + ) + ) + + return true + } + + internal companion object { + internal fun parseFromString(string: String): FormattedTimestamp? { + if (string.startsWith(TIMESTAMP_PREFIX) && string.endsWith(TIMESTAMP_SUFFIX)) { + val inner = string.removeSurrounding(TIMESTAMP_PREFIX, TIMESTAMP_SUFFIX).split(":") + val epochSeconds = inner.getOrNull(0) + val format = inner.getOrNull(1) + + return FormattedTimestamp( + Instant.fromEpochSeconds(epochSeconds?.toLongOrNull() ?: return null), + TimestampType.fromFormatSpecifier(format) ?: return null + ) + } else { + return null + } + } + } +} + +/** + * Container class for a timestamp and format, as expected by Discord. + * + * @param instant The timestamp this represents + * @param format Which format to display the timestamp in + */ +public data class FormattedTimestamp(val instant: Instant, val format: TimestampType) { + /** + * Format the timestamp using the format into Discord's special format. + */ + public fun toDiscord(): String = instant.toDiscord(format) +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/UserConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/UserConverter.kt index 493a951bfa..4bf1db2401 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/UserConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/UserConverter.kt @@ -7,10 +7,11 @@ package com.kotlindiscord.kord.extensions.commands.converters.impl -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext +import com.kotlindiscord.kord.extensions.commands.chat.ChatCommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.modules.annotations.converters.Converter import com.kotlindiscord.kord.extensions.modules.annotations.converters.ConverterType import com.kotlindiscord.kord.extensions.parser.StringParser @@ -18,6 +19,7 @@ import com.kotlindiscord.kord.extensions.utils.users import dev.kord.common.annotation.KordPreview import dev.kord.common.entity.Snowflake import dev.kord.core.entity.User +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.UserBuilder import kotlinx.coroutines.flow.firstOrNull @@ -49,8 +51,8 @@ public class UserConverter( override val signatureTypeString: String = "converters.user.signatureType" override suspend fun parse(parser: StringParser?, context: CommandContext, named: String?): Boolean { - if (useReply) { - val messageReference = context.getMessage()?.asMessage()?.messageReference + if (useReply && context is ChatCommandContext<*>) { + val messageReference = context.message.asMessage().messageReference if (messageReference != null) { val user = messageReference.message?.asMessage()?.author?.asUserOrNull() @@ -65,7 +67,7 @@ public class UserConverter( val arg: String = named ?: parser?.parseNext()?.data ?: return false this.parsed = findUser(arg, context) - ?: throw CommandException( + ?: throw DiscordRelayedException( context.translate("converters.user.error.missing", replacements = arrayOf(arg)) ) @@ -79,7 +81,7 @@ public class UserConverter( try { kord.getUser(Snowflake(id)) } catch (e: NumberFormatException) { - throw CommandException( + throw DiscordRelayedException( context.translate("converters.user.error.invalid", replacements = arrayOf(id)) ) } @@ -99,4 +101,11 @@ public class UserConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = UserBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.UserOptionValue)?.value ?: return false + this.parsed = optionValue + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/events/ChatCommandEvents.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/events/ChatCommandEvents.kt new file mode 100644 index 0000000000..e6496370eb --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/events/ChatCommandEvents.kt @@ -0,0 +1,38 @@ +package com.kotlindiscord.kord.extensions.commands.events + +import com.kotlindiscord.kord.extensions.ArgumentParsingException +import com.kotlindiscord.kord.extensions.commands.chat.ChatCommand +import dev.kord.core.event.message.MessageCreateEvent + +/** Event emitted when a chat command is invoked. **/ +public data class ChatCommandInvocationEvent( + override val command: ChatCommand<*>, + override val event: MessageCreateEvent +) : CommandInvocationEvent, MessageCreateEvent> + +/** Event emitted when a chat command invocation succeeds. **/ +public data class ChatCommandSucceededEvent( + override val command: ChatCommand<*>, + override val event: MessageCreateEvent +) : CommandSucceededEvent, MessageCreateEvent> + +/** Event emitted when a chat command's checks fail. **/ +public data class ChatCommandFailedChecksEvent( + override val command: ChatCommand<*>, + override val event: MessageCreateEvent, + override val reason: String, +) : CommandFailedChecksEvent, MessageCreateEvent> + +/** Event emitted when a chat command's argument parsing fails. **/ +public data class ChatCommandFailedParsingEvent( + override val command: ChatCommand<*>, + override val event: MessageCreateEvent, + override val exception: ArgumentParsingException, +) : CommandFailedParsingEvent, MessageCreateEvent> + +/** Event emitted when a chat command's invocation fails with an exception. **/ +public data class ChatCommandFailedWithExceptionEvent( + override val command: ChatCommand<*>, + override val event: MessageCreateEvent, + override val throwable: Throwable +) : CommandFailedWithExceptionEvent, MessageCreateEvent> diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/events/CommandEvents.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/events/CommandEvents.kt new file mode 100644 index 0000000000..408a01a565 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/events/CommandEvents.kt @@ -0,0 +1,47 @@ +package com.kotlindiscord.kord.extensions.commands.events + +import com.kotlindiscord.kord.extensions.ArgumentParsingException +import com.kotlindiscord.kord.extensions.commands.Command +import com.kotlindiscord.kord.extensions.events.KordExEvent +import dev.kord.core.event.Event + +/** + * Sealed interface representing a basic command event. + * + * @param C Command type + * @param E Event type + */ +public sealed interface CommandEvent : KordExEvent { + /** Command object that this event concerns. **/ + public val command: C + + /** Event object that triggered this invocation. **/ + public val event: E +} + +/** Basic event emitted when a command is invoked. **/ +public sealed interface CommandInvocationEvent : CommandEvent + +/** Basic event emitted when a command's invocation succeeds. **/ +public sealed interface CommandSucceededEvent : CommandEvent + +/** Basic event emitted when a command's invocation fails, for one reason or another. **/ +public sealed interface CommandFailedEvent : CommandEvent + +/** Basic event emitted when a command's checks fail, including for required bot permissions. **/ +public sealed interface CommandFailedChecksEvent : CommandFailedEvent { + /** Human-readable failure reason. **/ + public val reason: String +} + +/** Basic event emitted when a command's argument parsing fails. **/ +public sealed interface CommandFailedParsingEvent : CommandFailedEvent { + /** Argument parsing exception object. **/ + public val exception: ArgumentParsingException +} + +/** Basic event emitted when a command's body invocation fails with an exception. **/ +public sealed interface CommandFailedWithExceptionEvent : CommandFailedEvent { + /** Exception thrown for this failure. **/ + public val throwable: Throwable +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/events/MessageCommandEvents.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/events/MessageCommandEvents.kt new file mode 100644 index 0000000000..06c996b023 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/events/MessageCommandEvents.kt @@ -0,0 +1,90 @@ +package com.kotlindiscord.kord.extensions.commands.events + +import com.kotlindiscord.kord.extensions.commands.application.message.EphemeralMessageCommand +import com.kotlindiscord.kord.extensions.commands.application.message.MessageCommand +import com.kotlindiscord.kord.extensions.commands.application.message.PublicMessageCommand +import dev.kord.core.event.interaction.MessageCommandInteractionCreateEvent + +// region Invocation events + +/** Basic event emitted when a message command is invoked. **/ +public interface MessageCommandInvocationEvent> : + CommandInvocationEvent + +/** Event emitted when an ephemeral message command is invoked. **/ +public data class EphemeralMessageCommandInvocationEvent( + override val command: EphemeralMessageCommand, + override val event: MessageCommandInteractionCreateEvent +) : MessageCommandInvocationEvent + +/** Event emitted when a public message command is invoked. **/ +public data class PublicMessageCommandInvocationEvent( + override val command: PublicMessageCommand, + override val event: MessageCommandInteractionCreateEvent +) : MessageCommandInvocationEvent + +// endregion + +// region Succeeded events + +/** Basic event emitted when a message command invocation succeeds. **/ +public interface MessageCommandSucceededEvent> : + CommandSucceededEvent + +/** Event emitted when an ephemeral message command invocation succeeds. **/ +public data class EphemeralMessageCommandSucceededEvent( + override val command: EphemeralMessageCommand, + override val event: MessageCommandInteractionCreateEvent +) : MessageCommandSucceededEvent + +/** Event emitted when a public message command invocation succeeds. **/ +public data class PublicMessageCommandSucceededEvent( + override val command: PublicMessageCommand, + override val event: MessageCommandInteractionCreateEvent +) : MessageCommandSucceededEvent + +// endregion + +// region Failed events + +/** Basic event emitted when a message command invocation fails. **/ +public sealed interface MessageCommandFailedEvent> : + CommandFailedEvent + +/** Basic event emitted when a message command's checks fail. **/ +public interface MessageCommandFailedChecksEvent> : + MessageCommandFailedEvent, CommandFailedChecksEvent + +/** Event emitted when an ephemeral message command's checks fail. **/ +public data class EphemeralMessageCommandFailedChecksEvent( + override val command: EphemeralMessageCommand, + override val event: MessageCommandInteractionCreateEvent, + override val reason: String +) : MessageCommandFailedChecksEvent + +/** Event emitted when a public message command's checks fail. **/ +public data class PublicMessageCommandFailedChecksEvent( + override val command: PublicMessageCommand, + override val event: MessageCommandInteractionCreateEvent, + override val reason: String +) : MessageCommandFailedChecksEvent + +/** Basic event emitted when a message command invocation fails with an exception. **/ +public interface MessageCommandFailedWithExceptionEvent> : + MessageCommandFailedEvent, CommandFailedWithExceptionEvent + +/** Event emitted when an ephemeral message command invocation fails with an exception. **/ +public data class EphemeralMessageCommandFailedWithExceptionEvent( + override val command: EphemeralMessageCommand, + override val event: MessageCommandInteractionCreateEvent, + override val throwable: Throwable +) : MessageCommandFailedWithExceptionEvent + +/** Event emitted when a public message command invocation fails with an exception. **/ +public data class PublicMessageCommandFailedWithExceptionEvent( + override val command: PublicMessageCommand, + override val event: MessageCommandInteractionCreateEvent, + override val throwable: Throwable +) : MessageCommandFailedWithExceptionEvent + +// endregion diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/events/SlashCommandEvents.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/events/SlashCommandEvents.kt new file mode 100644 index 0000000000..373d08b0a6 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/events/SlashCommandEvents.kt @@ -0,0 +1,109 @@ +package com.kotlindiscord.kord.extensions.commands.events + +import com.kotlindiscord.kord.extensions.ArgumentParsingException +import com.kotlindiscord.kord.extensions.commands.application.slash.EphemeralSlashCommand +import com.kotlindiscord.kord.extensions.commands.application.slash.PublicSlashCommand +import com.kotlindiscord.kord.extensions.commands.application.slash.SlashCommand +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent + +// region Invocation events + +/** Basic event emitted when a slash command is invoked. **/ +public interface SlashCommandInvocationEvent> : + CommandInvocationEvent + +/** Event emitted when an ephemeral slash command is invoked. **/ +public data class EphemeralSlashCommandInvocationEvent( + override val command: EphemeralSlashCommand<*>, + override val event: ChatInputCommandInteractionCreateEvent +) : SlashCommandInvocationEvent> + +/** Event emitted when a public slash command is invoked. **/ +public data class PublicSlashCommandInvocationEvent( + override val command: PublicSlashCommand<*>, + override val event: ChatInputCommandInteractionCreateEvent +) : SlashCommandInvocationEvent> + +// endregion + +// region Succeeded events + +/** Basic event emitted when a slash command invocation succeeds. **/ +public interface SlashCommandSucceededEvent> : + CommandSucceededEvent + +/** Event emitted when an ephemeral slash command invocation succeeds. **/ +public data class EphemeralSlashCommandSucceededEvent( + override val command: EphemeralSlashCommand<*>, + override val event: ChatInputCommandInteractionCreateEvent +) : SlashCommandSucceededEvent> + +/** Event emitted when a public slash command invocation succeeds. **/ +public data class PublicSlashCommandSucceededEvent( + override val command: PublicSlashCommand<*>, + override val event: ChatInputCommandInteractionCreateEvent +) : SlashCommandSucceededEvent> + +// endregion + +// region Failed events + +/** Basic event emitted when a slash command invocation fails. **/ +public sealed interface SlashCommandFailedEvent> : + CommandFailedEvent + +/** Basic event emitted when a slash command's checks fail. **/ +public interface SlashCommandFailedChecksEvent> : + SlashCommandFailedEvent, CommandFailedChecksEvent + +/** Event emitted when an ephemeral slash command's checks fail. **/ +public data class EphemeralSlashCommandFailedChecksEvent( + override val command: EphemeralSlashCommand<*>, + override val event: ChatInputCommandInteractionCreateEvent, + override val reason: String, +) : SlashCommandFailedChecksEvent> + +/** Event emitted when a public slash command's checks fail. **/ +public data class PublicSlashCommandFailedChecksEvent( + override val command: PublicSlashCommand<*>, + override val event: ChatInputCommandInteractionCreateEvent, + override val reason: String, +) : SlashCommandFailedChecksEvent> + +/** Basic event emitted when a slash command's argument parsing fails'. **/ +public interface SlashCommandFailedParsingEvent> : + SlashCommandFailedEvent, CommandFailedParsingEvent + +/** Event emitted when an ephemeral slash command's argument parsing fails'. **/ +public data class EphemeralSlashCommandFailedParsingEvent( + override val command: EphemeralSlashCommand<*>, + override val event: ChatInputCommandInteractionCreateEvent, + override val exception: ArgumentParsingException, +) : SlashCommandFailedParsingEvent> + +/** Event emitted when a public slash command's argument parsing fails'. **/ +public data class PublicSlashCommandFailedParsingEvent( + override val command: PublicSlashCommand<*>, + override val event: ChatInputCommandInteractionCreateEvent, + override val exception: ArgumentParsingException, +) : SlashCommandFailedParsingEvent> + +/** Basic event emitted when a slash command invocation fails with an exception. **/ +public interface SlashCommandFailedWithExceptionEvent> : + SlashCommandFailedEvent, CommandFailedWithExceptionEvent + +/** Event emitted when an ephemeral slash command invocation fails with an exception. **/ +public data class EphemeralSlashCommandFailedWithExceptionEvent( + override val command: EphemeralSlashCommand<*>, + override val event: ChatInputCommandInteractionCreateEvent, + override val throwable: Throwable +) : SlashCommandFailedWithExceptionEvent> + +/** Event emitted when a public slash command invocation fails with an exception. **/ +public data class PublicSlashCommandFailedWithExceptionEvent( + override val command: PublicSlashCommand<*>, + override val event: ChatInputCommandInteractionCreateEvent, + override val throwable: Throwable +) : SlashCommandFailedWithExceptionEvent> + +// endregion diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/events/UserCommandEvents.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/events/UserCommandEvents.kt new file mode 100644 index 0000000000..4c3b4ca027 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/events/UserCommandEvents.kt @@ -0,0 +1,90 @@ +package com.kotlindiscord.kord.extensions.commands.events + +import com.kotlindiscord.kord.extensions.commands.application.user.EphemeralUserCommand +import com.kotlindiscord.kord.extensions.commands.application.user.PublicUserCommand +import com.kotlindiscord.kord.extensions.commands.application.user.UserCommand +import dev.kord.core.event.interaction.UserCommandInteractionCreateEvent + +// region Invocation events + +/** Basic event emitted when a user command is invoked. **/ +public interface UserCommandInvocationEvent> : + CommandInvocationEvent + +/** Event emitted when an ephemeral user command is invoked. **/ +public data class EphemeralUserCommandInvocationEvent( + override val command: EphemeralUserCommand, + override val event: UserCommandInteractionCreateEvent +) : UserCommandInvocationEvent + +/** Event emitted when a public user command is invoked. **/ +public data class PublicUserCommandInvocationEvent( + override val command: PublicUserCommand, + override val event: UserCommandInteractionCreateEvent +) : UserCommandInvocationEvent + +// endregion + +// region Succeeded events + +/** Basic event emitted when a user command invocation succeeds. **/ +public interface UserCommandSucceededEvent> : + CommandSucceededEvent + +/** Event emitted when an ephemeral user command invocation succeeds. **/ +public data class EphemeralUserCommandSucceededEvent( + override val command: EphemeralUserCommand, + override val event: UserCommandInteractionCreateEvent +) : UserCommandSucceededEvent + +/** Event emitted when a public user command invocation succeeds. **/ +public data class PublicUserCommandSucceededEvent( + override val command: PublicUserCommand, + override val event: UserCommandInteractionCreateEvent +) : UserCommandSucceededEvent + +// endregion + +// region Failed events + +/** Basic event emitted when a user command invocation fails. **/ +public sealed interface UserCommandFailedEvent> : + CommandFailedEvent + +/** Basic event emitted when a user command's checks fail. **/ +public interface UserCommandFailedChecksEvent> : + UserCommandFailedEvent, CommandFailedChecksEvent + +/** Event emitted when an ephemeral user command's checks fail. **/ +public data class EphemeralUserCommandFailedChecksEvent( + override val command: EphemeralUserCommand, + override val event: UserCommandInteractionCreateEvent, + override val reason: String, +) : UserCommandFailedChecksEvent + +/** Event emitted when a public user command's checks fail. **/ +public data class PublicUserCommandFailedChecksEvent( + override val command: PublicUserCommand, + override val event: UserCommandInteractionCreateEvent, + override val reason: String, +) : UserCommandFailedChecksEvent + +/** Basic event emitted when a user command invocation fails with an exception. **/ +public interface UserCommandFailedWithExceptionEvent> : + UserCommandFailedEvent, CommandFailedWithExceptionEvent + +/** Event emitted when an ephemeral user command invocation fails with an exception. **/ +public data class EphemeralUserCommandFailedWithExceptionEvent( + override val command: EphemeralUserCommand, + override val event: UserCommandInteractionCreateEvent, + override val throwable: Throwable +) : UserCommandFailedWithExceptionEvent + +/** Event emitted when a public user command invocation fails with an exception. **/ +public data class PublicUserCommandFailedWithExceptionEvent( + override val command: PublicUserCommand, + override val event: UserCommandInteractionCreateEvent, + override val throwable: Throwable +) : UserCommandFailedWithExceptionEvent + +// endregion diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/Annotations.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/Annotations.kt deleted file mode 100644 index 1936db16fd..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/Annotations.kt +++ /dev/null @@ -1,13 +0,0 @@ -@file:Suppress("Filename", "MatchingDeclarationName") - -package com.kotlindiscord.kord.extensions.commands.slash - -@RequiresOptIn( - message = "Due to limitations in the Discord API, it's not currently possible to translate this property.", - - level = RequiresOptIn.Level.WARNING -) -@Retention(AnnotationRetention.BINARY) -@Target(AnnotationTarget.PROPERTY) -/** Opt-in annotation to alert users that a string can't be translated yet. **/ -public annotation class TranslationNotSupported diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/AutoAckType.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/AutoAckType.kt deleted file mode 100644 index ce346c1d49..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/AutoAckType.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.kotlindiscord.kord.extensions.commands.slash - -/** Acknowledgement type for autoAck. **/ -public sealed class AutoAckType { - /** Ephemeral acknowledgement. **/ - public object EPHEMERAL : AutoAckType() - - /** Public acknowledgement. **/ - public object PUBLIC : AutoAckType() - - /** Do not ack automatically. **/ - public object NONE : AutoAckType() -} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/SlashCommand.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/SlashCommand.kt deleted file mode 100644 index 78b3125564..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/SlashCommand.kt +++ /dev/null @@ -1,741 +0,0 @@ -@file:OptIn(KordPreview::class, TranslationNotSupported::class) -@file:Suppress("StringLiteralDuplication") - -package com.kotlindiscord.kord.extensions.commands.slash - -import com.kotlindiscord.kord.extensions.CommandException -import com.kotlindiscord.kord.extensions.CommandRegistrationException -import com.kotlindiscord.kord.extensions.InvalidCommandException -import com.kotlindiscord.kord.extensions.annotations.ExtensionDSL -import com.kotlindiscord.kord.extensions.builders.ExtensibleBotBuilder -import com.kotlindiscord.kord.extensions.checks.types.Check -import com.kotlindiscord.kord.extensions.checks.types.CheckContext -import com.kotlindiscord.kord.extensions.commands.Command -import com.kotlindiscord.kord.extensions.commands.parser.Arguments -import com.kotlindiscord.kord.extensions.commands.slash.parser.SlashCommandParser -import com.kotlindiscord.kord.extensions.extensions.Extension -import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider -import com.kotlindiscord.kord.extensions.sentry.SentryAdapter -import com.kotlindiscord.kord.extensions.sentry.tag -import com.kotlindiscord.kord.extensions.sentry.user -import com.kotlindiscord.kord.extensions.utils.getLocale -import com.kotlindiscord.kord.extensions.utils.permissionsForMember -import com.kotlindiscord.kord.extensions.utils.translate -import dev.kord.common.annotation.KordPreview -import dev.kord.common.entity.Permission -import dev.kord.common.entity.Snowflake -import dev.kord.core.Kord -import dev.kord.core.KordObject -import dev.kord.core.any -import dev.kord.core.behavior.GuildBehavior -import dev.kord.core.behavior.UserBehavior -import dev.kord.core.behavior.interaction.respondEphemeral -import dev.kord.core.behavior.interaction.respondPublic -import dev.kord.core.entity.channel.DmChannel -import dev.kord.core.entity.channel.GuildChannel -import dev.kord.core.entity.channel.GuildMessageChannel -import dev.kord.core.entity.interaction.CommandInteraction -import dev.kord.core.entity.interaction.GroupCommand -import dev.kord.core.entity.interaction.SubCommand -import dev.kord.core.event.interaction.InteractionCreateEvent -import io.sentry.Sentry -import io.sentry.protocol.SentryId -import kotlinx.coroutines.flow.toList -import mu.KLogger -import mu.KotlinLogging -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import java.util.* - -private val logger: KLogger = KotlinLogging.logger {} -private const val DISCORD_LIMIT: Int = 25 - -/** - * Class representing a slash command. - * - * You shouldn't need to use this class directly - instead, create an [Extension] and use the - * [slash command function][Extension.slashCommand] to register your command, by overriding the [Extension.setup] - * function. - * - * @param extension The [Extension] that registered this command. - * @param arguments Arguments object builder for this command, if it has arguments. - * @param parentCommand If this is a subcommand, the root command this command belongs to. - * @param parentGroup If this is a grouped subcommand, the group this command belongs to. - */ -@ExtensionDSL -public open class SlashCommand( - extension: Extension, - public open val arguments: (() -> T)? = null, - - public open val parentCommand: SlashCommand? = null, - public open val parentGroup: SlashGroup? = null -) : Command(extension), KoinComponent { - /** Translations provider, for retrieving translations. **/ - public val translationsProvider: TranslationsProvider by inject() - - private val settings: ExtensibleBotBuilder by inject() - - /** Kord instance, backing the ExtensibleBot. **/ - public val kord: Kord by inject() - - /** Sentry adapter, for easy access to Sentry functions. **/ - public val sentry: SentryAdapter by inject() - - /** Command description, as displayed on Discord. **/ - public open lateinit var description: String - - /** @suppress **/ - public open lateinit var body: suspend SlashCommandContext.() -> Unit - - /** Whether this command has a body/action set. **/ - public open val hasBody: Boolean get() = ::body.isInitialized - - /** Guild ID this slash command is to be registered for, if any. **/ - public open var guild: Snowflake? = if (parentCommand == null && parentGroup == null) { - settings.slashCommandsBuilder.defaultGuild - } else { - null - } - - /** - * Whether to allow everyone to use this command by default. Set to `false` to use the allowed/disallowed role/user - * lists instead. This will be set to `false` automatically by the allow/disallow functions. - */ - public open var allowByDefault: Boolean = parentCommand?.allowByDefault ?: true - - /** - * List of allowed role IDs. Allows take priority over disallows. - */ - public open val allowedRoles: MutableSet = parentCommand?.allowedRoles ?: mutableSetOf() - - /** - * List of allowed invoker IDs. Allows take priority over disallows. - */ - public open val allowedUsers: MutableSet = parentCommand?.allowedUsers ?: mutableSetOf() - - /** - * List of disallowed role IDs. Allows take priority over disallows. - */ - public open val disallowedRoles: MutableSet = parentCommand?.disallowedRoles ?: mutableSetOf() - - /** - * List of disallowed invoker IDs. Allows take priority over disallows. - */ - public open val disallowedUsers: MutableSet = parentCommand?.disallowedUsers ?: mutableSetOf() - - /** Types of automatic ack to use, if any. **/ - public open var autoAck: AutoAckType = AutoAckType.EPHEMERAL - - /** Map of group names to slash command groups, if any. **/ - public open val groups: MutableMap = mutableMapOf() - - /** List of subcommands, if any. **/ - public open val subCommands: MutableList> = mutableListOf() - - /** @suppress **/ - public open val checkList: MutableList> = mutableListOf() - - public override val parser: SlashCommandParser = SlashCommandParser() - - /** Permissions required to be able to run this command. **/ - public open val requiredPerms: MutableSet = mutableSetOf() - - /** Translation cache, so we don't have to look up translations every time. **/ - public open val nameTranslationCache: MutableMap = mutableMapOf() - - /** Return this command's name translated for the given locale, cached as required. **/ - public open fun getTranslatedName(locale: Locale): String { - if (!nameTranslationCache.containsKey(locale)) { - nameTranslationCache[locale] = translationsProvider.translate( - this.name, - this.extension.bundle, - locale - ).lowercase() - } - - return nameTranslationCache[locale]!! - } - - /** - * An internal function used to ensure that all of a command's required properties are present. - * - * @throws InvalidCommandException Thrown when a required property hasn't been set. - */ - @Throws(InvalidCommandException::class) - public override fun validate() { - super.validate() - - if (!::description.isInitialized) { - throw InvalidCommandException(name, "No command description given.") - } - - if (!::body.isInitialized && groups.isEmpty() && subCommands.isEmpty()) { - throw InvalidCommandException(name, "No command action or subcommands/groups given.") - } - - if (::body.isInitialized && !(groups.isEmpty() && subCommands.isEmpty())) { - throw InvalidCommandException( - name, - - "Command action and subcommands/groups given, but slash commands may not have an action if they have" + - " a subcommand or group." - ) - } - - if (parentCommand != null && guild != null) { - throw InvalidCommandException( - name, - - "Subcommands may not be limited to specific guilds - set the `guild` property on the parent command " + - "instead." - ) - } - } - - /** If your bot requires permissions to be able to execute the command, add them using this function. **/ - public fun requirePermissions(vararg perms: Permission) { - perms.forEach { requiredPerms.add(it) } - } - - // region: DSL functions - - /** - * Create a command group, using the given name. - * - * Note that only root/top-level commands can contain command groups. An error will be thrown if you try to use - * this with a subcommand. - * - * @param name Name of the command group on Discord. - * @param body Lambda used to build the [SlashGroup] object. - */ - public open suspend fun group(name: String, body: suspend SlashGroup.() -> Unit): SlashGroup { - if (parentCommand != null) { - error("Command groups may not be nested inside subcommands.") - } - - if (subCommands.isNotEmpty()) { - error("Commands may only contain subcommands or command groups, not both.") - } - - if (groups.size >= DISCORD_LIMIT) { - error("Commands may only contain up to $DISCORD_LIMIT command groups.") - } - - if (groups[name] != null) { - error("A command group with the name '$name' has already been registered.") - } - - val group = SlashGroup(name, this) - - body.invoke(group) - group.validate() - - groups[name] = group - - return group - } - - /** Specify a specific guild for this slash command. **/ - public open fun guild(guild: Snowflake) { - this.guild = guild - } - - /** Specify a specific guild for this slash command. **/ - public open fun guild(guild: Long) { - this.guild = Snowflake(guild) - } - - /** Specify a specific guild for this slash command. **/ - public open fun guild(guild: GuildBehavior) { - this.guild = guild.id - } - - /** Register an allowed role, and set [allowByDefault] to `false`. **/ - public open fun allowRole(role: Snowflake) { - allowByDefault = false - - allowedRoles.add(role) - } - - /** Register an allowed role, and set [allowByDefault] to `false`. **/ - public open fun allowRole(role: UserBehavior): Unit = - allowRole(role.id) - - /** Register a disallowed role, and set [allowByDefault] to `false`. **/ - public open fun disallowRole(role: Snowflake) { - allowByDefault = false - - disallowedRoles.add(role) - } - - /** Register a disallowed role, and set [allowByDefault] to `false`. **/ - public open fun disallowRole(role: UserBehavior): Unit = - disallowRole(role.id) - - /** Register an allowed user, and set [allowByDefault] to `false`. **/ - public open fun allowUser(user: Snowflake) { - allowByDefault = false - - allowedUsers.add(user) - } - - /** Register an allowed user, and set [allowByDefault] to `false`. **/ - public open fun allowUser(user: UserBehavior): Unit = - allowUser(user.id) - - /** Register a disallowed user, and set [allowByDefault] to `false`. **/ - public open fun disallowUser(user: Snowflake) { - allowByDefault = false - - disallowedUsers.add(user) - } - - /** Register a disallowed user, and set [allowByDefault] to `false`. **/ - public open fun disallowUser(user: UserBehavior): Unit = - disallowUser(user.id) - - /** - * DSL function for easily registering a subcommand, with arguments. - * - * Use this in your setup function to register a subcommand that may be executed on Discord. - * - * @param arguments Arguments builder (probably a reference to the class constructor). - * @param body Builder lambda used for setting up the slash command object. - */ - public open suspend fun subCommand( - arguments: (() -> T), - body: suspend SlashCommand.() -> Unit - ): SlashCommand { - val commandObj = SlashCommand(this.extension, arguments, this) - body.invoke(commandObj) - - return subCommand(commandObj) - } - - /** - * DSL function for easily registering a subcommand, without arguments. - * - * Use this in your slash command function to register a subcommand that may be executed on Discord. - * - * @param body Builder lambda used for setting up the subcommand object. - */ - public open suspend fun subCommand( - body: suspend SlashCommand.() -> Unit - ): SlashCommand { - val commandObj = SlashCommand(this.extension, null, this) - body.invoke(commandObj) - - return subCommand(commandObj) - } - - /** - * Function for registering a custom slash command object, for subcommands. - * - * You can use this if you have a custom slash command subclass you need to register. - * - * @param commandObj SlashCommand object to register as a subcommand. - */ - public open suspend fun subCommand( - commandObj: SlashCommand - ): SlashCommand { - if (parentCommand != null) { - error("Subcommands may not be nested inside subcommands.") - } - - if (groups.isNotEmpty()) { - error("Commands may only contain subcommands or command groups, not both.") - } - - if (subCommands.size >= DISCORD_LIMIT) { - error("Commands may only contain up to $DISCORD_LIMIT top-level subcommands.") - } - - try { - commandObj.validate() - subCommands.add(commandObj) - } catch (e: CommandRegistrationException) { - logger.error(e) { "Failed to register subcommand - $e" } - } catch (e: InvalidCommandException) { - logger.error(e) { "Failed to register subcommand - $e" } - } - - return commandObj - } - - /** - * Define what will happen when your command is invoked. - * - * @param action The body of your command, which will be executed when your command is invoked. - */ - public open fun action(action: suspend SlashCommandContext.() -> Unit) { - this.body = action - } - - /** - * Define a check which must pass for the command to be executed. - * - * A command may have multiple checks - all checks must pass for the command to be executed. - * Checks will be run in the order that they're defined. - * - * This function can be used DSL-style with a given body, or it can be passed one or more - * predefined functions. See the samples for more information. - * - * @param checks Checks to apply to this command. - */ - public open fun check(vararg checks: Check) { - checks.forEach { checkList.add(it) } - } - - /** - * Overloaded check function to allow for DSL syntax. - * - * @param check Check to apply to this command. - */ - public open fun check(check: Check) { - checkList.add(check) - } - - /** - * Define a simple Boolean check which must pass for the command to be executed. - * - * Boolean checks are simple wrappers around the regular check system, allowing you to define a basic check that - * takes an event object and returns a [Boolean] representing whether it passed. This style of check does not have - * the same functionality as a regular check, and cannot return a message. - * - * A command may have multiple checks - all checks must pass for the command to be executed. - * Checks will be run in the order that they're defined. - * - * This function can be used DSL-style with a given body, or it can be passed one or more - * predefined functions. See the samples for more information. - * - * @param checks Checks to apply to this command. - */ - public open fun booleanCheck(vararg checks: suspend (InteractionCreateEvent) -> Boolean) { - checks.forEach(::booleanCheck) - } - - /** - * Overloaded simple Boolean check function to allow for DSL syntax. - * - * Boolean checks are simple wrappers around the regular check system, allowing you to define a basic check that - * takes an event object and returns a [Boolean] representing whether it passed. This style of check does not have - * the same functionality as a regular check, and cannot return a message. - * - * @param check Check to apply to this command. - */ - public open fun booleanCheck(check: suspend (InteractionCreateEvent) -> Boolean) { - check { - if (check(event)) { - pass() - } else { - fail() - } - } - } - - // endregion - - /** Run checks with the provided [InteractionCreateEvent]. Return false if any failed, true otherwise. **/ - public open suspend fun runChecks(event: InteractionCreateEvent, sendMessage: Boolean = true): Boolean { - val locale = event.getLocale() - - // global checks - for (check in extension.bot.settings.slashCommandsBuilder.checkList) { - val context = CheckContext(event, locale) - - check(context) - - if (!context.passed) { - val message = context.message - - if (message != null && sendMessage) { - if (autoAck == AutoAckType.EPHEMERAL) { - event.interaction.respondEphemeral { - content = translationsProvider.translate( - "checks.responseTemplate", - replacements = arrayOf(message) - ) - } - } else { - event.interaction.respondPublic { - content = translationsProvider.translate( - "checks.responseTemplate", - replacements = arrayOf(message) - ) - } - } - } - - return false - } - } - - // local extension checks - for (check in extension.slashCommandChecks) { - val context = CheckContext(event, locale) - - check(context) - - if (!context.passed) { - val message = context.message - - if (message != null && sendMessage) { - if (autoAck == AutoAckType.EPHEMERAL) { - event.interaction.respondEphemeral { - content = translationsProvider.translate( - "checks.responseTemplate", - replacements = arrayOf(message) - ) - } - } else { - event.interaction.respondPublic { - content = translationsProvider.translate( - "checks.responseTemplate", - replacements = arrayOf(message) - ) - } - } - } - - return false - } - } - - // parent command checks - if (parentCommand != null) { - val parentChecks = parentCommand!!.runChecks(event) - - if (!parentChecks) { - return false - } - } - - // command-specific checks - for (check in checkList) { - val context = CheckContext(event, locale) - - check(context) - - if (!context.passed) { - val message = context.message - - if (message != null && sendMessage) { - if (autoAck == AutoAckType.EPHEMERAL) { - event.interaction.respondEphemeral { - content = translationsProvider.translate( - "checks.responseTemplate", - replacements = arrayOf(message) - ) - } - } else { - event.interaction.respondPublic { - content = translationsProvider.translate( - "checks.responseTemplate", - replacements = arrayOf(message) - ) - } - } - } - - return false - } - } - - val channel = event.interaction.channel.asChannel() as? GuildMessageChannel - - // check that discord should enforce, but we don't trust them to - if (!allowByDefault) { - if (channel != null) { - val member = event.interaction.user.asMember(channel.guildId) - - return member.id in allowedUsers || member.roles.any { it.id in allowedRoles } - } - } else { - if (channel != null) { - val member = event.interaction.user.asMember(channel.guildId) - - return member.id !in disallowedUsers && member.roles.toList().all { it.id !in disallowedRoles } - } - } - - return true - } - - /** - * Execute this command, given an [InteractionCreateEvent]. - * - * This function takes a [InteractionCreateEvent] (generated when a slash command is executed), and - * processes it. The command's checks are invoked and, assuming all of the - * checks passed, the [command body][action] is executed. - * - * If an exception is thrown by the [command body][action], it is caught and a traceback - * is printed. - * - * @param event The interaction creation event. - */ - public open suspend fun call(event: InteractionCreateEvent) { - if (event.interaction !is CommandInteraction) return - - val interaction = event.interaction as CommandInteraction - val eventCommand = interaction.command - - // We lie to the compiler thrice below to work around an issue with generics. - val commandObj: SlashCommand = if (eventCommand is SubCommand) { - val firstSubCommandKey = eventCommand.name - - this.subCommands.firstOrNull { it.name == firstSubCommandKey } as SlashCommand? - ?: error("Unknown subcommand: $firstSubCommandKey") - } else if (eventCommand is GroupCommand) { - val firstEventGroupKey = eventCommand.groupName - val group = this.groups[firstEventGroupKey] ?: error("Unknown command group: $firstEventGroupKey") - val firstSubCommandKey = eventCommand.name - - group.subCommands.firstOrNull { it.name == firstSubCommandKey } as SlashCommand? - ?: error("Unknown subcommand: $firstSubCommandKey") - } else { - this as SlashCommand - } - - if (!commandObj.runChecks(event)) { - return - } - - val resp = when (commandObj.autoAck) { - AutoAckType.EPHEMERAL -> interaction.acknowledgeEphemeral() - AutoAckType.PUBLIC -> interaction.acknowledgePublic() - - AutoAckType.NONE -> null - } - - val context = SlashCommandContext(commandObj, event, commandObj.name, resp) - - context.populate() - - val firstBreadcrumb = if (sentry.enabled) { - val channel = context.channel.asChannelOrNull() - val guild = context.guild?.asGuildOrNull() - - val data = mutableMapOf( - "command" to commandObj.name - ) - - if (this.guild != null) { - data["command.guild"] to this.guild!!.asString - } - - if (channel != null) { - data["channel"] = when (channel) { - is DmChannel -> "Private Message (${channel.id.asString})" - is GuildMessageChannel -> "#${channel.name} (${channel.id.asString})" - - else -> channel.id.asString - } - } - - if (guild != null) { - data["guild"] = "${guild.name} (${guild.id.asString})" - } - - sentry.createBreadcrumb( - category = "command.slash", - type = "user", - message = "Slash command \"${commandObj.name}\" called.", - data = data - ) - } else { - null - } - - @Suppress("TooGenericExceptionCaught") - try { - if (context.guild != null) { - val perms = (context.channel.asChannel() as GuildChannel) - .permissionsForMember(kord.selfId) - - val missingPerms = requiredPerms.filter { !perms.contains(it) } - - if (missingPerms.isNotEmpty()) { - throw CommandException( - context.translate( - "commands.error.missingBotPermissions", - null, - replacements = arrayOf( - missingPerms.map { it.translate(context) }.joinToString(", ") - ) - ) - ) - } - } - - if (commandObj.arguments != null) { - val args = commandObj.parser.parse(commandObj.arguments!!, context) - context.populateArgs(args) - } - - commandObj.body(context) - } catch (e: CommandException) { - respondText(context, e.reason) - } catch (t: Throwable) { - if (sentry.enabled) { - logger.debug { "Submitting error to sentry." } - - lateinit var sentryId: SentryId - - val channel = context.channel - val author = context.user.asUserOrNull() - - Sentry.withScope { - if (author != null) { - it.user(author) - } - - it.tag("private", "false") - - if (channel is DmChannel) { - it.tag("private", "true") - } - - it.tag("command", commandObj.name) - it.tag("extension", commandObj.extension.name) - - it.addBreadcrumb(firstBreadcrumb!!) - - context.breadcrumbs.forEach { breadcrumb -> it.addBreadcrumb(breadcrumb) } - - sentryId = Sentry.captureException(t, "SlashCommand execution failed.") - - logger.debug { "Error submitted to Sentry: $sentryId" } - } - - sentry.addEventId(sentryId) - - logger.error(t) { "Error during execution of ${commandObj.name} slash command ($event)" } - - val errorMessage = if (extension.bot.extensions.containsKey("sentry")) { - context.translate("commands.error.user.sentry.slash", null, replacements = arrayOf(sentryId)) - } else { - context.translate("commands.error.user", null) - } - - respondText(context, errorMessage) - } else { - logger.error(t) { "Error during execution of ${commandObj.name} slash command ($event)" } - - respondText(context, context.translate("commands.error.user", null)) - } - } - } - - private suspend fun respondText( - context: SlashCommandContext<*>, - text: String - ): KordObject = when (context.isEphemeral) { - null -> { - context.ack(true) - context.ephemeralFollowUp { content = text } - } - - true -> context.ephemeralFollowUp { content = text } - false -> context.publicFollowUp { content = text } - } -} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/SlashCommandContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/SlashCommandContext.kt deleted file mode 100644 index 493b83345d..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/SlashCommandContext.kt +++ /dev/null @@ -1,214 +0,0 @@ -package com.kotlindiscord.kord.extensions.commands.slash - -import com.kotlindiscord.kord.extensions.annotations.ExtensionDSL -import com.kotlindiscord.kord.extensions.checks.channelFor -import com.kotlindiscord.kord.extensions.checks.guildFor -import com.kotlindiscord.kord.extensions.checks.memberFor -import com.kotlindiscord.kord.extensions.commands.CommandContext -import com.kotlindiscord.kord.extensions.commands.parser.Arguments -import com.kotlindiscord.kord.extensions.components.Components -import com.kotlindiscord.kord.extensions.pagination.InteractionButtonPaginator -import com.kotlindiscord.kord.extensions.pagination.builders.PaginatorBuilder -import dev.kord.common.annotation.KordPreview -import dev.kord.core.behavior.MemberBehavior -import dev.kord.core.behavior.MessageBehavior -import dev.kord.core.behavior.UserBehavior -import dev.kord.core.behavior.interaction.* -import dev.kord.core.entity.Guild -import dev.kord.core.entity.channel.MessageChannel -import dev.kord.core.entity.interaction.CommandInteraction -import dev.kord.core.entity.interaction.InteractionFollowup -import dev.kord.core.entity.interaction.PublicFollowupMessage -import dev.kord.core.event.interaction.InteractionCreateEvent -import dev.kord.rest.builder.message.create.EphemeralFollowupMessageCreateBuilder -import dev.kord.rest.builder.message.create.MessageCreateBuilder -import dev.kord.rest.builder.message.create.PublicFollowupMessageCreateBuilder -import dev.kord.rest.builder.message.modify.MessageModifyBuilder - -/** - * Command context object representing the context given to message commands. - * - * @property interactionResponse Interaction response object, for following up - */ -@OptIn(KordPreview::class) -@ExtensionDSL -public open class SlashCommandContext( - private val slashCommand: SlashCommand, - event: InteractionCreateEvent, - commandName: String, - public var interactionResponse: InteractionResponseBehavior? = null -) : CommandContext(slashCommand, event, commandName, null) { - /** Event that triggered this command execution. **/ - public val event: InteractionCreateEvent get() = eventObj as InteractionCreateEvent - - /** Quick access to the [CommandInteraction]. **/ - public val interaction: CommandInteraction get() = event.interaction as CommandInteraction - - /** Channel this command happened in. **/ - public open lateinit var channel: MessageChannel - - /** Guild this command happened in. **/ - public open var guild: Guild? = null - - /** Guild member responsible for executing this command. **/ - public open var member: MemberBehavior? = null - - /** User responsible for executing this command. **/ - public open lateinit var user: UserBehavior - - /** Arguments object containing this command's parsed arguments. **/ - public open lateinit var arguments: T - - /** Whether a response or ack has already been sent by the user. **/ - public open val acked: Boolean get() = interactionResponse != null - - /** Whether we're working ephemerally, or null if no ack or response was sent yet. **/ - public open val isEphemeral: Boolean? - get() = when (interactionResponse) { - is EphemeralInteractionResponseBehavior -> true - is PublicInteractionResponseBehavior -> false - - else -> null - } - - override val command: SlashCommand get() = slashCommand - - override suspend fun populate() { - channel = getChannel() - guild = getGuild() - member = getMember() - user = getUser() - } - - /** @suppress Internal function **/ - public fun populateArgs(args: T) { - arguments = args - } - - override suspend fun getChannel(): MessageChannel = channelFor(event)!!.asChannel() as MessageChannel - override suspend fun getGuild(): Guild? = guildFor(event)?.asGuildOrNull() - override suspend fun getMember(): MemberBehavior? = memberFor(event)?.asMemberOrNull() - override suspend fun getMessage(): MessageBehavior? = null - override suspend fun getUser(): UserBehavior = event.interaction.user - - /** - * Convenience function to create a button paginator using a builder DSL syntax. Handles the contextual stuff for - * you. - */ - public suspend fun paginator( - defaultGroup: String = "", - body: suspend PaginatorBuilder.() -> Unit - ): InteractionButtonPaginator { - val builder = PaginatorBuilder(command.extension, getLocale(), defaultGroup = defaultGroup) - - body(builder) - - return InteractionButtonPaginator(builder, this) - } - - /** - * Send an acknowledgement manually, assuming you have `autoAck` set to `NONE`. - * - * Note that what you supply for `ephemeral` will decide how the rest of your interactions - both responses and - * follow-ups. They must match in ephemeral state. - * - * This function will throw an exception if an acknowledgement or response has already been sent. - * - * @param ephemeral Whether this should be an ephemeral acknowledgement or not. - */ - public suspend fun ack(ephemeral: Boolean): InteractionResponseBehavior { - if (acked) { - error("Attempted to acknowledge an interaction that's already been acknowledged.") - } - - interactionResponse = if (ephemeral) { - event.interaction.acknowledgeEphemeral() - } else { - event.interaction.acknowledgePublic() - } - - return interactionResponse!! - } - - /** - * Assuming an acknowledgement or response has been sent, send an ephemeral follow-up message. - * - * This function will throw an exception if no acknowledgement or response has been sent yet, or this interaction - * has already been interacted with in a non-ephemeral manner. - * - * Note that ephemeral follow-ups require a content string, and may not contain embeds or files. - */ - public suspend inline fun ephemeralFollowUp( - builder: EphemeralFollowupMessageCreateBuilder.() -> Unit = {} - ): InteractionFollowup { - if (interactionResponse == null) { - error("Tried send an interaction follow-up before acknowledging it.") - } - - if (isEphemeral == false) { - error("Tried send an ephemeral follow-up for a public interaction.") - } - - return (interactionResponse as EphemeralInteractionResponseBehavior).followUpEphemeral(builder) - } - - /** - * Assuming an acknowledgement or response has been sent, send a public follow-up message. - * - * This function will throw an exception if no acknowledgement or response has been sent yet, or this interaction - * has already been interacted with in an ephemeral manner. - */ - public suspend inline fun publicFollowUp( - builder: PublicFollowupMessageCreateBuilder.() -> Unit - ): PublicFollowupMessage { - if (interactionResponse == null) { - error("Tried send an interaction follow-up before acknowledging it.") - } - - if (isEphemeral == true) { - error("Tried to send a public follow-up for an ephemeral interaction.") - } - - return (interactionResponse as PublicInteractionResponseBehavior).followUp(builder) - } - - /** - * Convenience function for adding components to your message via the [Components] class. - * - * @see Components - */ - public suspend fun MessageCreateBuilder.components( - timeoutSeconds: Long? = null, - body: suspend Components.() -> Unit - ): Components { - val components = Components(command.extension, this@SlashCommandContext) - - body(components) - - with(components) { - setup(timeoutSeconds) - } - - return components - } - - /** - * Convenience function for adding components to your message via the [Components] class. - * - * @see Components - */ - public suspend fun MessageModifyBuilder.components( - timeoutSeconds: Long? = null, - body: suspend Components.() -> Unit - ): Components { - val components = Components(command.extension, this@SlashCommandContext) - - body(components) - - with(components) { - setup(timeoutSeconds) - } - - return components - } -} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/SlashCommandRegistry.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/SlashCommandRegistry.kt deleted file mode 100644 index c2779dd064..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/SlashCommandRegistry.kt +++ /dev/null @@ -1,353 +0,0 @@ -@file:OptIn(TranslationNotSupported::class) - -@file:Suppress("StringLiteralDuplication") - -package com.kotlindiscord.kord.extensions.commands.slash - -import com.kotlindiscord.kord.extensions.ExtensibleBot -import com.kotlindiscord.kord.extensions.commands.converters.SlashCommandConverter -import com.kotlindiscord.kord.extensions.commands.parser.Arguments -import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider -import dev.kord.common.annotation.KordPreview -import dev.kord.common.entity.Snowflake -import dev.kord.core.Kord -import dev.kord.core.SlashCommands -import dev.kord.core.entity.interaction.CommandInteraction -import dev.kord.core.event.interaction.InteractionCreateEvent -import dev.kord.rest.builder.interaction.ApplicationCommandCreateBuilder -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.toList -import mu.KLogger -import mu.KotlinLogging -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -private val logger: KLogger = KotlinLogging.logger {} - -/** - * Class responsible for keeping track of slash commands, registering and executing them. - * - * Currently only single-level commands are supported, no command groups or subcommands. - */ -@OptIn(KordPreview::class) -public open class SlashCommandRegistry : KoinComponent { - /** Current instance of the bot. **/ - public open val bot: ExtensibleBot by inject() - - /** Kord instance, backing the ExtensibleBot. **/ - public val kord: Kord by inject() - - /** Translations provider, for retrieving translations. **/ - public val translationsProvider: TranslationsProvider by inject() - - /** @suppress **/ - public open val commands: MutableMap>> = mutableMapOf( - null to mutableListOf() // So that global commands always have a list here - ) - - /** @suppress **/ - public open val commandMap: MutableMap> = mutableMapOf() - - /** @suppress **/ - public open val api: SlashCommands get() = kord.slashCommands - -// TODO: Sentry? -// private val sentry: SentryAdapter by bot.koin.inject() - - /** Register a slash command here, before they're synced to Discord. **/ - public open fun register(command: SlashCommand, guild: Snowflake? = null): Boolean { - val locale = bot.settings.i18nBuilder.defaultLocale - - commands.putIfAbsent(guild, mutableListOf()) - - val args = command.arguments?.invoke() - var lastArgRequired = true // Start with `true` because required args must come first - - args?.args?.forEach { arg -> - if (arg.converter !is SlashCommandConverter) { - error("Argument ${arg.displayName} does not support slash commands.") - } - - if (arg.converter.required && !lastArgRequired) { - error("Required arguments must be placed before non-required arguments.") - } - - lastArgRequired = arg.converter.required - } - - val exists = commands[guild]!!.any { it.name == command.getTranslatedName(locale) } - - if (exists) { - return false - } - - commands[guild]!!.add(command) - - return true - } - - /** - * Sync all slash commands to Discord, removing unrecognised ones. - * - * Note that Discord doesn't let us get a list of guilds we have commands on, so we can't - * remove commands for guilds the bot isn't present on. - */ - @Suppress("TooGenericExceptionCaught") // Better safe than sorry - public open suspend fun syncAll() { - logger.info { "Synchronising slash commands. This may take some time." } - - if (!bot.settings.slashCommandsBuilder.register) { - logger.debug { - "Slash command registration is disabled, pairing existing commands with extension commands." - } - } - - try { - sync(null) - } catch (t: Throwable) { - logger.error(t) { "Failed to sync global slash commands" } - } - - commands.keys.filterNotNull().forEach { - try { - sync(it) - } catch (t: Throwable) { - logger.error(t) { "Failed to sync slash commands for guild ID: $it" } - } - } - } - - /** @suppress **/ - public open suspend fun sync(guild: Snowflake?) { - val locale = bot.settings.i18nBuilder.defaultLocale - - val guildObj = if (guild != null) { - val guildObj = kord.getGuild(guild) - - if (guildObj == null) { - logger.warn { "Cannot register slash commands for guild ID $guild, as it seems to be missing." } - return - } - - guildObj - } else { - null - } - - val registered = commands[guild]!! - - val existing = if (guild == null) { - api.getGlobalApplicationCommands().map { Pair(it.name, it.id) }.toList() - } else { - api.getGuildApplicationCommands(guild).map { Pair(it.name, it.id) }.toList() - } - - if (!bot.settings.slashCommandsBuilder.register) { - registered.forEach { r -> - val existingCommand = existing.firstOrNull { it.first == r.getTranslatedName(locale) } - - if (existingCommand != null) { - commandMap[existingCommand.second] = r - } - } - } else { - val toAdd = registered.filter { r -> existing.all { it.first != r.getTranslatedName(locale) } } - val toUpdate = registered.filter { r -> existing.any { it.first == r.getTranslatedName(locale) } } - val toRemove = existing.filter { e -> registered.all { it.getTranslatedName(locale) != e.first } } - - logger.info { - if (guild == null) { - "Global slash commands: ${toAdd.size} to add / " + - "${toUpdate.size} to update / " + - "${toRemove.size} to remove" - } else { - "Slash commands for guild ${guildObj?.name}: ${toAdd.size} to add / " + - "${toUpdate.size} to update / " + - "${toRemove.size} to remove" - } - } - - val toCreate = toAdd + toUpdate - - if (guild == null) { - val response = api.createGlobalApplicationCommands { - toCreate.forEach { - val translatedName = it.getTranslatedName(locale) - - logger.debug { "Adding/updating global slash command $translatedName" } - - command( - translatedName, - translationsProvider.translate(it.description, it.extension.bundle, locale = locale) - ) { register(it) } - } - }.toList().associate { it.name to it.id } - - toCreate.forEach { - commandMap[response[it.getTranslatedName(locale)]!!] = it - } - - api.getGlobalApplicationCommands().filter { e -> toRemove.any { it.second == e.id } } - .toList() - .forEach { - logger.debug { "Removing global slash command ${it.name}" } - it.delete() - } - } else { - toCreate.groupBy { it.guild!! }.forEach { (snowflake, commands) -> - val response = api.createGuildApplicationCommands(snowflake) { - commands.forEach { - val translatedName = it.getTranslatedName(locale) - - logger.debug { "Adding/updating global slash command $translatedName" } - - command( - translatedName, - translationsProvider.translate(it.description, it.extension.bundle) - ) { register(it) } - } - }.toList().associate { it.name to it.id } - - commands.forEach { - commandMap[response[it.getTranslatedName(locale)]!!] = it - } - } - - api.getGuildApplicationCommands(guild).filter { e -> toRemove.any { it.second == e.id } } - .toList() - .forEach { - logger.debug { "Removing guild slash command ${it.name}" } - it.delete() - } - } - - val commandsWithPerms = commandMap.filterValues { !it.allowByDefault }.toList().groupBy { - it.second.guild - } - - commandsWithPerms.forEach { (guild, commands) -> - if (guild != null) { - api.bulkEditApplicationCommandPermissions(api.applicationId, guild) { - commands.forEach { (id, commandObj) -> - command(id) { - commandObj.allowedUsers.map { user(it, true) } - commandObj.disallowedUsers.map { user(it, false) } - - commandObj.allowedRoles.map { role(it, true) } - commandObj.disallowedRoles.map { role(it, false) } - } - } - } - } else { - logger.warn { "Applying permissions to global slash commands is currently not supported." } - } - } - } - - logger.info { - if (guild == null) { - "Finished synchronising global slash commands" - } else { - "Finished synchronising slash commands for guild ${guildObj?.name}" - } - } - } - - internal open suspend fun ApplicationCommandCreateBuilder.register(command: SlashCommand) { - val locale = bot.settings.i18nBuilder.defaultLocale - - this.defaultPermission = command.guild == null || command.allowByDefault - - if (command.hasBody) { - val args = command.arguments?.invoke() - - if (args != null) { - args.args.forEach { arg -> - val converter = arg.converter - - if (converter !is SlashCommandConverter) { - error("Argument ${arg.displayName} does not support slash commands.") - } - - if (this.options == null) this.options = mutableListOf() - - // TODO: It's impossible to translate these right now - this.options!! += converter.toSlashOption(arg) - } - } - } else { - command.subCommands.forEach { - val args = it.arguments?.invoke()?.args?.map { arg -> - val converter = arg.converter - - if (converter !is SlashCommandConverter) { - error("Argument ${arg.displayName} does not support slash commands.") - } - - // TODO: It's impossible to translate these right now - converter.toSlashOption(arg) - } - - this.subCommand( - it.name, - translationsProvider.translate(it.description, command.extension.bundle, locale = locale) - ) { - if (args != null) { - if (this.options == null) this.options = mutableListOf() - - this.options!!.addAll(args) - } - } - } - - command.groups.values.forEach { group -> - this.group(group.name, group.description) { - group.subCommands.forEach { - val args = it.arguments?.invoke()?.args?.map { arg -> - val converter = arg.converter - - if (converter !is SlashCommandConverter) { - error("Argument ${arg.displayName} does not support slash commands.") - } - - // TODO: It's impossible to translate these right now - converter.toSlashOption(arg) - } - - this.subCommand( - it.name, - translationsProvider.translate(it.description, command.extension.bundle, locale = locale) - ) { - if (args != null) { - if (this.options == null) this.options = mutableListOf() - - this.options!!.addAll(args) - } - } - } - } - } - } - } - - /** Handle an [InteractionCreateEvent] and try to execute the corresponding command. **/ - public open suspend fun handle(event: InteractionCreateEvent) { - val interaction = event.interaction as? CommandInteraction ?: return - - val commandId = interaction.command.rootId - val command = commandMap[commandId] - - if (command == null) { - logger.warn { "Received interaction for unknown slash command: ${commandId.asString}" } - return - } - - if (!command.extension.loaded) { - logger.info { "Ignoring slash command ${command.name} as the extension is unloaded." } - return - } - - command.call(event) - } -} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/SlashGroup.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/SlashGroup.kt deleted file mode 100644 index 41c66e551e..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/slash/SlashGroup.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.kotlindiscord.kord.extensions.commands.slash - -import com.kotlindiscord.kord.extensions.CommandRegistrationException -import com.kotlindiscord.kord.extensions.InvalidCommandException -import com.kotlindiscord.kord.extensions.annotations.ExtensionDSL -import com.kotlindiscord.kord.extensions.commands.parser.Arguments -import mu.KLogger -import mu.KotlinLogging - -private val logger: KLogger = KotlinLogging.logger {} -private const val DISCORD_LIMIT: Int = 10 - -/** - * Object representing a set of grouped slash commands. - * - * @param name Name of this command group, shown on Discord. - * @param parent Root/top-level command that owns this group. - */ -@ExtensionDSL -public open class SlashGroup( - public val name: String, - public val parent: SlashCommand -) { - /** List of subcommands belonging to this group. **/ - public val subCommands: MutableList> = mutableListOf() - - /** Command group description, which is required and shown on Discord. **/ - public lateinit var description: String - - /** - * Validate this command group, ensuring it has everything it needs. - * - * Throws if not. - */ - public open fun validate() { - if (!::description.isInitialized) { - throw InvalidCommandException(name, "No group description given.") - } - - if (subCommands.isEmpty()) { - error("Command groups must contain at least one subcommand.") - } - } - - /** - * DSL function for easily registering a grouped subcommand, with arguments. - * - * Use this in your group function to register a grouped subcommand that may be executed on Discord. - * - * @param arguments Arguments builder (probably a reference to the class constructor). - * @param body Builder lambda used for setting up the subcommand object. - */ - public open suspend fun subCommand( - arguments: (() -> T), - body: suspend SlashCommand.() -> Unit - ): SlashCommand { - val commandObj = SlashCommand(parent.extension, arguments, parent, this) - body.invoke(commandObj) - - return subCommand(commandObj) - } - - /** - * DSL function for easily registering a grouped subcommand, without arguments. - * - * Use this in your group function to register a grouped subcommand that may be executed on Discord. - * - * @param body Builder lambda used for setting up the subcommand object. - */ - public open suspend fun subCommand( - body: suspend SlashCommand.() -> Unit - ): SlashCommand { - val commandObj = SlashCommand(parent.extension, null, parent, this) - body.invoke(commandObj) - - return subCommand(commandObj) - } - - /** - * Function for registering a grouped custom slash command object, for subcommands. - * - * You can use this if you have a custom slash command subclass you need to register. - * - * @param commandObj SlashCommand object to register as a grouped subcommand. - */ - public open suspend fun subCommand( - commandObj: SlashCommand - ): SlashCommand { - if (subCommands.size >= DISCORD_LIMIT) { - error("Groups may only contain up to 10 subcommands.") - } - - try { - commandObj.validate() - subCommands.add(commandObj) - } catch (e: CommandRegistrationException) { - logger.error(e) { "Failed to register subcommand - $e" } - } catch (e: InvalidCommandException) { - logger.error(e) { "Failed to register subcommand - $e" } - } - - return commandObj - } -} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/Component.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/Component.kt new file mode 100644 index 0000000000..a234934b18 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/Component.kt @@ -0,0 +1,44 @@ +package com.kotlindiscord.kord.extensions.components + +import com.kotlindiscord.kord.extensions.ExtensibleBot +import com.kotlindiscord.kord.extensions.builders.ExtensibleBotBuilder +import com.kotlindiscord.kord.extensions.commands.application.ApplicationCommandRegistry +import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider +import com.kotlindiscord.kord.extensions.sentry.SentryAdapter +import dev.kord.core.Kord +import dev.kord.rest.builder.component.ActionRowBuilder +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +/** Abstract class representing a basic Discord component. **/ +public abstract class Component : KoinComponent { + /** Component width, how many "slots" in one row it needs to be added to the row. **/ + public open val unitWidth: Int = 1 + + /** Translations provider, for retrieving translations. **/ + public val translationsProvider: TranslationsProvider by inject() + + /** Quick access to the command registry. **/ + public val registry: ApplicationCommandRegistry by inject() + + /** Bot settings object. **/ + public val settings: ExtensibleBotBuilder by inject() + + /** Bot object. **/ + public val bot: ExtensibleBot by inject() + + /** Kord instance, backing the ExtensibleBot. **/ + public val kord: Kord by inject() + + /** Sentry adapter, for easy access to Sentry functions. **/ + public val sentry: SentryAdapter by inject() + + /** Translation bundle, to retrieve translations from. **/ + public open var bundle: String? = null + + /** Validation function, called to ensure the component is valid, throws exceptions if not. **/ + public abstract fun validate() + + /** Called to apply the given component to a Kord [ActionRowBuilder]. **/ + public abstract fun apply(builder: ActionRowBuilder) +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/ComponentContainer.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/ComponentContainer.kt new file mode 100644 index 0000000000..2d8035fce7 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/ComponentContainer.kt @@ -0,0 +1,269 @@ +@file:Suppress("AnnotationSpacing") +// Genuinely hate having to deal with this one sometimes. +@file:OptIn(ExperimentalTime::class) + +package com.kotlindiscord.kord.extensions.components + +import com.kotlindiscord.kord.extensions.utils.scheduling.Task +import dev.kord.rest.builder.message.create.MessageCreateBuilder +import dev.kord.rest.builder.message.create.actionRow +import dev.kord.rest.builder.message.modify.MessageModifyBuilder +import dev.kord.rest.builder.message.modify.actionRow +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.time.Duration +import kotlin.time.ExperimentalTime + +/** The maximum number of slots you can have in a row. **/ +public const val ROW_SIZE: Int = 5 + +/** + * Class representing a single set of components that can be applied to any message. + * + * A timeout is supported by this class. If a [timeout] is provided, the components stored within this container will + * be unregistered from the [ComponentRegistry]. If this container contains actionable components, the timeout will be + * reset whenever an actionable component is interacted with. + * + * The `startTimeoutNow` parameter defaults to `false`, but will be set to `true` automatically if you're using a + * `components` DSL function provided in the message creation/modification builders. When this is `true`, the timeout + * task will be started immediately - when it's `false`, you'll have to call the [timeoutTask] `start` function + * yourself. + * + * @param timeout Optional timeout duration. + * @param startTimeoutNow Whether to start the timeout immediately. + */ +public open class ComponentContainer( + public val timeout: Duration? = null, + startTimeoutNow: Boolean = false +) : KoinComponent { + internal val registry: ComponentRegistry by inject() + + /** If a [timeout] was provided, the scheduled timeout task will be stored here. **/ + public open val timeoutTask: Task? = if (timeout != null) { + registry.scheduler.schedule(timeout, startNow = startTimeoutNow) { + removeAll() + timeoutCallback?.invoke(this) + } + } else { + null + } + + /** Extra callback to run when this container times out, if any. **/ + public open var timeoutCallback: (suspend (ComponentContainer).() -> Unit)? = null + + /** Components that haven't been sorted into rows by [sort] yet. **/ + public open val unsortedComponents: MutableList = mutableListOf() + + /** Array containing sorted rows of components. **/ + public open val rows: Array> = arrayOf( + // Up to 5 rows of components + + mutableListOf(), + mutableListOf(), + mutableListOf(), + mutableListOf(), + mutableListOf(), + ) + + /** Register an additional callback to be run when this container times out, assuming a timeout is configured. **/ + public open fun onTimeout(callback: suspend (ComponentContainer).() -> Unit) { + timeoutCallback = callback + } + + /** Remove all components, and unregister them from the [ComponentRegistry]. **/ + public open suspend fun removeAll() { + rows.toList().flatten().forEach { component -> + if (component is ComponentWithID) { + registry.unregister(component) + } + } + + rows.forEach { it.clear() } + } + + /** Remove the given component, and unregister it from the [ComponentRegistry]. **/ + public open suspend fun remove(component: Component): Boolean { + if (rows.any { it.remove(component) }) { + if (component is ComponentWithID) { + registry.unregister(component) + } + + return true + } + + return false + } + + /** Given two components, replace the old component with the new one and likewise handle registration. **/ + public open suspend fun replace(old: Component, new: Component): Boolean { + for (row in rows) { + val index = row.indexOf(old) + + if (index == -1) { + continue + } + + @Suppress("UnnecessaryParentheses") // Yeah, but let me be paranoid. Please. + val freeSlots = (ROW_SIZE - row.size) + old.unitWidth + + if (new.unitWidth > freeSlots) { + error( + "The given component takes up ${old.unitWidth} slot/s, but its row will only have " + + "$freeSlots available slots remaining." + ) + } + + row[index] = new + + if (new is ComponentWithID) { + registry.register(new) + } + + return true + } + + return false + } + + /** + * Given an old component ID and new component, replace the old component with the new one and likewise handle + * registration. + */ + public open suspend fun replace(id: String, new: Component): Boolean { + for (row in rows) { + val index = row.indexOfFirst { it is ComponentWithID && it.id == id } + + if (index == -1) { + continue + } + + val old = row[index] + val freeSlots = old.unitWidth + (ROW_SIZE - row.size) + + if (new.unitWidth > freeSlots) { + error( + "The given component takes up ${old.unitWidth} slots, but its row will only have " + + "$freeSlots available slots remaining." + ) + } + + row[index] = new + + if (new is ComponentWithID) { + registry.register(new) + } + + return true + } + + return false + } + + /** + * Add a component. New components will be unsorted, or placed in the numbered row denoted by [rowNum] if + * possible. + */ + public open suspend fun add(component: Component, rowNum: Int? = null) { + component.validate() + + if (rowNum == null) { + unsortedComponents.add(component) + + return + } + + if (rowNum < 0 || rowNum >= rows.size) { + error("The given row number ($rowNum) must be between 0 to ${rows.size - 1}, inclusive.") + } + + val row = rows[rowNum] + + if (row.size >= ROW_SIZE) { + error( + "Row $rowNum is full, no more components can be added to it." + ) + } + + if (row.size + component.unitWidth > ROW_SIZE) { + error( + "The given component takes up ${component.unitWidth} slots, but row $rowNum only has " + + "${ROW_SIZE - row.size} available slots remaining." + ) + } + + row.add(component) + + if (component is ComponentWithID) { + registry.register(component) + } + } + + /** Sort all components in [unsortedComponents] by packing them into rows as tightly as possible. **/ + public open suspend fun sort() { + while (unsortedComponents.isNotEmpty()) { + val component = unsortedComponents.removeFirst() + var sorted = false + + @Suppress("UnconditionalJumpStatementInLoop") // Yes, but this is nicer to read + for (row in rows) { + if (row.size >= ROW_SIZE || row.size + component.unitWidth > ROW_SIZE) { + continue + } + + row.add(component) + sorted = true + + break + } + + if (!sorted) { + error( + "Failed to sort components: Couldn't find a row with ${component.unitWidth} empty slots to fit" + + "$component." + ) + } + + if (component is ComponentWithID) { + registry.register(component) + } + } + } + + /** Apply the components in this container to a message that's being created. **/ + public open suspend fun MessageCreateBuilder.applyToMessage() { + sort() + + for (row in rows.filter { it.isNotEmpty() }) { + actionRow { + row.forEach { it.apply(this) } + } + } + } + + /** Apply the components in this container to a message that's being edited. **/ + public open suspend fun MessageModifyBuilder.applyToMessage() { + this.components = mutableListOf() // Clear 'em + + sort() + + for (row in rows.filter { it.isNotEmpty() }) { + actionRow { + row.forEach { it.apply(this) } + } + } + } +} + +/** DSL-style factory function to make component containers these by hand easier. **/ +@Suppress("FunctionNaming") // It's a factory function, detekt... +public suspend fun ComponentContainer( + timeout: Duration? = null, + startTimeoutNow: Boolean = false, + builder: suspend ComponentContainer.() -> Unit +): ComponentContainer { + val container = ComponentContainer(timeout, startTimeoutNow) + + builder(container) + + return container +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/ComponentContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/ComponentContext.kt new file mode 100644 index 0000000000..fbbde1e2b7 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/ComponentContext.kt @@ -0,0 +1,145 @@ +@file:OptIn(KordUnsafe::class, KordExperimental::class) + +package com.kotlindiscord.kord.extensions.components + +import com.kotlindiscord.kord.extensions.builders.ExtensibleBotBuilder +import com.kotlindiscord.kord.extensions.checks.channelFor +import com.kotlindiscord.kord.extensions.checks.guildFor +import com.kotlindiscord.kord.extensions.checks.userFor +import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider +import com.kotlindiscord.kord.extensions.sentry.SentryContext +import dev.kord.common.annotation.KordExperimental +import dev.kord.common.annotation.KordUnsafe +import dev.kord.core.behavior.GuildBehavior +import dev.kord.core.behavior.MemberBehavior +import dev.kord.core.behavior.UserBehavior +import dev.kord.core.behavior.channel.MessageChannelBehavior +import dev.kord.core.entity.Member +import dev.kord.core.entity.Message +import dev.kord.core.event.interaction.ComponentInteractionCreateEvent +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.util.* + +/** + * Abstract class representing the execution context for a generic components. + * + * @param E Event type the component makes use of + * @param component Component object that's being interacted with + * @param event Event that triggered this execution context + */ +public abstract class ComponentContext( + public open val component: Component, + public open val event: E +) : KoinComponent { + /** Translations provider, for retrieving translations. **/ + public val translationsProvider: TranslationsProvider by inject() + + /** Configured bot settings object. **/ + public val settings: ExtensibleBotBuilder by inject() + + /** Current Sentry context, containing breadcrumbs and other goodies. **/ + public val sentry: SentryContext = SentryContext() + + /** Cached locale variable, stored and retrieved by [getLocale]. **/ + public open var resolvedLocale: Locale? = null + + /** Channel this component was interacted with within. **/ + public open lateinit var channel: MessageChannelBehavior + + /** Guild this component was interacted with within, if any. **/ + public open var guild: GuildBehavior? = null + + /** Member that interacted with this component, if on a guild. **/ + public open var member: MemberBehavior? = null + + /** Member that interacted with this component, if on a guild. **/ + public open var message: Message? = null + + /** User that interacted with this component. **/ + public open lateinit var user: UserBehavior + + /** Called before processing, used to populate any extra variables from event data. **/ + public open suspend fun populate() { + channel = getChannel() + guild = getGuild() + member = getMember() + message = getMessage() + user = getUser() + } + + /** Extract channel information from event data, if that context is available. **/ + @JvmName("getChannel1") + public fun getChannel(): MessageChannelBehavior = + event.interaction.channel + + /** Extract guild information from event data, if that context is available. **/ + @JvmName("getGuild1") + public fun getGuild(): GuildBehavior? = + event.interaction.data.guildId.value?.let { event.kord.unsafe.guild(it) } + + /** Extract member information from event data, if that context is available. **/ + @JvmName("getMember1") + public suspend fun getMember(): MemberBehavior? = + getGuild()?.let { Member(event.interaction.data.member.value!!, event.interaction.user.data, event.kord) } + + /** Extract message information from event data, if that context is available. **/ + @JvmName("getMessage1") + public fun getMessage(): Message? = + event.interaction.message + + /** Extract user information from event data, if that context is available. **/ + @JvmName("getUser1") + public fun getUser(): UserBehavior = + event.interaction.user + + /** Resolve the locale for this command context. **/ + public suspend fun getLocale(): Locale { + var locale: Locale? = resolvedLocale + + if (locale != null) { + return locale + } + + val guild = guildFor(event) + val channel = channelFor(event) + val user = userFor(event) + + for (resolver in settings.i18nBuilder.localeResolvers) { + val result = resolver(guild, channel, user) + + if (result != null) { + locale = result + break + } + } + + resolvedLocale = locale ?: settings.i18nBuilder.defaultLocale + + return resolvedLocale!! + } + + /** + * Given a translation key and bundle name, return the translation for the locale provided by the bot's configured + * locale resolvers. + */ + public suspend fun translate( + key: String, + bundleName: String?, + replacements: Array = arrayOf() + ): String { + val locale = getLocale() + + return translationsProvider.translate(key, locale, bundleName, replacements) + } + + /** + * Given a translation key and possible replacements,return the translation for the given locale in the + * component's configured bundle, for the locale provided by the bot's configured locale resolvers. + */ + public suspend fun translate(key: String, replacements: Array = arrayOf()): String = translate( + key, + component.bundle, + replacements + ) +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/ComponentRegistry.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/ComponentRegistry.kt new file mode 100644 index 0000000000..58fc787325 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/ComponentRegistry.kt @@ -0,0 +1,71 @@ +package com.kotlindiscord.kord.extensions.components + +import com.kotlindiscord.kord.extensions.components.buttons.InteractionButtonWithAction +import com.kotlindiscord.kord.extensions.components.menus.SelectMenu +import com.kotlindiscord.kord.extensions.registry.DefaultLocalRegistryStorage +import com.kotlindiscord.kord.extensions.registry.RegistryStorage +import com.kotlindiscord.kord.extensions.utils.scheduling.Scheduler +import dev.kord.core.event.interaction.ButtonInteractionCreateEvent +import dev.kord.core.event.interaction.SelectMenuInteractionCreateEvent +import mu.KLogger +import mu.KotlinLogging + +/** + * Component registry, keeps track of components and handles incoming interaction events, dispatching as needed to + * registered component actions. + */ +public open class ComponentRegistry { + internal val logger: KLogger = KotlinLogging.logger {} + + /** Scheduler that can be used to schedule timeout tasks. All [ComponentContainer] objects use this scheduler. **/ + public open val scheduler: Scheduler = Scheduler() + + /** Map of registered component IDs to their components. **/ + public open val storage: RegistryStorage = DefaultLocalRegistryStorage() + + /** Register a component. Only components with IDs need registering. **/ + public open suspend fun register(component: ComponentWithID) { + logger.trace { "Registering component with ID: ${component.id}" } + + storage.set(component.id, component) + } + + /** Unregister a registered component. **/ + public open suspend fun unregister(component: ComponentWithID): Component? = + unregister(component.id) + + /** Unregister a registered component, by ID. **/ + public open suspend fun unregister(id: String): Component? = + storage.remove(id) + + /** Dispatch a [ButtonInteractionCreateEvent] to its button component object. **/ + public suspend fun handle(event: ButtonInteractionCreateEvent) { + val id = event.interaction.componentId + + when (val c = storage.get(id)) { + is InteractionButtonWithAction<*> -> c.call(event) + + null -> logger.debug { "Button interaction received for unknown component ID: $id" } + + else -> logger.warn { + "Button interaction received for component ($id), but that component is not a button component with " + + "an action" + } + } + } + + /** Dispatch a [SelectMenuInteractionCreateEvent] to its select (dropdown) menu component object. **/ + public suspend fun handle(event: SelectMenuInteractionCreateEvent) { + val id = event.interaction.componentId + + when (val c = storage.get(id)) { + is SelectMenu<*> -> c.call(event) + + null -> logger.debug { "Select Menu interaction received for unknown component ID: $id" } + + else -> logger.warn { + "Select Menu interaction received for component ($id), but that component is not a select menu" + } + } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/ComponentWithAction.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/ComponentWithAction.kt new file mode 100644 index 0000000000..6eb9fe943c --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/ComponentWithAction.kt @@ -0,0 +1,159 @@ +package com.kotlindiscord.kord.extensions.components + +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.checks.types.Check +import com.kotlindiscord.kord.extensions.checks.types.CheckContext +import com.kotlindiscord.kord.extensions.components.callbacks.ComponentCallbackRegistry +import com.kotlindiscord.kord.extensions.types.Lockable +import com.kotlindiscord.kord.extensions.utils.getLocale +import com.kotlindiscord.kord.extensions.utils.permissionsForMember +import com.kotlindiscord.kord.extensions.utils.scheduling.Task +import com.kotlindiscord.kord.extensions.utils.translate +import dev.kord.common.entity.Permission +import dev.kord.core.entity.channel.GuildChannel +import dev.kord.core.event.interaction.ComponentInteractionCreateEvent +import kotlinx.coroutines.sync.Mutex +import mu.KLogger +import mu.KotlinLogging +import org.koin.core.component.inject + +/** + * Abstract class representing a component with both an ID and executable action. + * + * @param E Event type that triggers interaction actions for this component type + * @param C Context type used for this component's execution context + * + * @param timeoutTask Timeout task that will be restarted when [call] is run, if any. This is intended to be used to + * in the timeout mechanism for the [ComponentContainer] that contains this component. + */ +public abstract class ComponentWithAction>( + public open val timeoutTask: Task? +) : ComponentWithID(), Lockable { + private val logger: KLogger = KotlinLogging.logger {} + + /** Quick access to the callback registry. **/ + protected val callbackRegistry: ComponentCallbackRegistry by inject() + + /** Whether to use a deferred ack, which will prevent Discord's "Thinking..." message. **/ + public open var deferredAck: Boolean = true + + /** @suppress **/ + public open val checkList: MutableList> = mutableListOf() + + /** Bot permissions required to be able to run execute this component's action. **/ + public open val requiredPerms: MutableSet = mutableSetOf() + + public override var locking: Boolean = false + + override var mutex: Mutex? = null + + /** Component body, to be called when the component is interacted with. **/ + public lateinit var body: suspend C.() -> Unit + + /** Use a registered callback instead of a provided [action]. Not evaluated until execution happens. **/ + public abstract fun useCallback(id: String) + + /** Call this to supply a component [body], to be called when the component is interacted with. **/ + public fun action(action: suspend C.() -> Unit) { + body = action + } + + /** + * Define a check which must pass for the component's body to be executed. + * + * A component may have multiple checks - all checks must pass for the component's body to be executed. + * Checks will be run in the order that they're defined. + * + * This function can be used DSL-style with a given body, or it can be passed one or more + * predefined functions. See the samples for more information. + * + * @param checks Checks to apply to this command. + */ + public open fun check(vararg checks: Check) { + checks.forEach { checkList.add(it) } + } + + /** + * Overloaded check function to allow for DSL syntax. + * + * @param check Check to apply to this command. + */ + public open fun check(check: Check) { + checkList.add(check) + } + + override fun validate() { + super.validate() + + if (!::body.isInitialized) { + error("No component body given.") + } + + if (locking && mutex == null) { + mutex = Mutex() + } + } + + /** If your bot requires permissions to be able to execute this component's body, add them using this function. **/ + public fun requireBotPermissions(vararg perms: Permission) { + perms.forEach { requiredPerms.add(it) } + } + + /** Runs standard checks that can be handled in a generic way, without worrying about subclass-specific checks. **/ + @Throws(DiscordRelayedException::class) + public open suspend fun runStandardChecks(event: E): Boolean { + val locale = event.getLocale() + + checkList.forEach { check -> + val context = CheckContext(event, locale) + + check(context) + + if (!context.passed) { + context.throwIfFailedWithMessage() + + return false + } + } + + return true + } + + /** Override this in order to implement any subclass-specific checks. **/ + @Throws(DiscordRelayedException::class) + public open suspend fun runChecks(event: E): Boolean = + runStandardChecks(event) + + /** Checks whether the bot has the specified required permissions, throwing if it doesn't. **/ + @Throws(DiscordRelayedException::class) + public open suspend fun checkBotPerms(context: C) { + if (requiredPerms.isEmpty()) { + return // Nothing to check, don't try to hit the cache + } + + if (context.guild != null) { + val perms = (context.channel.asChannel() as GuildChannel) + .permissionsForMember(kord.selfId) + + val missingPerms = requiredPerms.filter { !perms.contains(it) } + + if (missingPerms.isNotEmpty()) { + throw DiscordRelayedException( + context.translate( + "commands.error.missingBotPermissions", + null, + + replacements = arrayOf( + missingPerms.map { it.translate(context.getLocale()) }.joinToString(", ") + ) + ) + ) + } + } + } + + /** Override this to implement your component's calling logic. Check subtypes for examples! **/ + public open suspend fun call(event: E) { + timeoutTask?.restart() + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/ComponentWithID.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/ComponentWithID.kt new file mode 100644 index 0000000000..9c8197dd3f --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/ComponentWithID.kt @@ -0,0 +1,15 @@ +package com.kotlindiscord.kord.extensions.components + +import java.util.* + +/** Abstract class representing a component with an ID, which defaults to a newly-generated UUID. **/ +public abstract class ComponentWithID : Component() { + /** Component's ID, a UUID by default. **/ + public open var id: String = UUID.randomUUID().toString() + + public override fun validate() { + if (id.isEmpty()) { + error("All components must have a unique ID.") + } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/Components.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/Components.kt deleted file mode 100644 index 1f4409d9c3..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/Components.kt +++ /dev/null @@ -1,296 +0,0 @@ -@file:OptIn(KordPreview::class) - -package com.kotlindiscord.kord.extensions.components - -import com.kotlindiscord.kord.extensions.commands.slash.AutoAckType -import com.kotlindiscord.kord.extensions.commands.slash.SlashCommandContext -import com.kotlindiscord.kord.extensions.components.builders.* -import com.kotlindiscord.kord.extensions.events.EventHandler -import com.kotlindiscord.kord.extensions.extensions.Extension -import dev.kord.common.annotation.KordPreview -import dev.kord.core.Kord -import dev.kord.core.entity.interaction.ComponentInteraction -import dev.kord.core.event.interaction.InteractionCreateEvent -import dev.kord.rest.builder.message.create.MessageCreateBuilder -import dev.kord.rest.builder.message.create.actionRow -import dev.kord.rest.builder.message.modify.MessageModifyBuilder -import dev.kord.rest.builder.message.modify.actionRow -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import mu.KotlinLogging -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -private const val COMPONENTS_PER_ROW = 5 - -/** - * Class in charge of keeping track of sets of components, organising them into rows and handling their actions. - * - * Row specification is optional - by default, this class will try to sort components into rows automatically, filling - * in any available spots from top to bottom. If you don't want this, you can specify the row number and the component - * will be placed at the end of the row. - * - * When [parentContext] is provided, actionable component behaviour will match the slash command's current ack type. - * - * You most likely don't want to instantiate this class yourself - check the `components` DSL function available in - * all message creation contexts instead. - * - * @param extension Extension object parenting this `Components` instance. - * @param parentContext Optionally, parent slash command context. - */ -public open class Components( - public open val extension: Extension, - public open val parentContext: SlashCommandContext<*>? = null -) : KoinComponent { - private val logger = KotlinLogging.logger {} - - /** Current Kord instance. **/ - public val kord: Kord by inject() - - /** Current event handler instance waiting for interaction creation events. **/ - public var eventHandler: EventHandler? = null - - /** @suppress Internal Job object representing the timeout job. **/ - public var delayJob: Job? = null - - /** List of components that have yet to be sorted into rows. **/ - public open val unsortedComponents: MutableList = mutableListOf() - - /** Mapping of UUID to actionable component builder, used for handling interactions. **/ - public open val actionableComponents: MutableMap> = mutableMapOf() - - /** Predefined row structure, a 5x5 2D array of nulls. Filled in with component builders later. **/ - public open val rows: Array> = arrayOf( - // Up to 5 rows of components - - mutableListOf(), - mutableListOf(), - mutableListOf(), - mutableListOf(), - mutableListOf(), - ) - - /** List of registered timeout callbacks. **/ - public open val timeoutCallbacks: MutableList Unit> = mutableListOf() - - /** - * Create an interactive button that may be clicked on. - * - * @see InteractiveButtonBuilder - */ - public open suspend fun interactiveButton( - row: Int? = null, - builder: suspend InteractiveButtonBuilder.() -> Unit - ): InteractiveButtonBuilder { - val buttonBuilder = InteractiveButtonBuilder() - - if (parentContext == null) { - buttonBuilder.autoAck = AutoAckType.PUBLIC - } - - builder.invoke(buttonBuilder) - addComponent(buttonBuilder, row) - - actionableComponents[buttonBuilder.id] = buttonBuilder - - return buttonBuilder - } - - /** - * Create a link button that directs users to a URL when clicked. - * - * @see LinkButtonBuilder - */ - public open suspend fun linkButton( - row: Int? = null, - builder: suspend LinkButtonBuilder.() -> Unit - ): LinkButtonBuilder { - val buttonBuilder = LinkButtonBuilder() - - builder.invoke(buttonBuilder) - addComponent(buttonBuilder, row) - - return buttonBuilder - } - - /** - * Create a disabled interactive button, which does nothing when clicked. - * - * @see DisabledButtonBuilder - */ - public open suspend fun disabledButton( - row: Int? = null, - builder: suspend DisabledButtonBuilder.() -> Unit - ): DisabledButtonBuilder { - val buttonBuilder = DisabledButtonBuilder() - - builder.invoke(buttonBuilder) - addComponent(buttonBuilder, row) - - return buttonBuilder - } - - /** - * Create a dropdown menu, allowing users to select one or more values. - * - * @see MenuBuilder - */ - public open suspend fun menu( - row: Int? = null, - builder: suspend MenuBuilder.() -> Unit - ): MenuBuilder { - val menuBuilder = MenuBuilder() - - builder.invoke(menuBuilder) - addComponent(menuBuilder, row) - - actionableComponents[menuBuilder.id] = menuBuilder - - return menuBuilder - } - - /** Register a callback to run when a setup timeout expires. **/ - public open fun onTimeout(body: suspend () -> Unit): Boolean = - timeoutCallbacks.add(body) - - /** @suppress Internal API function that runs the timeout callbacks. **/ - @Suppress("TooGenericExceptionCaught") - public open suspend fun runTimeoutCallbacks() { - timeoutCallbacks.forEach { - try { - it() - } catch (t: Throwable) { - logger.error(t) { "Error during timeout callback: $it" } - } - } - } - - /** - * @suppress Internal API function for validating the given row number and storing the component builder. - */ - public open fun addComponent(builder: ComponentBuilder, row: Int? = null) { - builder.validate() - - if (row == null) { - unsortedComponents.add(builder) - } else { - if (row < 0 || row >= rows.size) { - error("The given row number ($row) is invalid - it must be between 0 - ${rows.size - 1}, inclusive.") - } - - val components = rows[row] - - if (components.size >= COMPONENTS_PER_ROW) { - error( - "Row $row is full - up to $COMPONENTS_PER_ROW components are allowed per row, or 1 " + - "row-exclusive component." - ) - } - - if (components.isNotEmpty() && components.any { it.rowExclusive }) { - error("Row $row contains a row-exclusive component, and can't contain any other components.") - } - - components.add(builder) - } - } - - /** - * @suppress Internal API function that tries to sort components into rows as tightly as possible. - */ - public open fun sortIntoRows() { - while (unsortedComponents.isNotEmpty()) { - val component = unsortedComponents.removeFirst() - - val row = if (component.rowExclusive) { - rows.firstOrNull { it.isEmpty() } ?: error( - "Failed to sort components into rows - Component $component is row-exclusive, but there are no " + - "empty rows left." - ) - } else { - rows.firstOrNull { it.size < COMPONENTS_PER_ROW && !it.any { e -> e.rowExclusive } } ?: error( - "Failed to sort components into rows - all possible rows are full." - ) - } - - row.add(component) - } - } - - /** - * @suppress Internal API function that creates an event handler to listen for component interactions, with a - * timeout. - */ - @Suppress("MagicNumber") // Turning seconds into millis, again - public open suspend fun startListening(timeoutSeconds: Long? = null) { - val timeoutMillis = timeoutSeconds?.let { it * 1000 } - - eventHandler = extension.event { - booleanCheck { - val interaction = it.interaction as? ComponentInteraction - - interaction != null && interaction.componentId in actionableComponents - } - - action { - val interaction = event.interaction as ComponentInteraction - val component = actionableComponents[interaction.componentId]!! - - component.call(this@Components, extension, event, parentContext) - } - } - - if (timeoutMillis != null) { - delayJob = kord.launch { - delay(timeoutMillis) - - eventHandler?.job?.cancel() - eventHandler = null - - runTimeoutCallbacks() - stop() - } - } - } - - /** - * Stop listening for interaction events. Actionable components will no longer function. - */ - public open fun stop() { - eventHandler?.job?.cancel() - delayJob?.cancel() - } - - /** - * @suppress Internal API function that sets up all of the components, adds them to the message, and listens for - * interactions. - */ - public open suspend fun MessageCreateBuilder.setup(timeoutSeconds: Long? = null) { - sortIntoRows() - - for (row in rows.filter { row -> row.isNotEmpty() }) { - actionRow { - row.forEach { it.apply(this) } - } - } - - startListening(timeoutSeconds) - } - - /** - * @suppress Internal API function that sets up all of the components, adds them to the message, and listens for - * interactions. - */ - public open suspend fun MessageModifyBuilder.setup(timeoutSeconds: Long? = null) { - sortIntoRows() - - for (row in rows.filter { row -> row.isNotEmpty() }) { - actionRow { - row.forEach { it.apply(this) } - } - } - - startListening(timeoutSeconds) - } -} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/_Functions.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/_Functions.kt new file mode 100644 index 0000000000..363ce6ffc2 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/_Functions.kt @@ -0,0 +1,138 @@ +@file:OptIn(ExperimentalTime::class) + +package com.kotlindiscord.kord.extensions.components + +import com.kotlindiscord.kord.extensions.components.buttons.DisabledInteractionButton +import com.kotlindiscord.kord.extensions.components.buttons.EphemeralInteractionButton +import com.kotlindiscord.kord.extensions.components.buttons.LinkInteractionButton +import com.kotlindiscord.kord.extensions.components.buttons.PublicInteractionButton +import com.kotlindiscord.kord.extensions.components.menus.EphemeralSelectMenu +import com.kotlindiscord.kord.extensions.components.menus.PublicSelectMenu +import dev.kord.rest.builder.message.create.MessageCreateBuilder +import dev.kord.rest.builder.message.modify.MessageModifyBuilder +import kotlin.time.Duration +import kotlin.time.ExperimentalTime + +/** DSL function for creating a disabled button and adding it to the current [ComponentContainer]. **/ +public suspend fun ComponentContainer.disabledButton( + row: Int? = null, + builder: suspend DisabledInteractionButton.() -> Unit +): DisabledInteractionButton { + val component = DisabledInteractionButton() + + builder(component) + add(component, row) + + return component +} + +/** DSL function for creating an ephemeral button and adding it to the current [ComponentContainer]. **/ +public suspend fun ComponentContainer.ephemeralButton( + row: Int? = null, + builder: suspend EphemeralInteractionButton.() -> Unit +): EphemeralInteractionButton { + val component = EphemeralInteractionButton(timeoutTask) + + builder(component) + add(component, row) + + return component +} + +/** DSL function for creating a link button and adding it to the current [ComponentContainer]. **/ +public suspend fun ComponentContainer.linkButton( + row: Int? = null, + builder: suspend LinkInteractionButton.() -> Unit +): LinkInteractionButton { + val component = LinkInteractionButton() + + builder(component) + add(component, row) + + return component +} + +/** DSL function for creating a public button and adding it to the current [ComponentContainer]. **/ +public suspend fun ComponentContainer.publicButton( + row: Int? = null, + builder: suspend PublicInteractionButton.() -> Unit +): PublicInteractionButton { + val component = PublicInteractionButton(timeoutTask) + + builder(component) + add(component, row) + + return component +} + +/** DSL function for creating an ephemeral select menu and adding it to the current [ComponentContainer]. **/ +public suspend fun ComponentContainer.ephemeralSelectMenu( + row: Int? = null, + builder: suspend EphemeralSelectMenu.() -> Unit +): EphemeralSelectMenu { + val component = EphemeralSelectMenu(timeoutTask) + + builder(component) + add(component, row) + + return component +} + +/** DSL function for creating a public select menu and adding it to the current [ComponentContainer]. **/ +public suspend fun ComponentContainer.publicSelectMenu( + row: Int? = null, + builder: suspend PublicSelectMenu.() -> Unit +): PublicSelectMenu { + val component = PublicSelectMenu(timeoutTask) + + builder(component) + add(component, row) + + return component +} + +/** Convenience function for applying the components in a [ComponentContainer] to a message you're creating. **/ +public suspend fun MessageCreateBuilder.applyComponents(components: ComponentContainer) { + with(components) { + applyToMessage() + } +} + +/** Convenience function for applying the components in a [ComponentContainer] to a message you're editing. **/ +public suspend fun MessageModifyBuilder.applyComponents(components: ComponentContainer) { + with(components) { + applyToMessage() + } +} + +/** + * Convenience function for creating a [ComponentContainer] and components, and applying it to a message you're + * creating. Supply a [timeout] and the components you add will be removed from the registry after the given period + * of inactivity. + */ +public suspend fun MessageCreateBuilder.components( + timeout: Duration? = null, + builder: suspend ComponentContainer.() -> Unit +): ComponentContainer { + val container = ComponentContainer(timeout, true, builder) + + applyComponents(container) + + return container +} + +/** + * Convenience function for creating a [ComponentContainer] and components, and applying it to a message you're + * editing. Supply a [timeout] and the components you add will be removed from the registry after the given period + * of inactivity. + */ +public suspend fun MessageModifyBuilder.components( + timeout: Duration? = null, + builder: suspend ComponentContainer.() -> Unit +): ComponentContainer { + val container = ComponentContainer(timeout, true, builder) + + applyComponents(container) + + return container +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/ActionableComponentBuilder.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/ActionableComponentBuilder.kt deleted file mode 100644 index 35dc941d1a..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/ActionableComponentBuilder.kt +++ /dev/null @@ -1,288 +0,0 @@ -@file:OptIn(KordPreview::class) - -package com.kotlindiscord.kord.extensions.components.builders - -import com.kotlindiscord.kord.extensions.CommandException -import com.kotlindiscord.kord.extensions.checks.* -import com.kotlindiscord.kord.extensions.checks.types.Check -import com.kotlindiscord.kord.extensions.checks.types.CheckContext -import com.kotlindiscord.kord.extensions.commands.slash.AutoAckType -import com.kotlindiscord.kord.extensions.commands.slash.SlashCommandContext -import com.kotlindiscord.kord.extensions.components.Components -import com.kotlindiscord.kord.extensions.components.contexts.ActionableComponentContext -import com.kotlindiscord.kord.extensions.extensions.Extension -import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider -import com.kotlindiscord.kord.extensions.sentry.tag -import com.kotlindiscord.kord.extensions.sentry.user -import com.kotlindiscord.kord.extensions.utils.ackEphemeral -import com.kotlindiscord.kord.extensions.utils.ackPublic -import com.kotlindiscord.kord.extensions.utils.getLocale -import dev.kord.common.annotation.KordPreview -import dev.kord.common.entity.ButtonStyle -import dev.kord.core.behavior.interaction.InteractionResponseBehavior -import dev.kord.core.behavior.interaction.respondEphemeral -import dev.kord.core.entity.channel.DmChannel -import dev.kord.core.entity.channel.GuildMessageChannel -import dev.kord.core.entity.interaction.ComponentInteraction -import dev.kord.core.event.interaction.InteractionCreateEvent -import io.sentry.Sentry -import io.sentry.protocol.SentryId -import mu.KotlinLogging -import org.koin.core.component.inject -import java.io.Serializable -import java.util.* - -/** - * Button builder representing an interactive button, with click action. - * - * Either a [label] or [emoji] must be provided. [style] must not be [ButtonStyle.Link]. - */ -public abstract class ActionableComponentBuilder> : - ComponentBuilder() { - private val logger = KotlinLogging.logger {} - private val translations: TranslationsProvider by inject() - - /** Unique ID for this button. Required by Discord. **/ - public open var id: String = UUID.randomUUID().toString() - - /** - * Automatic ack type, if not following a parent context. - */ - public open var autoAck: AutoAckType? = AutoAckType.EPHEMERAL - - /** - * Automatic ack type, if not following a parent context. - */ - @Deprecated( - "This property was renamed to autoAck for consistency.", - ReplaceWith("autoAck"), - DeprecationLevel.ERROR - ) - public open var ackType: AutoAckType? - get() = autoAck - set(value) { - autoAck = value - } - - /** Whether to send a deferred acknowledgement instead of a normal one. **/ - public open var deferredAck: Boolean = false - - /** Whether to follow the ack type of the parent slash command context, if any. **/ - public open var followParent: Boolean = true - - /** @suppress Internal variable, a list of checks to apply to click actions. **/ - public open val checks: MutableList> = mutableListOf() - - /** @suppress Internal variable, the click action to run. **/ - public open lateinit var body: suspend R.() -> Unit - - public override fun validate() { - if (!this::body.isInitialized) { - error("Actionable components must have an action defined.") - } - } - - /** Register a check that must pass for this button to be actioned. **/ - public open fun check(vararg checks: Check): Boolean = - this.checks.addAll(checks) - - /** Register a check that must pass for this button to be actioned. **/ - public open fun check(check: Check): Boolean = - checks.add(check) - - /** - * Define a simple Boolean check which must pass for the button to be actioned. - * - * Boolean checks are simple wrappers around the regular check system, allowing you to define a basic check that - * takes an event object and returns a [Boolean] representing whether it passed. This style of check does not have - * the same functionality as a regular check, and cannot return a message. - */ - public open fun booleanCheck(vararg checks: suspend (InteractionCreateEvent) -> Boolean) { - checks.forEach(::booleanCheck) - } - - /** - * Overloaded simple Boolean check function to allow for DSL syntax. - * - * Boolean checks are simple wrappers around the regular check system, allowing you to define a basic check that - * takes an event object and returns a [Boolean] representing whether it passed. This style of check does not have - * the same functionality as a regular check, and cannot return a message. - */ - public open fun booleanCheck(check: suspend (InteractionCreateEvent) -> Boolean) { - check { - if (check(event)) { - pass() - } else { - fail() - } - } - } - - /** Register the click action that should be run when this button is clicked, assuming the checks pass. **/ - public open fun action(action: suspend R.() -> Unit) { - this.body = action - } - - /** Run this component's checks, returning a Boolean representing whether the checks passed. **/ - public open suspend fun runChecks(event: InteractionCreateEvent, sendMessage: Boolean = true): Boolean { - val interaction = event.interaction as T - val locale = event.getLocale() - - for (check in checks) { - val context = CheckContext(event, locale) - - check(context) - - if (!context.passed) { - val message = context.message - - if (message != null && sendMessage) { - interaction.respondEphemeral { - content = translations.translate( - "checks.responseTemplate", - replacements = arrayOf(message) - ) - } - } else { - interaction.acknowledgeEphemeralDeferredMessageUpdate() - } - - return false - } - } - - return true - } - - override suspend fun call( - components: Components, - extension: Extension, - event: InteractionCreateEvent, - parentContext: SlashCommandContext<*>? - ) { - if (!runChecks(event)) { - return - } - - val firstBreadcrumb = if (sentry.enabled) { - val channel = channelFor(event) - val guild = guildFor(event)?.asGuildOrNull() - - val data = mutableMapOf() - - if (channel != null) { - data["channel"] = when (channel) { - is DmChannel -> "Private Message (${channel.id.asString})" - is GuildMessageChannel -> "#${channel.name} (${channel.id.asString})" - - else -> channel.id.asString - } - } - - if (guild != null) { - data["guild"] = "${guild.name} (${guild.id.asString})" - } - - sentry.createBreadcrumb( - category = "interaction", - type = "user", - message = "Interaction for component \"$id\" received.", - data = data - ) - } else { - null - } - - val interaction = event.interaction as T - - val response = if (parentContext != null && followParent) { - when (parentContext.isEphemeral) { - true -> interaction.ackEphemeral(deferredAck) - false -> interaction.ackPublic(deferredAck) - - else -> null - } - } else { - when (autoAck) { - AutoAckType.EPHEMERAL -> interaction.ackEphemeral(deferredAck) - AutoAckType.PUBLIC -> interaction.ackPublic(deferredAck) - - else -> null - } - } - - val context = getContext( - extension, event, components, response, interaction - ) - - context.populate() - - @Suppress("TooGenericExceptionCaught") - try { - body(context) - } catch (e: CommandException) { - context.respond(e.toString()) - } catch (t: Throwable) { - if (sentry.enabled) { - logger.debug { "Submitting error to sentry." } - - lateinit var sentryId: SentryId - - val user = userFor(event)?.asUserOrNull() - val channel = channelFor(event)?.asChannelOrNull() - - Sentry.withScope { - if (user != null) { - it.user(user) - } - - it.tag("private", "false") - - if (channel is DmChannel) { - it.tag("private", "true") - } - - it.tag("extension", extension.name) - it.addBreadcrumb(firstBreadcrumb!!) - - context.breadcrumbs.forEach(it::addBreadcrumb) - - sentryId = Sentry.captureException(t, "Component interaction execution failed.") - - logger.debug { "Error submitted to Sentry: $sentryId" } - } - - sentry.addEventId(sentryId) - - logger.error(t) { "Error thrown during component interaction" } - - if (extension.bot.extensions.containsKey("sentry")) { - context.respond( - context.translate( - "commands.error.user.sentry.slash", - null, - replacements = arrayOf(sentryId) - ) - ) - } else { - context.respond( - context.translate("commands.error.user") - ) - } - } else { - logger.error(t) { "Error thrown during component interaction" } - - context.respond(context.translate("commands.error.user", null)) - } - } - } - - /** Function to be overridden in order to retrieve a context object of the correct type. **/ - public abstract fun getContext( - extension: Extension, - event: InteractionCreateEvent, - components: Components, - interactionResponse: InteractionResponseBehavior? = null, - interaction: T = event.interaction as T - ): R -} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/ButtonBuilder.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/ButtonBuilder.kt deleted file mode 100644 index 92b2eb6234..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/ButtonBuilder.kt +++ /dev/null @@ -1,69 +0,0 @@ -@file:OptIn(KordPreview::class) - -package com.kotlindiscord.kord.extensions.components.builders - -import dev.kord.common.annotation.KordPreview -import dev.kord.common.entity.DiscordPartialEmoji -import dev.kord.common.entity.optional.optional -import dev.kord.core.entity.GuildEmoji -import dev.kord.core.entity.ReactionEmoji -import org.koin.core.component.KoinComponent - -/** - * Abstract class representing a button builder, providing common functionality and properties. - */ -public interface ButtonBuilder : KoinComponent { - /** - * The button's label text. Optional if you've got an emoji. - * - * Labels default to a zero-width space. This does make them slightly wider than usual if you don't set your - * own label, but it means that iOS users can tap emoji-only buttons without having to specifically tap the - * emoji. - */ - public var label: String? - - /** - * A partial emoji object, either a guild or Unicode emoji. Optional if you've got a label. - * - * @see emoji - */ - public var partialEmoji: DiscordPartialEmoji? - - /** Convenience function for setting [partialEmoji] based on a given Unicode emoji. **/ - public fun emoji(unicodeEmoji: String) { - partialEmoji = DiscordPartialEmoji( - name = unicodeEmoji - ) - } - - /** Convenience function for setting [partialEmoji] based on a given guild custom emoji. **/ - public fun emoji(guildEmoji: GuildEmoji) { - partialEmoji = DiscordPartialEmoji( - id = guildEmoji.id, - name = guildEmoji.name, - animated = guildEmoji.isAnimated.optional() - ) - } - - /** Convenience function for setting [partialEmoji] based on a given reaction emoji. **/ - public fun emoji(unicodeEmoji: ReactionEmoji.Unicode) { - partialEmoji = DiscordPartialEmoji( - name = unicodeEmoji.name - ) - } - - /** Convenience function for setting [partialEmoji] based on a given reaction emoji. **/ - public fun emoji(guildEmoji: ReactionEmoji.Custom) { - partialEmoji = DiscordPartialEmoji( - id = guildEmoji.id, - name = guildEmoji.name, - animated = guildEmoji.isAnimated.optional() - ) - } - - /** Convenience function for setting [partialEmoji] based on a given reaction emoji. **/ - public fun emoji(emoji: ReactionEmoji): Unit = when (emoji) { - is ReactionEmoji.Unicode -> emoji(emoji) - is ReactionEmoji.Custom -> emoji(emoji) - } -} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/ComponentBuilder.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/ComponentBuilder.kt deleted file mode 100644 index 869b252359..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/ComponentBuilder.kt +++ /dev/null @@ -1,51 +0,0 @@ -@file:OptIn(KordPreview::class) - -package com.kotlindiscord.kord.extensions.components.builders - -import com.kotlindiscord.kord.extensions.ExtensibleBot -import com.kotlindiscord.kord.extensions.commands.slash.SlashCommandContext -import com.kotlindiscord.kord.extensions.components.Components -import com.kotlindiscord.kord.extensions.extensions.Extension -import com.kotlindiscord.kord.extensions.sentry.SentryAdapter -import dev.kord.common.annotation.KordPreview -import dev.kord.core.Kord -import dev.kord.core.event.interaction.InteractionCreateEvent -import dev.kord.rest.builder.component.ActionRowBuilder -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -/** - * Abstract class representing a button builder, providing common functionality and properties. - */ -public abstract class ComponentBuilder : KoinComponent { - /** The [ExtensibleBot] instance that this extension is installed to. **/ - public val bot: ExtensibleBot by inject() - - /** Current Kord instance powering the bot. **/ - public val kord: Kord by inject() - - /** Sentry adapter, for easy access to Sentry functions. **/ - public val sentry: SentryAdapter by inject() - - /** Whether this component must be in a row on its own. **/ - public open val rowExclusive: Boolean = false - - /** Function used to add this button to an action row. **/ - public abstract fun apply(builder: ActionRowBuilder) - - /** Function called to validate the button. Should throw exceptions if something is invalid. **/ - public abstract fun validate() - - /** - * For interactive button types, called in order to action the button. Throws [UnsupportedOperationException] - * by default. - */ - public open suspend fun call( - components: Components, - extension: Extension, - event: InteractionCreateEvent, - parentContext: SlashCommandContext<*>? = null - ) { - throw UnsupportedOperationException("This type of component doesn't support callable actions.") - } -} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/DisabledButtonBuilder.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/DisabledButtonBuilder.kt deleted file mode 100644 index 92640ba685..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/DisabledButtonBuilder.kt +++ /dev/null @@ -1,46 +0,0 @@ -@file:OptIn(KordPreview::class) - -package com.kotlindiscord.kord.extensions.components.builders - -import dev.kord.common.annotation.KordPreview -import dev.kord.common.entity.ButtonStyle -import dev.kord.common.entity.DiscordPartialEmoji -import dev.kord.rest.builder.component.ActionRowBuilder -import java.util.* - -/** - * Button builder representing a disabled interactive button. No click action. - * - * Either a [label] or [emoji] must be provided. [style] must not be [ButtonStyle.Link]. - */ -public open class DisabledButtonBuilder : ButtonBuilder, ComponentBuilder() { - /** Unique ID for this button. Required by Discord. **/ - public open var id: String = UUID.randomUUID().toString() - - /** Button style. **/ - public open var style: ButtonStyle = ButtonStyle.Primary - - override var label: String? = null - override var partialEmoji: DiscordPartialEmoji? = null - - public override fun apply(builder: ActionRowBuilder) { - builder.interactionButton(style, id) { - emoji = partialEmoji - - // ZWSP, so iOS users don't have to directly tap an emoji if there's no label - label = this@DisabledButtonBuilder.label ?: "\u200B" - - disabled = true - } - } - - public override fun validate() { - if (label == null && partialEmoji == null) { - error("Disabled buttons must have either a label or emoji.") - } - - if (style == ButtonStyle.Link) { - error("The Link button style is reserved for link buttons.") - } - } -} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/InteractiveButtonBuilder.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/InteractiveButtonBuilder.kt deleted file mode 100644 index cc4a74c1bf..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/InteractiveButtonBuilder.kt +++ /dev/null @@ -1,62 +0,0 @@ -@file:OptIn(KordPreview::class) - -package com.kotlindiscord.kord.extensions.components.builders - -import com.kotlindiscord.kord.extensions.components.Components -import com.kotlindiscord.kord.extensions.components.contexts.InteractiveButtonContext -import com.kotlindiscord.kord.extensions.extensions.Extension -import dev.kord.common.annotation.KordPreview -import dev.kord.common.entity.ButtonStyle -import dev.kord.common.entity.DiscordPartialEmoji -import dev.kord.core.behavior.interaction.InteractionResponseBehavior -import dev.kord.core.entity.interaction.ButtonInteraction -import dev.kord.core.event.interaction.InteractionCreateEvent -import dev.kord.rest.builder.component.ActionRowBuilder -import mu.KotlinLogging - -private val logger = KotlinLogging.logger {} - -/** - * Button builder representing an interactive button, with click action. - * - * Either a [label] or [emoji] must be provided. [style] must not be [ButtonStyle.Link]. - */ -public open class InteractiveButtonBuilder : ButtonBuilder, - ActionableComponentBuilder() { - /** Button style. **/ - public open var style: ButtonStyle = ButtonStyle.Primary - - override var label: String? = null - override var partialEmoji: DiscordPartialEmoji? = null - - public override fun apply(builder: ActionRowBuilder) { - builder.interactionButton(style, id) { - emoji = partialEmoji - - // ZWSP, so iOS users don't have to directly tap an emoji if there's no label - label = this@InteractiveButtonBuilder.label ?: "\u200B" - } - } - - public override fun validate() { - if (label == null && partialEmoji == null) { - error("Interactive buttons must have either a label or emoji.") - } - - if (style == ButtonStyle.Link) { - error("The Link button style is reserved for link buttons.") - } - - super.validate() - } - - override fun getContext( - extension: Extension, - event: InteractionCreateEvent, - components: Components, - interactionResponse: InteractionResponseBehavior?, - interaction: ButtonInteraction - ): InteractiveButtonContext = InteractiveButtonContext( - extension, event, components, interactionResponse, interaction - ) -} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/LinkButtonBuilder.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/LinkButtonBuilder.kt deleted file mode 100644 index dfae319a4c..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/LinkButtonBuilder.kt +++ /dev/null @@ -1,39 +0,0 @@ -@file:OptIn(KordPreview::class) - -package com.kotlindiscord.kord.extensions.components.builders - -import dev.kord.common.annotation.KordPreview -import dev.kord.common.entity.DiscordPartialEmoji -import dev.kord.rest.builder.component.ActionRowBuilder - -/** - * Button builder representing a link button that directs users to a URL. - * - * Either a [label] or [emoji] must be provided. A [url] is also required. - */ -public open class LinkButtonBuilder : ButtonBuilder, ComponentBuilder() { - /** URL to direct users to when clicked. **/ - public open lateinit var url: String - - override var label: String? = null - override var partialEmoji: DiscordPartialEmoji? = null - - public override fun apply(builder: ActionRowBuilder) { - builder.linkButton(url) { - emoji = partialEmoji - - // ZWSP, so iOS users don't have to directly tap an emoji if there's no label - label = this@LinkButtonBuilder.label ?: "\u200B" - } - } - - public override fun validate() { - if (label == null && partialEmoji == null) { - error("Link buttons must have either a label or emoji.") - } - - if (!this::url.isInitialized) { - error("Link buttons must have a URL.") - } - } -} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/MenuBuilder.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/MenuBuilder.kt deleted file mode 100644 index df054c8ccc..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/builders/MenuBuilder.kt +++ /dev/null @@ -1,101 +0,0 @@ -@file:OptIn(KordPreview::class) - -package com.kotlindiscord.kord.extensions.components.builders - -import com.kotlindiscord.kord.extensions.components.Components -import com.kotlindiscord.kord.extensions.components.contexts.MenuContext -import com.kotlindiscord.kord.extensions.extensions.Extension -import dev.kord.common.annotation.KordPreview -import dev.kord.core.behavior.interaction.InteractionResponseBehavior -import dev.kord.core.entity.interaction.SelectMenuInteraction -import dev.kord.core.event.interaction.InteractionCreateEvent -import dev.kord.rest.builder.component.ActionRowBuilder -import dev.kord.rest.builder.component.SelectOptionBuilder - -private const val DESCRIPTION_MAX = 50 -private const val LABEL_MAX = 25 -private const val OPTIONS_MAX = 25 -private const val PLACEHOLDER_MAX = 100 -private const val VALUE_MAX = 100 - -/** - * Builder representing a dropdown menu on Discord. - * - * At least one option must be provided. - */ -public open class MenuBuilder : ActionableComponentBuilder() { - /** List of options for the user to choose from. **/ - public val options: MutableList = mutableListOf() - - /** The minimum number of choices that the user must make. **/ - public var minimumChoices: Int = 1 - - /** The maximum number of choices that the user can make. Set to `null` for no maximum. **/ - public var maximumChoices: Int? = 1 - - /** Placeholder text to show before the user has selected any options.. **/ - public var placeholder: String? = null - - // Menus can only be on their own in a row. - override val rowExclusive: Boolean = true - - /** Add an option to this select menu. **/ - public suspend fun option(label: String, value: String, body: suspend SelectOptionBuilder.() -> Unit = {}) { - val builder = SelectOptionBuilder(label, value) - - body(builder) - - if (builder.description?.length ?: 0 > DESCRIPTION_MAX) { - error("Option descriptions must not be longer than $DESCRIPTION_MAX characters.") - } - - if (builder.label.length > LABEL_MAX) { - error("Option labels must not be longer than $LABEL_MAX characters.") - } - - if (builder.value.length > VALUE_MAX) { - error("Option values must not be longer than $VALUE_MAX characters.") - } - - options.add(builder) - } - - public override fun apply(builder: ActionRowBuilder) { - if (maximumChoices == null || maximumChoices!! > options.size) { - maximumChoices = options.size - } - - builder.selectMenu(id) { - allowedValues = minimumChoices..maximumChoices!! - - this.options.addAll(this@MenuBuilder.options) - this.placeholder = this@MenuBuilder.placeholder - } - } - - override fun validate() { - if (this.options.isEmpty()) { - error("Menu components must have at least one option.") - } - - if (this.options.size > OPTIONS_MAX) { - error("Menu components must not have more than $OPTIONS_MAX options.") - } - - if (this.placeholder?.length ?: 0 > PLACEHOLDER_MAX) { - error("Menu components must not have a placeholder longer than $PLACEHOLDER_MAX characters.") - } - - super.validate() - } - - override fun getContext( - extension: Extension, - event: InteractionCreateEvent, - components: Components, - interactionResponse: InteractionResponseBehavior?, - interaction: SelectMenuInteraction - ): MenuContext = MenuContext( - extension, event, components, interactionResponse, interaction - ) -} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/DisabledInteractionButton.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/DisabledInteractionButton.kt new file mode 100644 index 0000000000..7c30ecdafd --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/DisabledInteractionButton.kt @@ -0,0 +1,27 @@ +package com.kotlindiscord.kord.extensions.components.buttons + +import dev.kord.common.entity.ButtonStyle +import dev.kord.rest.builder.component.ActionRowBuilder + +/** Class representing a disabled button component, which has no action. **/ +public open class DisabledInteractionButton : InteractionButtonWithID() { + /** Button style - anything but Link is valid. **/ + public open var style: ButtonStyle = ButtonStyle.Primary + + override fun apply(builder: ActionRowBuilder) { + builder.interactionButton(style, id) { + emoji = partialEmoji + label = this@DisabledInteractionButton.label + + disabled = true + } + } + + override fun validate() { + super.validate() + + if (style == ButtonStyle.Link) { + error("The Link button style is reserved for link buttons.") + } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/EphemeralInteractionButton.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/EphemeralInteractionButton.kt new file mode 100644 index 0000000000..9a323d583f --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/EphemeralInteractionButton.kt @@ -0,0 +1,120 @@ +@file:Suppress("TooGenericExceptionCaught") + +package com.kotlindiscord.kord.extensions.components.buttons + +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.components.callbacks.EphemeralButtonCallback +import com.kotlindiscord.kord.extensions.types.FailureReason +import com.kotlindiscord.kord.extensions.types.respond +import com.kotlindiscord.kord.extensions.utils.scheduling.Task +import dev.kord.common.entity.ButtonStyle +import dev.kord.core.behavior.interaction.respondEphemeral +import dev.kord.core.event.interaction.ButtonInteractionCreateEvent +import dev.kord.rest.builder.component.ActionRowBuilder +import dev.kord.rest.builder.message.create.InteractionResponseCreateBuilder + +public typealias InitialEphemeralButtonResponseBuilder = + (suspend InteractionResponseCreateBuilder.(ButtonInteractionCreateEvent) -> Unit)? + +/** Class representing an ephemeral-only interaction button. **/ +public open class EphemeralInteractionButton( + timeoutTask: Task? +) : InteractionButtonWithAction(timeoutTask) { + /** Button style - anything but Link is valid. **/ + public open var style: ButtonStyle = ButtonStyle.Primary + + /** @suppress Initial response builder. **/ + public open var initialResponseBuilder: InitialEphemeralButtonResponseBuilder = null + + /** Call this to open with a response, omit it to ack instead. **/ + public fun initialResponse(body: InitialEphemeralButtonResponseBuilder) { + initialResponseBuilder = body + } + + override fun useCallback(id: String) { + action { + val callback: EphemeralButtonCallback = callbackRegistry.getOfTypeOrNull(id) + ?: error("Callback \"$id\" is either missing or is the wrong type.") + + callback.call(this) + } + + check { + val callback: EphemeralButtonCallback = callbackRegistry.getOfTypeOrNull(id) + ?: error("Callback \"$id\" is either missing or is the wrong type.") + + passed = callback.runChecks(event) + } + } + + override fun apply(builder: ActionRowBuilder) { + builder.interactionButton(style, id) { + emoji = partialEmoji + label = this@EphemeralInteractionButton.label + } + } + + override suspend fun call(event: ButtonInteractionCreateEvent): Unit = withLock { + super.call(event) + + try { + if (!runChecks(event)) { + return@withLock + } + } catch (e: DiscordRelayedException) { + event.interaction.respondEphemeral { + settings.failureResponseBuilder(this, e.reason, FailureReason.ProvidedCheckFailure(e)) + } + + return@withLock + } + + val response = if (initialResponseBuilder != null) { + event.interaction.respondEphemeral { initialResponseBuilder!!(event) } + } else { + if (!deferredAck) { + event.interaction.acknowledgeEphemeral() + } else { + event.interaction.acknowledgeEphemeralDeferredMessageUpdate() + } + } + + val context = EphemeralInteractionButtonContext(this, event, response) + + context.populate() + + firstSentryBreadcrumb(context, this) + + try { + checkBotPerms(context) + } catch (e: DiscordRelayedException) { + respondText(context, e.reason, FailureReason.OwnPermissionsCheckFailure(e)) + + return@withLock + } + + try { + body(context) + } catch (e: DiscordRelayedException) { + respondText(context, e.reason, FailureReason.RelayedFailure(e)) + } catch (t: Throwable) { + handleError(context, t, this) + } + } + + override fun validate() { + super.validate() + + if (style == ButtonStyle.Link) { + error("The Link button style is reserved for link buttons.") + } + } + + override suspend fun respondText( + context: EphemeralInteractionButtonContext, + message: String, + failureType: FailureReason<*> + ) { + context.respond { settings.failureResponseBuilder(this, message, failureType) } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/EphemeralInteractionButtonContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/EphemeralInteractionButtonContext.kt new file mode 100644 index 0000000000..74665745ba --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/EphemeralInteractionButtonContext.kt @@ -0,0 +1,12 @@ +package com.kotlindiscord.kord.extensions.components.buttons + +import com.kotlindiscord.kord.extensions.types.EphemeralInteractionContext +import dev.kord.core.behavior.interaction.EphemeralInteractionResponseBehavior +import dev.kord.core.event.interaction.ButtonInteractionCreateEvent + +/** Class representing the execution context for an ephemeral-only button. **/ +public class EphemeralInteractionButtonContext( + override val component: EphemeralInteractionButton, + override val event: ButtonInteractionCreateEvent, + override val interactionResponse: EphemeralInteractionResponseBehavior +) : InteractionButtonContext(component, event), EphemeralInteractionContext diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/InteractionButton.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/InteractionButton.kt new file mode 100644 index 0000000000..2eab4e78b6 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/InteractionButton.kt @@ -0,0 +1,19 @@ +package com.kotlindiscord.kord.extensions.components.buttons + +import com.kotlindiscord.kord.extensions.components.Component +import com.kotlindiscord.kord.extensions.components.types.HasPartialEmoji +import dev.kord.common.entity.DiscordPartialEmoji + +/** Abstract class representing a button component. **/ +public abstract class InteractionButton : Component(), HasPartialEmoji { + /** Button label, for display on Discord. **/ + public var label: String? = null + + public override var partialEmoji: DiscordPartialEmoji? = null + + override fun validate() { + if (label == null && partialEmoji == null) { + error("Buttons must have either a label or emoji.") + } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/InteractionButtonContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/InteractionButtonContext.kt new file mode 100644 index 0000000000..facf324749 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/InteractionButtonContext.kt @@ -0,0 +1,11 @@ +package com.kotlindiscord.kord.extensions.components.buttons + +import com.kotlindiscord.kord.extensions.components.Component +import com.kotlindiscord.kord.extensions.components.ComponentContext +import dev.kord.core.event.interaction.ButtonInteractionCreateEvent + +/** Abstract class representing the execution context for a button component's action. **/ +public abstract class InteractionButtonContext( + component: Component, + event: ButtonInteractionCreateEvent +) : ComponentContext(component, event) diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/InteractionButtonWithAction.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/InteractionButtonWithAction.kt new file mode 100644 index 0000000000..46a62e3634 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/InteractionButtonWithAction.kt @@ -0,0 +1,115 @@ +package com.kotlindiscord.kord.extensions.components.buttons + +import com.kotlindiscord.kord.extensions.components.ComponentWithAction +import com.kotlindiscord.kord.extensions.components.types.HasPartialEmoji +import com.kotlindiscord.kord.extensions.sentry.BreadcrumbType +import com.kotlindiscord.kord.extensions.sentry.tag +import com.kotlindiscord.kord.extensions.sentry.user +import com.kotlindiscord.kord.extensions.types.FailureReason +import com.kotlindiscord.kord.extensions.utils.scheduling.Task +import dev.kord.common.entity.DiscordPartialEmoji +import dev.kord.core.entity.channel.DmChannel +import dev.kord.core.entity.channel.GuildMessageChannel +import dev.kord.core.event.interaction.ButtonInteractionCreateEvent +import io.sentry.Sentry +import mu.KLogger +import mu.KotlinLogging + +/** Abstract class representing a button component that has a click action. **/ +public abstract class InteractionButtonWithAction(timeoutTask: Task?) : + ComponentWithAction(timeoutTask), HasPartialEmoji { + internal val logger: KLogger = KotlinLogging.logger {} + + /** Button label, for display on Discord. **/ + public var label: String? = null + + public override var partialEmoji: DiscordPartialEmoji? = null + + /** If enabled, adds the initial Sentry breadcrumb to the given context. **/ + public open suspend fun firstSentryBreadcrumb(context: C, button: InteractionButtonWithAction<*>) { + if (sentry.enabled) { + context.sentry.breadcrumb(BreadcrumbType.User) { + category = "component.button" + message = "Button \"${button.id}\" clicked." + + val channel = context.channel.asChannelOrNull() + val guild = context.guild?.asGuildOrNull() + val message = context.message + + data["component"] = button.id + + if (channel != null) { + data["channel"] = when (channel) { + is DmChannel -> "Private Message (${channel.id.asString})" + is GuildMessageChannel -> "#${channel.name} (${channel.id.asString})" + + else -> channel.id.asString + } + } + + if (guild != null) { + data["guild"] = "${guild.name} (${guild.id.asString})" + } + + if (message != null) { + data["message"] = message.id.asString + } + } + } + } + + /** A general way to handle errors thrown during the course of a button action's execution. **/ + public open suspend fun handleError(context: C, t: Throwable, button: InteractionButtonWithAction<*>) { + logger.error(t) { "Error during execution of button (${button.id}) action (${context.event})" } + + if (sentry.enabled) { + logger.trace { "Submitting error to sentry." } + + val channel = context.channel + val author = context.user.asUserOrNull() + + val sentryId = context.sentry.captureException(t, "Button action execution failed.") { + if (author != null) { + user(author) + } + + tag("private", "false") + + if (channel is DmChannel) { + tag("private", "true") + } + + tag("component", button.id) + + Sentry.captureException(t, "Slash command execution failed.") + } + + logger.info { "Error submitted to Sentry: $sentryId" } + + val errorMessage = if (bot.extensions.containsKey("sentry")) { + context.translate("commands.error.user.sentry.slash", null, replacements = arrayOf(sentryId)) + } else { + context.translate("commands.error.user", null) + } + + respondText(context, errorMessage, FailureReason.ExecutionError(t)) + } else { + respondText( + context, + context.translate("commands.error.user", null), + FailureReason.ExecutionError(t) + ) + } + } + + override fun validate() { + super.validate() + + if (label == null && partialEmoji == null) { + error("Buttons must have either a label or emoji.") + } + } + + /** Override this to implement a way to respond to the user, regardless of whatever happens. **/ + public abstract suspend fun respondText(context: C, message: String, failureType: FailureReason<*>) +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/InteractionButtonWithID.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/InteractionButtonWithID.kt new file mode 100644 index 0000000000..07ab7726a3 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/InteractionButtonWithID.kt @@ -0,0 +1,20 @@ +package com.kotlindiscord.kord.extensions.components.buttons + +import com.kotlindiscord.kord.extensions.components.ComponentWithID +import com.kotlindiscord.kord.extensions.components.types.HasPartialEmoji +import dev.kord.common.entity.DiscordPartialEmoji + +/** Abstract class representing a button component with an ID, but without a click action. **/ +public abstract class InteractionButtonWithID : ComponentWithID(), HasPartialEmoji { + /** Button label, for display on Discord. **/ + public var label: String? = null + public override var partialEmoji: DiscordPartialEmoji? = null + + override fun validate() { + super.validate() + + if (label == null && partialEmoji == null) { + error("Buttons must have either a label or emoji.") + } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/LinkInteractionButton.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/LinkInteractionButton.kt new file mode 100644 index 0000000000..25b0659707 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/LinkInteractionButton.kt @@ -0,0 +1,24 @@ +package com.kotlindiscord.kord.extensions.components.buttons + +import dev.kord.rest.builder.component.ActionRowBuilder + +/** Class representing a linked button component, which opens a URL when clicked. **/ +public open class LinkInteractionButton : InteractionButton() { + /** URL to send the user to when clicked. **/ + public open lateinit var url: String + + override fun validate() { + super.validate() + + if (!this::url.isInitialized) { + error("Link buttons must have a URL.") + } + } + + override fun apply(builder: ActionRowBuilder) { + builder.linkButton(url) { + emoji = partialEmoji + label = this@LinkInteractionButton.label + } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/PublicInteractionButton.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/PublicInteractionButton.kt new file mode 100644 index 0000000000..24f884bb63 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/PublicInteractionButton.kt @@ -0,0 +1,121 @@ +@file:Suppress("TooGenericExceptionCaught") + +package com.kotlindiscord.kord.extensions.components.buttons + +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.components.callbacks.PublicButtonCallback +import com.kotlindiscord.kord.extensions.types.FailureReason +import com.kotlindiscord.kord.extensions.types.respond +import com.kotlindiscord.kord.extensions.utils.scheduling.Task +import dev.kord.common.entity.ButtonStyle +import dev.kord.core.behavior.interaction.respondEphemeral +import dev.kord.core.behavior.interaction.respondPublic +import dev.kord.core.event.interaction.ButtonInteractionCreateEvent +import dev.kord.rest.builder.component.ActionRowBuilder +import dev.kord.rest.builder.message.create.InteractionResponseCreateBuilder + +public typealias InitialPublicButtonResponseBuilder = + (suspend InteractionResponseCreateBuilder.(ButtonInteractionCreateEvent) -> Unit)? + +/** Class representing a public-only button component. **/ +public open class PublicInteractionButton( + timeoutTask: Task? +) : InteractionButtonWithAction(timeoutTask) { + /** Button style - anything but Link is valid. **/ + public open var style: ButtonStyle = ButtonStyle.Primary + + /** @suppress Initial response builder. **/ + public open var initialResponseBuilder: InitialPublicButtonResponseBuilder = null + + /** Call this to open with a response, omit it to ack instead. **/ + public fun initialResponse(body: InitialPublicButtonResponseBuilder) { + initialResponseBuilder = body + } + + override fun useCallback(id: String) { + action { + val callback: PublicButtonCallback = callbackRegistry.getOfTypeOrNull(id) + ?: error("Callback \"$id\" is either missing or is the wrong type.") + + callback.call(this) + } + + check { + val callback: PublicButtonCallback = callbackRegistry.getOfTypeOrNull(id) + ?: error("Callback \"$id\" is either missing or is the wrong type.") + + passed = callback.runChecks(event) + } + } + + override fun apply(builder: ActionRowBuilder) { + builder.interactionButton(style, id) { + emoji = partialEmoji + label = this@PublicInteractionButton.label + } + } + + override suspend fun call(event: ButtonInteractionCreateEvent): Unit = withLock { + super.call(event) + + try { + if (!runChecks(event)) { + return@withLock + } + } catch (e: DiscordRelayedException) { + event.interaction.respondEphemeral { + settings.failureResponseBuilder(this, e.reason, FailureReason.ProvidedCheckFailure(e)) + } + + return@withLock + } + + val response = if (initialResponseBuilder != null) { + event.interaction.respondPublic { initialResponseBuilder!!(event) } + } else { + if (!deferredAck) { + event.interaction.acknowledgePublic() + } else { + event.interaction.acknowledgePublicDeferredMessageUpdate() + } + } + + val context = PublicInteractionButtonContext(this, event, response) + + context.populate() + + firstSentryBreadcrumb(context, this) + + try { + checkBotPerms(context) + } catch (e: DiscordRelayedException) { + respondText(context, e.reason, FailureReason.OwnPermissionsCheckFailure(e)) + + return@withLock + } + + try { + body(context) + } catch (e: DiscordRelayedException) { + respondText(context, e.reason, FailureReason.RelayedFailure(e)) + } catch (t: Throwable) { + handleError(context, t, this) + } + } + + override fun validate() { + super.validate() + + if (style == ButtonStyle.Link) { + error("The Link button style is reserved for link buttons.") + } + } + + override suspend fun respondText( + context: PublicInteractionButtonContext, + message: String, + failureType: FailureReason<*> + ) { + context.respond { settings.failureResponseBuilder(this, message, failureType) } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/PublicInteractionButtonContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/PublicInteractionButtonContext.kt new file mode 100644 index 0000000000..95b3471586 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/buttons/PublicInteractionButtonContext.kt @@ -0,0 +1,12 @@ +package com.kotlindiscord.kord.extensions.components.buttons + +import com.kotlindiscord.kord.extensions.types.PublicInteractionContext +import dev.kord.core.behavior.interaction.PublicInteractionResponseBehavior +import dev.kord.core.event.interaction.ButtonInteractionCreateEvent + +/** Class representing the execution context for a public-only button. **/ +public class PublicInteractionButtonContext( + component: PublicInteractionButton, + event: ButtonInteractionCreateEvent, + override val interactionResponse: PublicInteractionResponseBehavior +) : InteractionButtonContext(component, event), PublicInteractionContext diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/callbacks/ComponentCallback.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/callbacks/ComponentCallback.kt new file mode 100644 index 0000000000..cc561b7a56 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/callbacks/ComponentCallback.kt @@ -0,0 +1,93 @@ +package com.kotlindiscord.kord.extensions.components.callbacks + +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.checks.types.Check +import com.kotlindiscord.kord.extensions.checks.types.CheckContext +import com.kotlindiscord.kord.extensions.components.ComponentContext +import com.kotlindiscord.kord.extensions.components.buttons.EphemeralInteractionButtonContext +import com.kotlindiscord.kord.extensions.components.buttons.PublicInteractionButtonContext +import com.kotlindiscord.kord.extensions.components.menus.EphemeralSelectMenuContext +import com.kotlindiscord.kord.extensions.components.menus.PublicSelectMenuContext +import com.kotlindiscord.kord.extensions.utils.getLocale +import dev.kord.core.event.interaction.ButtonInteractionCreateEvent +import dev.kord.core.event.interaction.InteractionCreateEvent +import dev.kord.core.event.interaction.SelectMenuInteractionCreateEvent + +/** Sealed class representing a component callback. **/ +public sealed class ComponentCallback, E : InteractionCreateEvent> { + /** @suppress List of checks stored within this callback. **/ + protected open val checkList: MutableList> = mutableListOf() + + /** @suppress Action body, to be called when the component is interacted with. **/ + protected lateinit var body: suspend C.() -> Unit + + /** Call this to supply a callback [body], to be called when the component is interacted with. **/ + public fun action(action: suspend C.() -> Unit) { + body = action + } + + /** + * Define a check which must pass for the callback to be executed. + * + * A callback may have multiple checks - all checks must pass for the callback's [body] to be executed. + * Checks will be run in the order that they're defined. + * + * This function can be used DSL-style with a given body, or it can be passed one or more + * predefined functions. See the samples for more information. + * + * @param checks Checks to apply to this command. + */ + public open fun check(vararg checks: Check) { + checks.forEach { checkList.add(it) } + } + + /** + * Overloaded check function to allow for DSL syntax. + * + * @param check Check to apply to this command. + */ + public open fun check(check: Check) { + checkList.add(check) + } + + /** Runs the checks that are defined for this callback. **/ + @Throws(DiscordRelayedException::class) + public open suspend fun runChecks(event: E): Boolean { + val locale = event.getLocale() + + checkList.forEach { check -> + val context = CheckContext(event, locale) + + check(context) + + if (!context.passed) { + context.throwIfFailedWithMessage() + + return false + } + } + + return true + } + + /** Function used to run the callback's body. **/ + public open suspend fun call(context: C) { + body(context) + } +} + +/** Component callback for an ephemeral button. **/ +public class EphemeralButtonCallback : + ComponentCallback() + +/** Component callback for a public button. **/ +public class PublicButtonCallback : + ComponentCallback() + +/** Component callback for an ephemeral select menu. **/ +public class EphemeralMenuCallback : + ComponentCallback() + +/** Component callback for a public select menu. **/ +public class PublicMenuCallback : + ComponentCallback() diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/callbacks/ComponentCallbackRegistry.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/callbacks/ComponentCallbackRegistry.kt new file mode 100644 index 0000000000..de3c6b7f8c --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/callbacks/ComponentCallbackRegistry.kt @@ -0,0 +1,84 @@ +package com.kotlindiscord.kord.extensions.components.callbacks + +import com.kotlindiscord.kord.extensions.registry.DefaultLocalRegistryStorage +import com.kotlindiscord.kord.extensions.registry.RegistryStorage + +/** + * Very simple registry for keeping track of registered component callbacks. + * + * Intended to be extended for more advanced use-cases. + */ +public open class ComponentCallbackRegistry { + /** Component callback storage, keyed by unique ID. **/ + public open val storage: RegistryStorage> = DefaultLocalRegistryStorage() + + /** Register a public button callback to the given ID. **/ + public open suspend fun registerForPublicButton( + id: String, + builder: suspend PublicButtonCallback.() -> Unit + ) { + val callback = PublicButtonCallback() + + builder(callback) + + storage.set(id, callback) + } + + /** Register an ephemeral button callback to the given ID. **/ + public open suspend fun registerForEphemeralButton( + id: String, + builder: suspend EphemeralButtonCallback.() -> Unit + ) { + val callback = EphemeralButtonCallback() + + builder(callback) + + storage.set(id, callback) + } + + /** Register a public select menu callback to the given ID. **/ + public open suspend fun registerForPublicMenu( + id: String, + builder: suspend PublicMenuCallback.() -> Unit + ) { + val callback = PublicMenuCallback() + + builder(callback) + + storage.set(id, callback) + } + + /** Register an ephemeral select menu callback to the given ID. **/ + public open suspend fun registerForEphemeralMenu( + id: String, + builder: suspend EphemeralMenuCallback.() -> Unit + ) { + val callback = EphemeralMenuCallback() + + builder(callback) + + storage.set(id, callback) + } + + /** Get a generic component callback object for the given ID, throwing if it doesn't exist. **/ + public open suspend fun get(id: String): ComponentCallback<*, *> = + storage.get(id) ?: error("No callback registered for ID: $id") + + /** Get a generic component callback object for the given ID, or null if it doesn't exist. **/ + public open suspend fun getOrNull(id: String): ComponentCallback<*, *>? = + storage.get(id) + + /** Get a typed component callback object for the given ID, throwing if it doesn't exist or is the wrong type. **/ + public suspend inline fun > getOfType(id: String): T { + val callback = storage.get(id) ?: error("No callback registered for ID: $id") + + return callback as T + } + + /** Get a typed component callback object for the given ID, or null if it doesn't exist or is the wrong type. **/ + public suspend inline fun > getOfTypeOrNull(id: String): T? { + val callback = storage.get(id) + + return callback as? T + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/contexts/ActionableComponentContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/contexts/ActionableComponentContext.kt deleted file mode 100644 index 4f80b347cb..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/contexts/ActionableComponentContext.kt +++ /dev/null @@ -1,243 +0,0 @@ -package com.kotlindiscord.kord.extensions.components.contexts - -import com.kotlindiscord.kord.extensions.annotations.ExtensionDSL -import com.kotlindiscord.kord.extensions.checks.channelFor -import com.kotlindiscord.kord.extensions.checks.guildFor -import com.kotlindiscord.kord.extensions.checks.memberFor -import com.kotlindiscord.kord.extensions.components.Components -import com.kotlindiscord.kord.extensions.extensions.Extension -import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider -import com.kotlindiscord.kord.extensions.sentry.SentryAdapter -import dev.kord.common.annotation.KordPreview -import dev.kord.core.behavior.MemberBehavior -import dev.kord.core.behavior.UserBehavior -import dev.kord.core.behavior.interaction.* -import dev.kord.core.entity.Guild -import dev.kord.core.entity.channel.MessageChannel -import dev.kord.core.entity.interaction.ComponentInteraction -import dev.kord.core.entity.interaction.InteractionFollowup -import dev.kord.core.entity.interaction.PublicFollowupMessage -import dev.kord.core.event.interaction.InteractionCreateEvent -import dev.kord.rest.builder.message.create.EphemeralFollowupMessageCreateBuilder -import dev.kord.rest.builder.message.create.PublicFollowupMessageCreateBuilder -import io.sentry.Breadcrumb -import io.sentry.SentryLevel -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import java.util.* - -/** - * Context object representing the execution context of an actionable component interaction. - * - * @property extension Extension object this interaction happened within. - * @property event Event that was fired for this interaction. - * @property components Components container this button belongs to. - * @property interactionResponse Interaction response, if automatically acked. - * @property interaction Convenience access to the properly-typed interaction object. - */ -@OptIn(KordPreview::class) -@ExtensionDSL -public abstract class ActionableComponentContext( - public open val extension: Extension, - public open val event: InteractionCreateEvent, - public open val components: Components, - public open var interactionResponse: InteractionResponseBehavior? = null, - public open val interaction: T = event.interaction as T -) : KoinComponent { - /** Translations provider, for retrieving translations. **/ - public val translationsProvider: TranslationsProvider by inject() - - /** Sentry adapter, for easy access to Sentry functions. **/ - public val sentry: SentryAdapter by inject() - - /** A list of Sentry breadcrumbs created during interaction execution. **/ - public open val breadcrumbs: MutableList = mutableListOf() - - /** Cached locale variable, stored and retrieved by [getLocale]. **/ - public open var resolvedLocale: Locale? = null - - /** Whether a response or ack has already been sent by the user. **/ - public open val acked: Boolean get() = interactionResponse != null - - /** Channel this interaction happened in. **/ - public open lateinit var channel: MessageChannel - - /** Guild this interaction happened in. **/ - public open var guild: Guild? = null - - /** Guild member responsible for executing this interaction. **/ - public open var member: MemberBehavior? = null - - /** User responsible for executing this interaction. **/ - public open lateinit var user: UserBehavior - - /** Whether we're working ephemerally, or null if no ack or response was sent yet. **/ - public open val isEphemeral: Boolean? - get() = when (interactionResponse) { - is EphemeralInteractionResponseBehavior -> true - is PublicInteractionResponseBehavior -> false - - else -> null - } - - /** Called before processing, used to populate any extra variables from event data. **/ - public suspend fun populate() { - channel = getChannel() - guild = getGuild() - member = getMember() - user = getUser() - } - - /** Extract channel information from event data, if that context is available. **/ - public suspend fun getChannel(): MessageChannel = channelFor(event)!!.asChannel() as MessageChannel - - /** Extract guild information from event data, if that context is available. **/ - public suspend fun getGuild(): Guild? = guildFor(event)?.asGuildOrNull() - - /** Extract member information from event data, if that context is available. **/ - public suspend fun getMember(): MemberBehavior? = memberFor(event)?.asMemberOrNull() - - /** Extract user information from event data, if that context is available. **/ - public suspend fun getUser(): UserBehavior = event.interaction.user - - /** - * Send an acknowledgement manually, assuming you have `autoAck` set to `NONE`. - * - * Note that what you supply for `ephemeral` will decide how the rest of your interactions - both responses and - * follow-ups. They must match in ephemeral state. - * - * This function will throw an exception if an acknowledgement or response has already been sent. - * - * @param ephemeral Whether this should be an ephemeral acknowledgement or not. - */ - public suspend fun ack(ephemeral: Boolean): InteractionResponseBehavior { - if (acked) { - error("Attempted to acknowledge an interaction that's already been acknowledged.") - } - - interactionResponse = if (ephemeral) { - event.interaction.acknowledgeEphemeral() - } else { - event.interaction.acknowledgePublic() - } - - return interactionResponse!! - } - - /** - * Assuming an acknowledgement or response has been sent, send an ephemeral follow-up message. - * - * This function will throw an exception if no acknowledgement or response has been sent yet, or this interaction - * has already been interacted with in a non-ephemeral manner. - * - * Note that ephemeral follow-ups require a content string, and may not contain embeds or files. - */ - public suspend inline fun ephemeralFollowUp( - builder: EphemeralFollowupMessageCreateBuilder.() -> Unit = {} - ): InteractionFollowup { - if (interactionResponse == null) { - error("Tried send an interaction follow-up before acknowledging it.") - } - - if (isEphemeral == false) { - error("Tried send an ephemeral follow-up for a public interaction.") - } - - return (interactionResponse as EphemeralInteractionResponseBehavior).followUpEphemeral(builder) - } - - /** - * Assuming an acknowledgement or response has been sent, send a public follow-up message. - * - * This function will throw an exception if no acknowledgement or response has been sent yet, or this interaction - * has already been interacted with in an ephemeral manner. - */ - public suspend inline fun publicFollowUp( - builder: PublicFollowupMessageCreateBuilder.() -> Unit - ): PublicFollowupMessage { - if (interactionResponse == null) { - error("Tried send an interaction follow-up before acknowledging it.") - } - - if (isEphemeral == true) { - error("Tried to send a public follow-up for an ephemeral interaction.") - } - - return (interactionResponse as PublicInteractionResponseBehavior).followUp(builder) - } - - /** - * Add a Sentry breadcrumb to this context. - * - * This should be used for the purposes of tracing what exactly is happening during your - * interaction processing. If the bot administrator decides to enable Sentry integration, the - * breadcrumbs will be sent to Sentry when there's an interaction processing error. - */ - public fun breadcrumb( - category: String? = null, - level: SentryLevel? = null, - type: String? = null, - - data: Map = mapOf() - ): Breadcrumb { - val crumb = sentry.createBreadcrumb(category, level, null, type, data) - - breadcrumbs.add(crumb) - - return crumb - } - - /** Resolve the locale for this context. **/ - public suspend fun getLocale(): Locale { - var locale: Locale? = resolvedLocale - - if (locale != null) { - return locale - } - - for (resolver in extension.bot.settings.i18nBuilder.localeResolvers) { - val result = resolver(guild, channel, user) - - if (result != null) { - locale = result - break - } - } - - resolvedLocale = locale ?: extension.bot.settings.i18nBuilder.defaultLocale - - return resolvedLocale!! - } - - /** - * Given a translation key and bundle name, return the translation for the locale provided by the bot's configured - * locale resolvers. - */ - public suspend fun translate( - key: String, - bundleName: String?, - replacements: Array = arrayOf() - ): String { - val locale = getLocale() - - return translationsProvider.translate(key, locale, bundleName, replacements) - } - - /** - * Given a translation key and possible replacements,return the translation for the given locale in the - * extension's configured bundle, for the locale provided by the bot's configured locale resolvers. - */ - public suspend fun translate(key: String, replacements: Array = arrayOf()): String = translate( - key, - extension.bundle, - replacements - ) - - /** Convenience function to send a response follow-up message containing only text. **/ - public suspend fun respond(text: String): Any = when (isEphemeral) { - true -> ephemeralFollowUp { content = text } - false -> publicFollowUp { content = text } - - else -> interactionResponse = interaction.respondEphemeral { content = text } - } -} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/contexts/InteractiveButtonContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/contexts/InteractiveButtonContext.kt deleted file mode 100644 index d5463e8a48..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/contexts/InteractiveButtonContext.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.kotlindiscord.kord.extensions.components.contexts - -import com.kotlindiscord.kord.extensions.annotations.ExtensionDSL -import com.kotlindiscord.kord.extensions.components.Components -import com.kotlindiscord.kord.extensions.extensions.Extension -import dev.kord.common.annotation.KordPreview -import dev.kord.core.behavior.interaction.* -import dev.kord.core.entity.interaction.* -import dev.kord.core.event.interaction.InteractionCreateEvent -import org.koin.core.component.KoinComponent -import java.util.* - -/** - * Context object representing the execution context of an interactive button interaction. - */ -@OptIn(KordPreview::class) -@ExtensionDSL -public open class InteractiveButtonContext( - extension: Extension, - event: InteractionCreateEvent, - components: Components, - interactionResponse: InteractionResponseBehavior? = null, - interaction: ButtonInteraction = event.interaction as ButtonInteraction -) : KoinComponent, ActionableComponentContext( - extension, event, components, interactionResponse, interaction -) diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/contexts/MenuContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/contexts/MenuContext.kt deleted file mode 100644 index f2c9fbb33a..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/contexts/MenuContext.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.kotlindiscord.kord.extensions.components.contexts - -import com.kotlindiscord.kord.extensions.annotations.ExtensionDSL -import com.kotlindiscord.kord.extensions.components.Components -import com.kotlindiscord.kord.extensions.extensions.Extension -import dev.kord.common.annotation.KordPreview -import dev.kord.core.behavior.interaction.* -import dev.kord.core.entity.interaction.SelectMenuInteraction -import dev.kord.core.event.interaction.InteractionCreateEvent -import org.koin.core.component.KoinComponent -import java.util.* - -/** - * Context object representing the execution context of a menu selection interaction. - */ -@OptIn(KordPreview::class) -@ExtensionDSL -public open class MenuContext( - extension: Extension, - event: InteractionCreateEvent, - components: Components, - interactionResponse: InteractionResponseBehavior? = null, - interaction: SelectMenuInteraction = event.interaction as SelectMenuInteraction -) : KoinComponent, ActionableComponentContext( - extension, event, components, interactionResponse, interaction -) { - /** Quick access to the selected values. **/ - public val selected: List = interaction.values -} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/EphemeralSelectMenu.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/EphemeralSelectMenu.kt new file mode 100644 index 0000000000..5d73017741 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/EphemeralSelectMenu.kt @@ -0,0 +1,98 @@ +@file:Suppress("TooGenericExceptionCaught") + +package com.kotlindiscord.kord.extensions.components.menus + +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.components.callbacks.EphemeralMenuCallback +import com.kotlindiscord.kord.extensions.types.FailureReason +import com.kotlindiscord.kord.extensions.types.respond +import com.kotlindiscord.kord.extensions.utils.scheduling.Task +import dev.kord.core.behavior.interaction.respondEphemeral +import dev.kord.core.event.interaction.SelectMenuInteractionCreateEvent +import dev.kord.rest.builder.message.create.InteractionResponseCreateBuilder + +public typealias InitialEphemeralSelectMenuResponseBuilder = + (suspend InteractionResponseCreateBuilder.(SelectMenuInteractionCreateEvent) -> Unit)? + +/** Class representing an ephemeral-only select (dropdown) menu. **/ +public open class EphemeralSelectMenu(timeoutTask: Task?) : SelectMenu(timeoutTask) { + /** @suppress Initial response builder. **/ + public open var initialResponseBuilder: InitialEphemeralSelectMenuResponseBuilder = null + + /** Call this to open with a response, omit it to ack instead. **/ + public fun initialResponse(body: InitialEphemeralSelectMenuResponseBuilder) { + initialResponseBuilder = body + } + + override fun useCallback(id: String) { + action { + val callback: EphemeralMenuCallback = callbackRegistry.getOfTypeOrNull(id) + ?: error("Callback \"$id\" is either missing or is the wrong type.") + + callback.call(this) + } + + check { + val callback: EphemeralMenuCallback = callbackRegistry.getOfTypeOrNull(id) + ?: error("Callback \"$id\" is either missing or is the wrong type.") + + passed = callback.runChecks(event) + } + } + + override suspend fun call(event: SelectMenuInteractionCreateEvent): Unit = withLock { + super.call(event) + + try { + if (!runChecks(event)) { + return@withLock + } + } catch (e: DiscordRelayedException) { + event.interaction.respondEphemeral { + settings.failureResponseBuilder(this, e.reason, FailureReason.ProvidedCheckFailure(e)) + } + + return@withLock + } + + val response = if (initialResponseBuilder != null) { + event.interaction.respondEphemeral { initialResponseBuilder!!(event) } + } else { + if (!deferredAck) { + event.interaction.acknowledgeEphemeral() + } else { + event.interaction.acknowledgeEphemeralDeferredMessageUpdate() + } + } + + val context = EphemeralSelectMenuContext(this, event, response) + + context.populate() + + firstSentryBreadcrumb(context, this) + + try { + checkBotPerms(context) + } catch (e: DiscordRelayedException) { + respondText(context, e.reason, FailureReason.OwnPermissionsCheckFailure(e)) + + return@withLock + } + + try { + body(context) + } catch (e: DiscordRelayedException) { + respondText(context, e.reason, FailureReason.RelayedFailure(e)) + } catch (t: Throwable) { + handleError(context, t, this) + } + } + + override suspend fun respondText( + context: EphemeralSelectMenuContext, + message: String, + failureType: FailureReason<*> + ) { + context.respond { settings.failureResponseBuilder(this, message, failureType) } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/EphemeralSelectMenuContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/EphemeralSelectMenuContext.kt new file mode 100644 index 0000000000..93ba25d6c1 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/EphemeralSelectMenuContext.kt @@ -0,0 +1,13 @@ +package com.kotlindiscord.kord.extensions.components.menus + +import com.kotlindiscord.kord.extensions.components.Component +import com.kotlindiscord.kord.extensions.types.EphemeralInteractionContext +import dev.kord.core.behavior.interaction.EphemeralInteractionResponseBehavior +import dev.kord.core.event.interaction.SelectMenuInteractionCreateEvent + +/** Class representing the execution context for an ephemeral-only select (dropdown) menu. **/ +public class EphemeralSelectMenuContext( + override val component: Component, + override val event: SelectMenuInteractionCreateEvent, + override val interactionResponse: EphemeralInteractionResponseBehavior +) : SelectMenuContext(component, event), EphemeralInteractionContext diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/PublicSelectMenu.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/PublicSelectMenu.kt new file mode 100644 index 0000000000..fea451b3da --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/PublicSelectMenu.kt @@ -0,0 +1,99 @@ +@file:Suppress("TooGenericExceptionCaught") + +package com.kotlindiscord.kord.extensions.components.menus + +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.components.callbacks.PublicMenuCallback +import com.kotlindiscord.kord.extensions.types.FailureReason +import com.kotlindiscord.kord.extensions.types.respond +import com.kotlindiscord.kord.extensions.utils.scheduling.Task +import dev.kord.core.behavior.interaction.respondEphemeral +import dev.kord.core.behavior.interaction.respondPublic +import dev.kord.core.event.interaction.SelectMenuInteractionCreateEvent +import dev.kord.rest.builder.message.create.InteractionResponseCreateBuilder + +public typealias InitialPublicSelectMenuResponseBuilder = + (suspend InteractionResponseCreateBuilder.(SelectMenuInteractionCreateEvent) -> Unit)? + +/** Class representing a public-only select (dropdown) menu. **/ +public open class PublicSelectMenu(timeoutTask: Task?) : SelectMenu(timeoutTask) { + /** @suppress Initial response builder. **/ + public open var initialResponseBuilder: InitialPublicSelectMenuResponseBuilder = null + + /** Call this to open with a response, omit it to ack instead. **/ + public fun initialResponse(body: InitialPublicSelectMenuResponseBuilder) { + initialResponseBuilder = body + } + + override fun useCallback(id: String) { + action { + val callback: PublicMenuCallback = callbackRegistry.getOfTypeOrNull(id) + ?: error("Callback \"$id\" is either missing or is the wrong type.") + + callback.call(this) + } + + check { + val callback: PublicMenuCallback = callbackRegistry.getOfTypeOrNull(id) + ?: error("Callback \"$id\" is either missing or is the wrong type.") + + passed = callback.runChecks(event) + } + } + + override suspend fun call(event: SelectMenuInteractionCreateEvent): Unit = withLock { + super.call(event) + + try { + if (!runChecks(event)) { + return@withLock + } + } catch (e: DiscordRelayedException) { + event.interaction.respondEphemeral { + settings.failureResponseBuilder(this, e.reason, FailureReason.ProvidedCheckFailure(e)) + } + + return@withLock + } + + val response = if (initialResponseBuilder != null) { + event.interaction.respondPublic { initialResponseBuilder!!(event) } + } else { + if (!deferredAck) { + event.interaction.acknowledgePublic() + } else { + event.interaction.acknowledgePublicDeferredMessageUpdate() + } + } + + val context = PublicSelectMenuContext(this, event, response) + + context.populate() + + firstSentryBreadcrumb(context, this) + + try { + checkBotPerms(context) + } catch (e: DiscordRelayedException) { + respondText(context, e.reason, FailureReason.OwnPermissionsCheckFailure(e)) + + return@withLock + } + + try { + body(context) + } catch (e: DiscordRelayedException) { + respondText(context, e.reason, FailureReason.RelayedFailure(e)) + } catch (t: Throwable) { + handleError(context, t, this) + } + } + + override suspend fun respondText( + context: PublicSelectMenuContext, + message: String, + failureType: FailureReason<*> + ) { + context.respond { settings.failureResponseBuilder(this, message, failureType) } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/PublicSelectMenuContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/PublicSelectMenuContext.kt new file mode 100644 index 0000000000..f939203aeb --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/PublicSelectMenuContext.kt @@ -0,0 +1,13 @@ +package com.kotlindiscord.kord.extensions.components.menus + +import com.kotlindiscord.kord.extensions.components.Component +import com.kotlindiscord.kord.extensions.types.PublicInteractionContext +import dev.kord.core.behavior.interaction.PublicInteractionResponseBehavior +import dev.kord.core.event.interaction.SelectMenuInteractionCreateEvent + +/** Class representing the execution context for a public-only select (dropdown) menu. **/ +public class PublicSelectMenuContext( + override val component: Component, + override val event: SelectMenuInteractionCreateEvent, + override val interactionResponse: PublicInteractionResponseBehavior +) : SelectMenuContext(component, event), PublicInteractionContext diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/SelectMenu.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/SelectMenu.kt new file mode 100644 index 0000000000..cfe66440e3 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/SelectMenu.kt @@ -0,0 +1,185 @@ +package com.kotlindiscord.kord.extensions.components.menus + +import com.kotlindiscord.kord.extensions.components.ComponentWithAction +import com.kotlindiscord.kord.extensions.sentry.BreadcrumbType +import com.kotlindiscord.kord.extensions.sentry.tag +import com.kotlindiscord.kord.extensions.sentry.user +import com.kotlindiscord.kord.extensions.types.FailureReason +import com.kotlindiscord.kord.extensions.utils.scheduling.Task +import dev.kord.core.entity.channel.DmChannel +import dev.kord.core.entity.channel.GuildMessageChannel +import dev.kord.core.event.interaction.SelectMenuInteractionCreateEvent +import dev.kord.rest.builder.component.ActionRowBuilder +import dev.kord.rest.builder.component.SelectOptionBuilder +import io.sentry.Sentry +import mu.KLogger +import mu.KotlinLogging + +/** Maximum length for an option's description. **/ +public const val DESCRIPTION_MAX: Int = 50 + +/** Maximum length for an option's label. **/ +public const val LABEL_MAX: Int = 25 + +/** Maximum number of options for a menu. **/ +public const val OPTIONS_MAX: Int = 25 + +/** Maximum length for a menu's placeholder. **/ +public const val PLACEHOLDER_MAX: Int = 100 + +/** Maximum length for an option's value. **/ +public const val VALUE_MAX: Int = 100 + +/** Abstract class representing a select (dropdown) menu component. **/ +public abstract class SelectMenu( + timeoutTask: Task? +) : ComponentWithAction(timeoutTask) { + internal val logger: KLogger = KotlinLogging.logger {} + + /** List of options for the user to choose from. **/ + public val options: MutableList = mutableListOf() + + /** The minimum number of choices that the user must make. **/ + public var minimumChoices: Int = 1 + + /** The maximum number of choices that the user can make. Set to `null` for no maximum. **/ + public var maximumChoices: Int? = 1 + + /** Placeholder text to show before the user has selected any options. **/ + public var placeholder: String? = null + + @Suppress("MagicNumber") // WHY DO YOU THINK I ASSIGN IT HERE + override val unitWidth: Int = 5 + + /** Add an option to this select menu. **/ + @Suppress("UnnecessaryParentheses") // Disagrees with IDEA, amusingly. + public open suspend fun option(label: String, value: String, body: suspend SelectOptionBuilder.() -> Unit = {}) { + val builder = SelectOptionBuilder(label, value) + + body(builder) + + if ((builder.description?.length ?: 0) > DESCRIPTION_MAX) { + error("Option descriptions must not be longer than $DESCRIPTION_MAX characters.") + } + + if (builder.label.length > LABEL_MAX) { + error("Option labels must not be longer than $LABEL_MAX characters.") + } + + if (builder.value.length > VALUE_MAX) { + error("Option values must not be longer than $VALUE_MAX characters.") + } + + options.add(builder) + } + + public override fun apply(builder: ActionRowBuilder) { + if (maximumChoices == null || maximumChoices!! > options.size) { + maximumChoices = options.size + } + + builder.selectMenu(id) { + allowedValues = minimumChoices..maximumChoices!! + + this.options.addAll(this@SelectMenu.options) + this.placeholder = this@SelectMenu.placeholder + } + } + + @Suppress("UnnecessaryParentheses") // Disagrees with IDEA, amusingly. + override fun validate() { + super.validate() + + if (this.options.isEmpty()) { + error("Menu components must have at least one option.") + } + + if (this.options.size > OPTIONS_MAX) { + error("Menu components must not have more than $OPTIONS_MAX options.") + } + + if ((this.placeholder?.length ?: 0) > PLACEHOLDER_MAX) { + error("Menu components must not have a placeholder longer than $PLACEHOLDER_MAX characters.") + } + } + + /** If enabled, adds the initial Sentry breadcrumb to the given context. **/ + public open suspend fun firstSentryBreadcrumb(context: C, button: SelectMenu<*>) { + if (sentry.enabled) { + context.sentry.breadcrumb(BreadcrumbType.User) { + category = "component.selectMenu" + message = "Select menu \"${button.id}\" submitted." + + val channel = context.channel.asChannelOrNull() + val guild = context.guild?.asGuildOrNull() + val message = context.message + + data["component"] = button.id + + if (channel != null) { + data["channel"] = when (channel) { + is DmChannel -> "Private Message (${channel.id.asString})" + is GuildMessageChannel -> "#${channel.name} (${channel.id.asString})" + + else -> channel.id.asString + } + } + + if (guild != null) { + data["guild"] = "${guild.name} (${guild.id.asString})" + } + + if (message != null) { + data["message"] = message.id.asString + } + } + } + } + + /** A general way to handle errors thrown during the course of a select menu action's execution. **/ + public open suspend fun handleError(context: C, t: Throwable, button: SelectMenu<*>) { + logger.error(t) { "Error during execution of select menu (${button.id}) action (${context.event})" } + + if (sentry.enabled) { + logger.trace { "Submitting error to sentry." } + + val channel = context.channel + val author = context.user.asUserOrNull() + + val sentryId = context.sentry.captureException(t, "Select menu action execution failed.") { + if (author != null) { + user(author) + } + + tag("private", "false") + + if (channel is DmChannel) { + tag("private", "true") + } + + tag("component", button.id) + + Sentry.captureException(t, "Select menu action execution failed.") + } + + logger.info { "Error submitted to Sentry: $sentryId" } + + val errorMessage = if (bot.extensions.containsKey("sentry")) { + context.translate("commands.error.user.sentry.slash", null, replacements = arrayOf(sentryId)) + } else { + context.translate("commands.error.user", null) + } + + respondText(context, errorMessage, FailureReason.ExecutionError(t)) + } else { + respondText( + context, + context.translate("commands.error.user", null), + FailureReason.ExecutionError(t) + ) + } + } + + /** Override this to implement a way to respond to the user, regardless of whatever happens. **/ + public abstract suspend fun respondText(context: C, message: String, failureType: FailureReason<*>) +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/SelectMenuContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/SelectMenuContext.kt new file mode 100644 index 0000000000..62d0633d79 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/menus/SelectMenuContext.kt @@ -0,0 +1,14 @@ +package com.kotlindiscord.kord.extensions.components.menus + +import com.kotlindiscord.kord.extensions.components.Component +import com.kotlindiscord.kord.extensions.components.ComponentContext +import dev.kord.core.event.interaction.SelectMenuInteractionCreateEvent + +/** Abstract class representing the execution context of a select (dropdown) menu component. **/ +public abstract class SelectMenuContext( + component: Component, + event: SelectMenuInteractionCreateEvent +) : ComponentContext(component, event) { + /** Menu options that were selected by the user before de-focusing the menu. **/ + public val selected: List by lazy { event.interaction.values } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/types/HasPartialEmoji.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/types/HasPartialEmoji.kt new file mode 100644 index 0000000000..a87829c833 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/components/types/HasPartialEmoji.kt @@ -0,0 +1,57 @@ +package com.kotlindiscord.kord.extensions.components.types + +import dev.kord.common.entity.DiscordPartialEmoji +import dev.kord.common.entity.optional.optional +import dev.kord.core.entity.GuildEmoji +import dev.kord.core.entity.ReactionEmoji + +/** + * Interface representing a button type that has a partial emoji property. This is used to keep the [emoji] + * function DRY. + */ +public interface HasPartialEmoji { + /** + * A partial emoji object, either a guild or Unicode emoji. Optional if you've got a label. + * + * @see emoji + */ + public var partialEmoji: DiscordPartialEmoji? +} + +/** Convenience function for setting [HasPartialEmoji.partialEmoji] based on a given Unicode emoji. **/ +public fun HasPartialEmoji.emoji(unicodeEmoji: String) { + partialEmoji = DiscordPartialEmoji( + name = unicodeEmoji + ) +} + +/** Convenience function for setting [HasPartialEmoji.partialEmoji] based on a given guild custom emoji. **/ +public fun HasPartialEmoji.emoji(guildEmoji: GuildEmoji) { + partialEmoji = DiscordPartialEmoji( + id = guildEmoji.id, + name = guildEmoji.name, + animated = guildEmoji.isAnimated.optional() + ) +} + +/** Convenience function for setting [HasPartialEmoji.partialEmoji] based on a given reaction emoji. **/ +public fun HasPartialEmoji.emoji(unicodeEmoji: ReactionEmoji.Unicode) { + partialEmoji = DiscordPartialEmoji( + name = unicodeEmoji.name + ) +} + +/** Convenience function for setting [HasPartialEmoji.partialEmoji] based on a given reaction emoji. **/ +public fun HasPartialEmoji.emoji(guildEmoji: ReactionEmoji.Custom) { + partialEmoji = DiscordPartialEmoji( + id = guildEmoji.id, + name = guildEmoji.name, + animated = guildEmoji.isAnimated.optional() + ) +} + +/** Convenience function for setting [HasPartialEmoji.partialEmoji] based on a given reaction emoji. **/ +public fun HasPartialEmoji.emoji(emoji: ReactionEmoji): Unit = when (emoji) { + is ReactionEmoji.Unicode -> emoji(emoji) + is ReactionEmoji.Custom -> emoji(emoji) +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/EventContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/EventContext.kt index 57ca5cc55e..7a1d7e2c65 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/EventContext.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/EventContext.kt @@ -4,10 +4,8 @@ import com.kotlindiscord.kord.extensions.checks.channelFor import com.kotlindiscord.kord.extensions.checks.guildFor import com.kotlindiscord.kord.extensions.checks.userFor import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider -import com.kotlindiscord.kord.extensions.sentry.SentryAdapter +import com.kotlindiscord.kord.extensions.sentry.SentryContext import dev.kord.core.event.Event -import io.sentry.Breadcrumb -import io.sentry.SentryLevel import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.util.* @@ -27,11 +25,8 @@ public open class EventContext( /** Translations provider, for retrieving translations. **/ public val translationsProvider: TranslationsProvider by inject() - /** Sentry adapter, for easy access to Sentry functions. **/ - public val sentry: SentryAdapter by inject() - - /** A list of Sentry breadcrumbs created during event processing. **/ - public open val breadcrumbs: MutableList = mutableListOf() + /** Current Sentry context, containing breadcrumbs and other goodies. **/ + public val sentry: SentryContext = SentryContext() /** * Given a translation key and optional bundle name, return the translation for the locale provided by the bot's @@ -73,26 +68,4 @@ public open class EventContext( key: String, replacements: Array = arrayOf() ): String = translate(key, eventHandler.extension.bundle, replacements) - - /** - * Add a Sentry breadcrumb to this event context. - * - * This should be used for the purposes of tracing what exactly is happening during your - * event processing. If the bot administrator decides to enable Sentry integration, the - * breadcrumbs will be sent to Sentry when there's an event processing error. - */ - public fun breadcrumb( - category: String? = null, - level: SentryLevel? = null, - message: String? = null, - type: String? = null, - - data: Map = mapOf() - ): Breadcrumb { - val crumb = sentry.createBreadcrumb(category, level, message, type, data) - - breadcrumbs.add(crumb) - - return crumb - } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/EventHandler.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/EventHandler.kt index 3169c01fba..a96b103e85 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/EventHandler.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/EventHandler.kt @@ -7,20 +7,18 @@ import com.kotlindiscord.kord.extensions.checks.types.Check import com.kotlindiscord.kord.extensions.checks.types.CheckContext import com.kotlindiscord.kord.extensions.extensions.Extension import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider +import com.kotlindiscord.kord.extensions.sentry.BreadcrumbType import com.kotlindiscord.kord.extensions.sentry.SentryAdapter import com.kotlindiscord.kord.extensions.sentry.tag import com.kotlindiscord.kord.extensions.utils.getKoin -import dev.kord.common.entity.Snowflake import dev.kord.core.Kord import dev.kord.core.entity.channel.DmChannel import dev.kord.core.entity.channel.GuildMessageChannel import dev.kord.core.event.Event -import io.sentry.Sentry import kotlinx.coroutines.Job import mu.KotlinLogging import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import java.io.Serializable import java.util.* private val logger = KotlinLogging.logger {} @@ -44,8 +42,8 @@ public open class EventHandler( /** Sentry adapter, for easy access to Sentry functions. **/ public val sentry: SentryAdapter by inject() - /** Kord instance, backing the ExtensibleBot. **/ - public val kord: Kord by inject() + /** Current Kord instance powering the bot. **/ + public open val kord: Kord by inject() /** Translations provider, for retrieving translations. **/ public val translationsProvider: TranslationsProvider by inject() @@ -65,6 +63,9 @@ public open class EventHandler( */ public var job: Job? = null + /** @suppress Internal hack to work around logic ordering with inline functions. **/ + public var listenerRegistrationCallable: (() -> Unit)? = null + /** Cached locale variable, stored and retrieved by [getLocale]. **/ public var resolvedLocale: Locale? = null @@ -111,44 +112,6 @@ public open class EventHandler( */ public fun check(check: Check): Boolean = checkList.add(check) - /** - * Define a simple Boolean check which must pass for the event handler to be executed. - * - * Boolean checks are simple wrappers around the regular check system, allowing you to define a basic check that - * takes an event object and returns a [Boolean] representing whether it passed. This style of check does not have - * the same functionality as a regular check, and cannot return a message. - * - * An event handler may have multiple checks - all checks must pass for the command to be executed. - * Checks will be run in the order that they're defined. - * - * This function can be used DSL-style with a given body, or it can be passed one or more - * predefined functions. See the samples for more information. - * - * @param checks Checks to apply to this event handler. - */ - public open fun booleanCheck(vararg checks: suspend (T) -> Boolean) { - checks.forEach(::booleanCheck) - } - - /** - * Overloaded simple Boolean check function to allow for DSL syntax. - * - * Boolean checks are simple wrappers around the regular check system, allowing you to define a basic check that - * takes an event object and returns a [Boolean] representing whether it passed. This style of check does not have - * the same functionality as a regular check, and cannot return a message. - * - * @param check Check to apply to this event handler. - */ - public open fun booleanCheck(check: suspend (T) -> Boolean) { - check { - if (check(event)) { - pass() - } else { - fail() - } - } - } - // endregion /** @@ -175,17 +138,17 @@ public open class EventHandler( val context = EventContext(this, event) val eventName = event::class.simpleName - val firstBreadcrumb = if (sentry.enabled) { - val data = mutableMapOf() + if (sentry.enabled) { + context.sentry.breadcrumb(BreadcrumbType.Info) { + category = "event" + message = "Event \"$eventName\" fired." - val channelId = channelIdFor(event) - val guildBehavior = guildFor(event) - val messageBehavior = messageFor(event) - val roleBehavior = roleFor(event) - val userBehavior = userFor(event) - - if (channelId != null) { - val channel = kord.getChannel(Snowflake(channelId)) + val channel = topChannelFor(event) + val guildBehavior = guildFor(event) + val messageBehavior = messageFor(event) + val roleBehavior = roleFor(event) + val thread = threadFor(event)?.asChannel() + val userBehavior = userFor(event) if (channel != null) { data["channel"] = when (channel) { @@ -194,53 +157,46 @@ public open class EventHandler( else -> channel.id.asString } - } else { - data["channel"] = channelId } - } - if (guildBehavior != null) { - val guild = guildBehavior.asGuildOrNull() + if (thread != null) { + data["thread"] = "#${thread.name} (${thread.id.asString})" + } + + if (guildBehavior != null) { + val guild = guildBehavior.asGuildOrNull() - data["guild"] = if (guild != null) { - "${guild.name} (${guild.id.asString})" - } else { - guildBehavior.id.asString + data["guild"] = if (guild != null) { + "${guild.name} (${guild.id.asString})" + } else { + guildBehavior.id.asString + } } - } - if (messageBehavior != null) { - data["message"] = messageBehavior.id.asString - } + if (messageBehavior != null) { + data["message"] = messageBehavior.id.asString + } - if (roleBehavior != null) { - val role = roleBehavior.guild.getRoleOrNull(roleBehavior.id) + if (roleBehavior != null) { + val role = roleBehavior.guild.getRoleOrNull(roleBehavior.id) - data["role"] = if (role != null) { - "@${role.name} (${role.id.asString})" - } else { - roleBehavior.id.asString + data["role"] = if (role != null) { + "@${role.name} (${role.id.asString})" + } else { + roleBehavior.id.asString + } } - } - if (userBehavior != null) { - val user = userBehavior.asUserOrNull() + if (userBehavior != null) { + val user = userBehavior.asUserOrNull() - data["user"] = if (user != null) { - "${user.tag} (${user.id.asString})" - } else { - userBehavior.id.asString + data["user"] = if (user != null) { + "${user.tag} (${user.id.asString})" + } else { + userBehavior.id.asString + } } } - - sentry.createBreadcrumb( - category = "event", - type = "info", - message = "Event \"$eventName\" fired.", - data = data - ) - } else { - null } @Suppress("TooGenericExceptionCaught") // Anything could happen here @@ -248,21 +204,15 @@ public open class EventHandler( this.body(context) } catch (t: Throwable) { if (sentry.enabled && extension.bot.extensions.containsKey("sentry")) { - logger.debug { "Submitting error to sentry." } + logger.trace { "Submitting error to sentry." } - Sentry.withScope { - it.tag("event", eventName ?: "Unknown") - it.tag("extension", extension.name) - - it.addBreadcrumb(firstBreadcrumb!!) - - context.breadcrumbs.forEach { breadcrumb -> it.addBreadcrumb(breadcrumb) } - - val sentryId = Sentry.captureException(t, "Event processing failed.") - - logger.debug { "Error submitted to Sentry: $sentryId" } + val sentryId = context.sentry.captureException(t, "Event processing failed.") { + tag("event", eventName ?: "Unknown") + tag("extension", extension.name) } + logger.info { "Error submitted to Sentry: $sentryId" } + logger.error(t) { "Error during execution of event handler ($eventName)" } } else { logger.error(t) { "Error during execution of event handler ($eventName)" } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/ExtensionEvent.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/ExtensionEvent.kt deleted file mode 100644 index 0799f366bd..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/ExtensionEvent.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.kotlindiscord.kord.extensions.events - -import com.kotlindiscord.kord.extensions.ExtensibleBot -import dev.kord.core.Kord -import dev.kord.core.event.Event -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -/** - * Base interface for events fired by Kord Extensions. - */ -public abstract class ExtensionEvent : Event, KoinComponent { - /** Current bot instance for this event. **/ - public val bot: ExtensibleBot by inject() - - override val kord: Kord by inject() - override val shard: Int = -1 -} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/ExtensionStateEvent.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/ExtensionStateEvent.kt index 1811ea6451..a0f78fd447 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/ExtensionStateEvent.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/ExtensionStateEvent.kt @@ -9,7 +9,7 @@ import com.kotlindiscord.kord.extensions.extensions.ExtensionState * @property extension Extension that has a state change * @property state Extension's new state */ -public class ExtensionStateEvent( +public data class ExtensionStateEvent( public val extension: Extension, public val state: ExtensionState -) : ExtensionEvent() +) : KordExEvent diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/KordExEvent.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/KordExEvent.kt new file mode 100644 index 0000000000..eb558e7e6a --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/events/KordExEvent.kt @@ -0,0 +1,15 @@ +package com.kotlindiscord.kord.extensions.events + +import dev.kord.core.Kord +import dev.kord.core.event.Event +import org.koin.core.component.KoinComponent +import kotlin.coroutines.CoroutineContext + +/** + * Base interface for events fired by Kord Extensions. + */ +public interface KordExEvent : Event, KoinComponent { + override val kord: Kord get() = getKoin().get() + override val shard: Int get() = -1 + override val coroutineContext: CoroutineContext get() = kord.coroutineContext +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/Extension.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/Extension.kt index 47730f8642..b101b94458 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/Extension.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/Extension.kt @@ -2,22 +2,24 @@ package com.kotlindiscord.kord.extensions.extensions -import com.kotlindiscord.kord.extensions.* -import com.kotlindiscord.kord.extensions.annotations.ExtensionDSL -import com.kotlindiscord.kord.extensions.checks.types.Check -import com.kotlindiscord.kord.extensions.commands.GroupCommand -import com.kotlindiscord.kord.extensions.commands.MessageCommand -import com.kotlindiscord.kord.extensions.commands.MessageCommandRegistry -import com.kotlindiscord.kord.extensions.commands.parser.Arguments -import com.kotlindiscord.kord.extensions.commands.slash.SlashCommand -import com.kotlindiscord.kord.extensions.commands.slash.SlashCommandRegistry +import com.kotlindiscord.kord.extensions.ExtensibleBot +import com.kotlindiscord.kord.extensions.checks.types.ChatCommandCheck +import com.kotlindiscord.kord.extensions.checks.types.MessageCommandCheck +import com.kotlindiscord.kord.extensions.checks.types.SlashCommandCheck +import com.kotlindiscord.kord.extensions.checks.types.UserCommandCheck +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.application.ApplicationCommandRegistry +import com.kotlindiscord.kord.extensions.commands.application.message.MessageCommand +import com.kotlindiscord.kord.extensions.commands.application.slash.SlashCommand +import com.kotlindiscord.kord.extensions.commands.application.user.UserCommand +import com.kotlindiscord.kord.extensions.commands.chat.ChatCommand +import com.kotlindiscord.kord.extensions.commands.chat.ChatCommandRegistry import com.kotlindiscord.kord.extensions.events.EventHandler import com.kotlindiscord.kord.extensions.events.ExtensionStateEvent import dev.kord.common.annotation.KordPreview import dev.kord.core.Kord import dev.kord.core.event.Event -import dev.kord.core.event.interaction.InteractionCreateEvent -import dev.kord.core.event.message.MessageCreateEvent +import dev.kord.gateway.Intent import mu.KotlinLogging import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -39,10 +41,10 @@ public abstract class Extension : KoinComponent { public open val kord: Kord by inject() /** Message command registry. **/ - private val messageCommandsRegistry: MessageCommandRegistry by inject() + public open val chatCommandRegistry: ChatCommandRegistry by inject() /** Slash command registry. **/ - private val slashCommandsRegistry: SlashCommandRegistry by inject() + public open val applicationCommandRegistry: ApplicationCommandRegistry by inject() /** * The name of this extension. @@ -73,7 +75,7 @@ public abstract class Extension : KoinComponent { * * When an extension is unloaded, all the commands are removed from the bot. */ - public open val commands: MutableList> = mutableListOf() + public open val chatCommands: MutableList> = mutableListOf() /** * List of registered slash commands. @@ -81,27 +83,59 @@ public abstract class Extension : KoinComponent { * Unlike normal commands, slash commands cannot be unregistered dynamically. However, slash commands that * belong to unloaded extensions will not execute. */ - public open val slashCommands: MutableList> = mutableListOf() + public open val messageCommands: MutableList> = mutableListOf() /** - * List of message command checks. + * List of registered slash commands. + * + * Unlike normal commands, slash commands cannot be unregistered dynamically. However, slash commands that + * belong to unloaded extensions will not execute. + */ + public open val slashCommands: MutableList> = mutableListOf() + + /** + * List of registered slash commands. + * + * Unlike normal commands, slash commands cannot be unregistered dynamically. However, slash commands that + * belong to unloaded extensions will not execute. + */ + public open val userCommands: MutableList> = mutableListOf() + + /** + * List of chat command checks. * - * These checks will be checked against all commands in this extension. + * These checks will be checked against all chat commands in this extension. */ - public open val commandChecks: MutableList> = + public open val chatCommandChecks: MutableList = mutableListOf() + /** + * List of message command checks. + * + * These checks will be checked against all message commands in this extension. + */ + public val messageCommandChecks: MutableList = mutableListOf() + /** * List of slash command checks. * * These checks will be checked against all slash commands in this extension. */ - public open val slashCommandChecks: MutableList> = - mutableListOf() + public val slashCommandChecks: MutableList = mutableListOf() + + /** + * List of user command checks. + * + * These checks will be checked against all user commands in this extension. + */ + public val userCommandChecks: MutableList = mutableListOf() /** String representing the bundle to get translations from for command names/descriptions. **/ public open val bundle: String? = null + /** Set of intents required by this extension's event handlers and commands. **/ + public open val intents: MutableSet = mutableSetOf() + /** * Override this in your subclass and use it to register your commands and event * handlers. @@ -139,160 +173,6 @@ public abstract class Extension : KoinComponent { this.state = state } - /** - * DSL function for easily registering a command. - * - * Use this in your setup function to register a command that may be executed on Discord. - * - * @param body Builder lambda used for setting up the command object. - */ - @ExtensionDSL - public open suspend fun command( - arguments: () -> T, - body: suspend MessageCommand.() -> Unit - ): MessageCommand { - val commandObj = MessageCommand(this, arguments) - body.invoke(commandObj) - - return command(commandObj) - } - - /** - * DSL function for easily registering a command, without arguments. - * - * Use this in your setup function to register a command that may be executed on Discord. - * - * @param body Builder lambda used for setting up the command object. - */ - @ExtensionDSL - public open suspend fun command( - body: suspend MessageCommand.() -> Unit - ): MessageCommand { - val commandObj = MessageCommand(this) - body.invoke(commandObj) - - return command(commandObj) - } - - /** - * Function for registering a custom command object. - * - * You can use this if you have a custom command subclass you need to register. - * - * @param commandObj MessageCommand object to register. - */ - public open suspend fun command(commandObj: MessageCommand): MessageCommand { - try { - commandObj.validate() - messageCommandsRegistry.add(commandObj) - commands.add(commandObj) - } catch (e: CommandRegistrationException) { - logger.error(e) { "Failed to register command - $e" } - } catch (e: InvalidCommandException) { - logger.error(e) { "Failed to register command - $e" } - } - - return commandObj - } - - /** - * DSL function for easily registering a slash command, with arguments. - * - * Use this in your setup function to register a slash command that may be executed on Discord. - * - * @param arguments Arguments builder (probably a reference to the class constructor). - * @param body Builder lambda used for setting up the slash command object. - */ - @ExtensionDSL - public open suspend fun slashCommand( - arguments: () -> T, - body: suspend SlashCommand.() -> Unit - ): SlashCommand { - val commandObj = SlashCommand(this, arguments) - body.invoke(commandObj) - - return slashCommand(commandObj) - } - - /** - * DSL function for easily registering a slash command, without arguments. - * - * Use this in your setup function to register a slash command that may be executed on Discord. - * - * @param body Builder lambda used for setting up the slash command object. - */ - @ExtensionDSL - public open suspend fun slashCommand( - body: suspend SlashCommand.() -> Unit - ): SlashCommand { - val commandObj = SlashCommand(this, null) - body.invoke(commandObj) - - return slashCommand(commandObj) - } - - /** - * Function for registering a custom slash command object. - * - * You can use this if you have a custom slash command subclass you need to register. - * - * @param commandObj SlashCommand object to register. - */ - public open suspend fun slashCommand( - commandObj: SlashCommand - ): SlashCommand { - try { - commandObj.validate() - slashCommands.add(commandObj) - slashCommandsRegistry.register(commandObj, commandObj.guild) - } catch (e: CommandRegistrationException) { - logger.error(e) { "Failed to register slash command - $e" } - } catch (e: InvalidCommandException) { - logger.error(e) { "Failed to register slash command - $e" } - } - - return commandObj - } - - /** - * DSL function for easily registering a grouped command. - * - * Use this in your setup function to register a group of commands. - * - * The body of the grouped command will be executed if there is no - * matching subcommand. - * - * @param body Builder lambda used for setting up the command object. - */ - @ExtensionDSL - public open suspend fun group( - arguments: () -> T, - body: suspend GroupCommand.() -> Unit - ): GroupCommand { - val commandObj = GroupCommand(this, arguments) - body.invoke(commandObj) - - return command(commandObj) as GroupCommand - } - - /** - * DSL function for easily registering a grouped command, without its own arguments. - * - * Use this in your setup function to register a group of commands. - * - * The body of the grouped command will be executed if there is no - * matching subcommand. - * - * @param body Builder lambda used for setting up the command object. - */ - @ExtensionDSL - public open suspend fun group(body: suspend GroupCommand.() -> Unit): GroupCommand { - val commandObj = GroupCommand(this) - body.invoke(commandObj) - - return command(commandObj) as GroupCommand - } - /** * If you need to, override this function and use it to clean up your extension when * it's unloaded. @@ -301,7 +181,7 @@ public abstract class Extension : KoinComponent { * handled for you. */ public open suspend fun unload() { - logger.debug { "Unload function not overridden." } + logger.trace { "Unload function not overridden." } } /** @@ -331,12 +211,12 @@ public abstract class Extension : KoinComponent { bot.removeEventHandler(handler) } - for (command in commands) { - messageCommandsRegistry.remove(command) + for (command in chatCommands) { + chatCommandRegistry.remove(command) } eventHandlers.clear() - commands.clear() + chatCommands.clear() if (error != null) { throw error @@ -344,86 +224,4 @@ public abstract class Extension : KoinComponent { this.setState(ExtensionState.UNLOADED) } - - /** - * DSL function for easily registering an event handler. - * - * Use this in your setup function to register an event handler that reacts to a given event. - * - * @param body Builder lambda used for setting up the event handler object. - */ - public suspend inline fun event( - noinline body: suspend EventHandler.() -> Unit - ): EventHandler { - val eventHandler = EventHandler(this) - val logger = KotlinLogging.logger {} - - body.invoke(eventHandler) - - try { - eventHandler.validate() - eventHandler.job = bot.addEventHandler(eventHandler) - - eventHandlers.add(eventHandler) - } catch (e: EventHandlerRegistrationException) { - logger.error(e) { "Failed to register event handler - $e" } - } catch (e: InvalidEventHandlerException) { - logger.error(e) { "Failed to register event handler - $e" } - } - - return eventHandler - } - - /** - * Define a check which must pass for the command to be executed. This check will be applied to all - * slash commands in this extension. - * - * A command may have multiple checks - all checks must pass for the command to be executed. - * Checks will be run in the order that they're defined. - * - * This function can be used DSL-style with a given body, or it can be passed one or more - * predefined functions. See the samples for more information. - * - * @param checks Checks to apply to all slash commands in this extension. - */ - public open fun slashCheck(vararg checks: Check) { - checks.forEach { slashCommandChecks.add(it) } - } - - /** - * Overloaded check function to allow for DSL syntax. - * - * @param check Check to apply to all slash commands in this extension. - */ - @ExtensionDSL - public open fun slashCheck(check: Check) { - slashCommandChecks.add(check) - } - - /** - * Define a check which must pass for the command to be executed. This check will be applied to all commands - * in this extension. - * - * A command may have multiple checks - all checks must pass for the command to be executed. - * Checks will be run in the order that they're defined. - * - * This function can be used DSL-style with a given body, or it can be passed one or more - * predefined functions. See the samples for more information. - * - * @param checks Checks to apply to all commands in this extension. - */ - @ExtensionDSL - public open fun check(vararg checks: Check) { - checks.forEach { commandChecks.add(it) } - } - - /** - * Overloaded check function to allow for DSL syntax. - * - * @param check Check to apply to all commands in this extension. - */ - @ExtensionDSL - public open fun check(check: Check) { - commandChecks.add(check) - } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/_Commands.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/_Commands.kt new file mode 100644 index 0000000000..d8d8ae75ac --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/_Commands.kt @@ -0,0 +1,509 @@ +@file:Suppress("StringLiteralDuplication") + +package com.kotlindiscord.kord.extensions.extensions + +import com.kotlindiscord.kord.extensions.CommandRegistrationException +import com.kotlindiscord.kord.extensions.InvalidCommandException +import com.kotlindiscord.kord.extensions.annotations.ExtensionDSL +import com.kotlindiscord.kord.extensions.checks.types.ChatCommandCheck +import com.kotlindiscord.kord.extensions.checks.types.MessageCommandCheck +import com.kotlindiscord.kord.extensions.checks.types.SlashCommandCheck +import com.kotlindiscord.kord.extensions.checks.types.UserCommandCheck +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.application.message.EphemeralMessageCommand +import com.kotlindiscord.kord.extensions.commands.application.message.PublicMessageCommand +import com.kotlindiscord.kord.extensions.commands.application.slash.EphemeralSlashCommand +import com.kotlindiscord.kord.extensions.commands.application.slash.PublicSlashCommand +import com.kotlindiscord.kord.extensions.commands.application.user.EphemeralUserCommand +import com.kotlindiscord.kord.extensions.commands.application.user.PublicUserCommand +import com.kotlindiscord.kord.extensions.commands.chat.ChatCommand +import com.kotlindiscord.kord.extensions.commands.chat.ChatGroupCommand +import dev.kord.gateway.Intent +import mu.KotlinLogging + +private val logger = KotlinLogging.logger {} + +// region: Message commands + +/** + * Define a check which must pass for a message command to be executed. This check will be applied to all + * message commands in this extension. + * + * A message command may have multiple checks - all checks must pass for the command to be executed. + * Checks will be run in the order that they're defined. + * + * This function can be used DSL-style with a given body, or it can be passed one or more + * predefined functions. See the samples for more information. + * + * @param checks Checks to apply to all slash commands. + */ +public fun Extension.messageCommandCheck(vararg checks: MessageCommandCheck) { + checks.forEach { messageCommandChecks.add(it) } +} + +/** + * Overloaded message command check function to allow for DSL syntax. + * + * @param check Check to apply to all slash commands. + */ +public fun Extension.messageCommandCheck(check: MessageCommandCheck) { + messageCommandChecks.add(check) +} + +/** Register an ephemeral message command, DSL-style. **/ +@ExtensionDSL +public suspend fun Extension.ephemeralMessageCommand( + body: suspend EphemeralMessageCommand.() -> Unit +): EphemeralMessageCommand { + val commandObj = EphemeralMessageCommand(this) + body(commandObj) + + return ephemeralMessageCommand(commandObj) +} + +/** Register a custom instance of an ephemeral message command. **/ +@ExtensionDSL +public suspend fun Extension.ephemeralMessageCommand( + commandObj: EphemeralMessageCommand +): EphemeralMessageCommand { + try { + commandObj.validate() + messageCommands.add(commandObj) + } catch (e: CommandRegistrationException) { + logger.error(e) { "Failed to register message command ${commandObj.name} - $e" } + } catch (e: InvalidCommandException) { + logger.error(e) { "Failed to register message command ${commandObj.name} - $e" } + } + + if (applicationCommandRegistry.initialised) { + applicationCommandRegistry.register(commandObj) + } + + return commandObj +} + +/** Register a public message command, DSL-style. **/ +@ExtensionDSL +public suspend fun Extension.publicMessageCommand( + body: suspend PublicMessageCommand.() -> Unit +): PublicMessageCommand { + val commandObj = PublicMessageCommand(this) + body(commandObj) + + return publicMessageCommand(commandObj) +} + +/** Register a custom instance of a public message command. **/ +@ExtensionDSL +public suspend fun Extension.publicMessageCommand( + commandObj: PublicMessageCommand +): PublicMessageCommand { + try { + commandObj.validate() + messageCommands.add(commandObj) + } catch (e: CommandRegistrationException) { + logger.error(e) { "Failed to register message command ${commandObj.name} - $e" } + } catch (e: InvalidCommandException) { + logger.error(e) { "Failed to register message command ${commandObj.name} - $e" } + } + + if (applicationCommandRegistry.initialised) { + applicationCommandRegistry.register(commandObj) + } + + return commandObj +} + +// endregion + +// region: Slash commands (Generic) + +/** + * Define a check which must pass for a slash command to be executed. This check will be applied to all + * slash commands in this extension. + * + * A slash command may have multiple checks - all checks must pass for the command to be executed. + * Checks will be run in the order that they're defined. + * + * This function can be used DSL-style with a given body, or it can be passed one or more + * predefined functions. See the samples for more information. + * + * @param checks Checks to apply to all slash commands. + */ +public fun Extension.slashCommandCheck(vararg checks: SlashCommandCheck) { + checks.forEach { slashCommandChecks.add(it) } +} + +/** + * Overloaded slash command check function to allow for DSL syntax. + * + * @param check Check to apply to all slash commands. + */ +public fun Extension.slashCommandCheck(check: SlashCommandCheck) { + slashCommandChecks.add(check) +} + +// endregion + +// region: Slash commands (Ephemeral) + +/** + * DSL function for easily registering an ephemeral slash command, with arguments. + * + * Use this in your setup function to register a slash command that may be executed on Discord. + * + * @param arguments Arguments builder (probably a reference to the class constructor). + * @param body Builder lambda used for setting up the slash command object. + */ +@ExtensionDSL +public suspend fun Extension.ephemeralSlashCommand( + arguments: () -> T, + body: suspend EphemeralSlashCommand.() -> Unit +): EphemeralSlashCommand { + val commandObj = EphemeralSlashCommand(this, arguments, null, null) + body(commandObj) + + return ephemeralSlashCommand(commandObj) +} + +/** + * Function for registering a custom ephemeral slash command object. + * + * You can use this if you have a custom ephemeral slash command subclass you need to register. + * + * @param commandObj EphemeralSlashCommand object to register. + */ +@ExtensionDSL +public suspend fun Extension.ephemeralSlashCommand( + commandObj: EphemeralSlashCommand +): EphemeralSlashCommand { + try { + commandObj.validate() + slashCommands.add(commandObj) + } catch (e: CommandRegistrationException) { + logger.error(e) { "Failed to register subcommand - $e" } + } catch (e: InvalidCommandException) { + logger.error(e) { "Failed to register subcommand - $e" } + } + + if (applicationCommandRegistry.initialised) { + applicationCommandRegistry.register(commandObj) + } + + return commandObj +} + +/** + * DSL function for easily registering an ephemeral slash command, without arguments. + * + * Use this in your setup function to register a slash command that may be executed on Discord. + * + * @param body Builder lambda used for setting up the slash command object. + */ +@ExtensionDSL +public suspend fun Extension.ephemeralSlashCommand( + body: suspend EphemeralSlashCommand.() -> Unit +): EphemeralSlashCommand { + val commandObj = EphemeralSlashCommand(this, null, null, null) + body(commandObj) + + return ephemeralSlashCommand(commandObj) +} + +// endregion + +// region: Slash commands (Public) + +/** + * DSL function for easily registering a public slash command, with arguments. + * + * Use this in your setup function to register a slash command that may be executed on Discord. + * + * @param arguments Arguments builder (probably a reference to the class constructor). + * @param body Builder lambda used for setting up the slash command object. + */ +@ExtensionDSL +public suspend fun Extension.publicSlashCommand( + arguments: () -> T, + body: suspend PublicSlashCommand.() -> Unit +): PublicSlashCommand { + val commandObj = PublicSlashCommand(this, arguments, null, null) + body(commandObj) + + return publicSlashCommand(commandObj) +} + +/** + * Function for registering a custom public slash command object. + * + * You can use this if you have a custom public slash command subclass you need to register. + * + * @param commandObj PublicSlashCommand object to register. + */ +@ExtensionDSL +public suspend fun Extension.publicSlashCommand( + commandObj: PublicSlashCommand +): PublicSlashCommand { + try { + commandObj.validate() + slashCommands.add(commandObj) + } catch (e: CommandRegistrationException) { + logger.error(e) { "Failed to register subcommand - $e" } + } catch (e: InvalidCommandException) { + logger.error(e) { "Failed to register subcommand - $e" } + } + + if (applicationCommandRegistry.initialised) { + applicationCommandRegistry.register(commandObj) + } + + return commandObj +} + +/** + * DSL function for easily registering a public slash command, without arguments. + * + * Use this in your setup function to register a slash command that may be executed on Discord. + * + * @param body Builder lambda used for setting up the slash command object. + */ +@ExtensionDSL +public suspend fun Extension.publicSlashCommand( + body: suspend PublicSlashCommand.() -> Unit +): PublicSlashCommand { + val commandObj = PublicSlashCommand(this, null, null, null) + body(commandObj) + + return publicSlashCommand(commandObj) +} + +// endregion + +// region: User commands + +/** + * Define a check which must pass for a user command to be executed. This check will be applied to all + * user commands in this extension. + * + * A user command may have multiple checks - all checks must pass for the command to be executed. + * Checks will be run in the order that they're defined. + * + * This function can be used DSL-style with a given body, or it can be passed one or more + * predefined functions. See the samples for more information. + * + * @param checks Checks to apply to all slash commands. + */ +public fun Extension.userCommandCheck(vararg checks: UserCommandCheck) { + checks.forEach { userCommandChecks.add(it) } +} + +/** + * Overloaded user command check function to allow for DSL syntax. + * + * @param check Check to apply to all slash commands. + */ +public fun Extension.userCommandCheck(check: UserCommandCheck) { + userCommandChecks.add(check) +} + +/** Register an ephemeral user command, DSL-style. **/ +@ExtensionDSL +public suspend fun Extension.ephemeralUserCommand( + body: suspend EphemeralUserCommand.() -> Unit +): EphemeralUserCommand { + val commandObj = EphemeralUserCommand(this) + body(commandObj) + + return ephemeralUserCommand(commandObj) +} + +/** Register a custom instance of an ephemeral user command. **/ +@ExtensionDSL +public suspend fun Extension.ephemeralUserCommand( + commandObj: EphemeralUserCommand +): EphemeralUserCommand { + try { + commandObj.validate() + userCommands.add(commandObj) + } catch (e: CommandRegistrationException) { + logger.error(e) { "Failed to register message command ${commandObj.name} - $e" } + } catch (e: InvalidCommandException) { + logger.error(e) { "Failed to register message command ${commandObj.name} - $e" } + } + + if (applicationCommandRegistry.initialised) { + applicationCommandRegistry.register(commandObj) + } + + return commandObj +} + +/** Register a public user command, DSL-style. **/ +@ExtensionDSL +public suspend fun Extension.publicUserCommand( + body: suspend PublicUserCommand.() -> Unit +): PublicUserCommand { + val commandObj = PublicUserCommand(this) + body(commandObj) + + return publicUserCommand(commandObj) +} + +/** Register a custom instance of a public user command. **/ +@ExtensionDSL +public suspend fun Extension.publicUserCommand( + commandObj: PublicUserCommand +): PublicUserCommand { + try { + commandObj.validate() + userCommands.add(commandObj) + } catch (e: CommandRegistrationException) { + logger.error(e) { "Failed to register message command ${commandObj.name} - $e" } + } catch (e: InvalidCommandException) { + logger.error(e) { "Failed to register message command ${commandObj.name} - $e" } + } + + if (applicationCommandRegistry.initialised) { + applicationCommandRegistry.register(commandObj) + } + + return commandObj +} + +// endregion + +// region: Chat commands + +/** + * Define a check which must pass for the command to be executed. This check will be applied to all commands + * in this extension. + * + * A command may have multiple checks - all checks must pass for the command to be executed. + * Checks will be run in the order that they're defined. + * + * This function can be used DSL-style with a given body, or it can be passed one or more + * predefined functions. See the samples for more information. + * + * @param checks Checks to apply to all commands in this extension. + */ +@ExtensionDSL +public fun Extension.chatCommandCheck(vararg checks: ChatCommandCheck) { + checks.forEach { chatCommandChecks.add(it) } +} + +/** + * Overloaded check function to allow for DSL syntax. + * + * @param check Check to apply to all commands in this extension. + */ +@ExtensionDSL +public fun Extension.chatCommandCheck(check: ChatCommandCheck) { + chatCommandChecks.add(check) +} + +/** + * DSL function for easily registering a command. + * + * Use this in your setup function to register a command that may be executed on Discord. + * + * @param body Builder lambda used for setting up the command object. + */ +@ExtensionDSL +public suspend fun Extension.chatCommand( + arguments: () -> T, + body: suspend ChatCommand.() -> Unit +): ChatCommand { + val commandObj = ChatCommand(this, arguments) + body.invoke(commandObj) + + return chatCommand(commandObj) +} + +/** + * DSL function for easily registering a command, without arguments. + * + * Use this in your setup function to register a command that may be executed on Discord. + * + * @param body Builder lambda used for setting up the command object. + */ +@ExtensionDSL +public suspend fun Extension.chatCommand( + body: suspend ChatCommand.() -> Unit +): ChatCommand { + val commandObj = ChatCommand(this) + body.invoke(commandObj) + + return chatCommand(commandObj) +} + +/** + * Function for registering a custom command object. + * + * You can use this if you have a custom command subclass you need to register. + * + * @param commandObj MessageContentCommand object to register. + */ +@ExtensionDSL +public fun Extension.chatCommand( + commandObj: ChatCommand +): ChatCommand { + try { + commandObj.validate() + chatCommandRegistry.add(commandObj) + chatCommands.add(commandObj) + } catch (e: CommandRegistrationException) { + logger.error(e) { "Failed to register command - $e" } + } catch (e: InvalidCommandException) { + logger.error(e) { "Failed to register command - $e" } + } + + if (chatCommandRegistry.enabled) { // Don't add the intents if they won't be used + intents += Intent.DirectMessages + intents += Intent.GuildMessages + } + + return commandObj +} + +/** + * DSL function for easily registering a grouped command. + * + * Use this in your setup function to register a group of commands. + * + * The body of the grouped command will be executed if there is no + * matching subcommand. + * + * @param body Builder lambda used for setting up the command object. + */ +@ExtensionDSL +public suspend fun Extension.chatGroupCommand( + arguments: () -> T, + body: suspend ChatGroupCommand.() -> Unit +): ChatGroupCommand { + val commandObj = ChatGroupCommand(this, arguments) + body.invoke(commandObj) + + return chatCommand(commandObj) as ChatGroupCommand +} + +/** + * DSL function for easily registering a grouped command, without its own arguments. + * + * Use this in your setup function to register a group of commands. + * + * The body of the grouped command will be executed if there is no + * matching subcommand. + * + * @param body Builder lambda used for setting up the command object. + */ +@ExtensionDSL +public suspend fun Extension.chatGroupCommand( + body: suspend ChatGroupCommand.() -> Unit +): ChatGroupCommand { + val commandObj = ChatGroupCommand(this) + body.invoke(commandObj) + + return chatCommand(commandObj) as ChatGroupCommand +} + +// endregion diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/_Events.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/_Events.kt new file mode 100644 index 0000000000..e7fb2eeb01 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/_Events.kt @@ -0,0 +1,47 @@ +package com.kotlindiscord.kord.extensions.extensions + +import com.kotlindiscord.kord.extensions.EventHandlerRegistrationException +import com.kotlindiscord.kord.extensions.InvalidEventHandlerException +import com.kotlindiscord.kord.extensions.events.EventHandler +import dev.kord.core.enableEvent +import dev.kord.core.event.Event +import dev.kord.gateway.Intents +import mu.KotlinLogging + +/** + * DSL function for easily registering an event handler. + * + * Use this in your setup function to register an event handler that reacts to a given event. + * + * @param body Builder lambda used for setting up the event handler object. + */ +public suspend inline fun Extension.event( + noinline body: suspend EventHandler.() -> Unit +): EventHandler { + val eventHandler = EventHandler(this) + val logger = KotlinLogging.logger {} + + body.invoke(eventHandler) + + try { + eventHandler.validate() + + eventHandler.listenerRegistrationCallable = { + eventHandler.job = bot.registerListenerForHandler(eventHandler) + } + + bot.addEventHandler(eventHandler) + eventHandlers.add(eventHandler) + } catch (e: EventHandlerRegistrationException) { + logger.error(e) { "Failed to register event handler - $e" } + } catch (e: InvalidEventHandlerException) { + logger.error(e) { "Failed to register event handler - $e" } + } + + val fakeBuilder = Intents.IntentsBuilder() + + fakeBuilder.enableEvent() + intents += fakeBuilder.flags().values + + return eventHandler +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/base/HelpProvider.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/base/HelpProvider.kt index a43515a23c..be39f1ccaa 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/base/HelpProvider.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/base/HelpProvider.kt @@ -1,9 +1,9 @@ package com.kotlindiscord.kord.extensions.extensions.base -import com.kotlindiscord.kord.extensions.commands.MessageCommand -import com.kotlindiscord.kord.extensions.commands.MessageCommandContext -import com.kotlindiscord.kord.extensions.commands.MessageCommandRegistry -import com.kotlindiscord.kord.extensions.commands.parser.Arguments +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.chat.ChatCommand +import com.kotlindiscord.kord.extensions.commands.chat.ChatCommandContext +import com.kotlindiscord.kord.extensions.commands.chat.ChatCommandRegistry import com.kotlindiscord.kord.extensions.pagination.BasePaginator import com.kotlindiscord.kord.extensions.utils.getKoin import dev.kord.core.event.message.MessageCreateEvent @@ -34,7 +34,7 @@ public interface HelpProvider { public suspend fun formatCommandHelp( prefix: String, event: MessageCreateEvent, - command: MessageCommand, + command: ChatCommand, longDescription: Boolean = false ): Triple @@ -51,11 +51,11 @@ public interface HelpProvider { * description, and the command's argument list. */ public suspend fun formatCommandHelp( - context: MessageCommandContext<*>, - command: MessageCommand, + context: ChatCommandContext<*>, + command: ChatCommand, longDescription: Boolean = false ): Triple { - val prefix = getKoin().get().getPrefix(context.event) + val prefix = getKoin().get().getPrefix(context.event) return formatCommandHelp(prefix, context.event, command, longDescription) } @@ -63,12 +63,12 @@ public interface HelpProvider { /** * Gather all available commands (with passing checks) from the bot, and return them. */ - public suspend fun gatherCommands(event: MessageCreateEvent): List> + public suspend fun gatherCommands(event: MessageCreateEvent): List> /** * Return the [MessageCommand] specified in the arguments, or `null` if it can't be found (or the checks fail). */ - public suspend fun getCommand(event: MessageCreateEvent, args: List): MessageCommand? + public suspend fun getCommand(event: MessageCreateEvent, args: List): ChatCommand? /** * Given an event, prefix and argument list, attempt to find the command represented by the arguments and return @@ -100,10 +100,10 @@ public interface HelpProvider { * @return Paginator containing the command's help, or an error message. */ public suspend fun getCommandHelpPaginator( - context: MessageCommandContext<*>, + context: ChatCommandContext<*>, args: List ): BasePaginator { - val prefix = getKoin().get().getPrefix(context.event) + val prefix = getKoin().get().getPrefix(context.event) return getCommandHelpPaginator(context.event, prefix, args) } @@ -126,7 +126,7 @@ public interface HelpProvider { public suspend fun getCommandHelpPaginator( event: MessageCreateEvent, prefix: String, - command: MessageCommand? + command: ChatCommand? ): BasePaginator /** @@ -144,10 +144,10 @@ public interface HelpProvider { * @return Paginator containing the command's help, or an error message. */ public suspend fun getCommandHelpPaginator( - context: MessageCommandContext<*>, - command: MessageCommand? + context: ChatCommandContext<*>, + command: ChatCommand? ): BasePaginator { - val prefix = getKoin().get().getPrefix(context.event) + val prefix = getKoin().get().getPrefix(context.event) return getCommandHelpPaginator(context.event, prefix, command) } @@ -183,8 +183,8 @@ public interface HelpProvider { * * @return BasePaginator containing help information for all loaded commands with passing checks. */ - public suspend fun getMainHelpPaginator(context: MessageCommandContext<*>): BasePaginator { - val prefix = getKoin().get().getPrefix(context.event) + public suspend fun getMainHelpPaginator(context: ChatCommandContext<*>): BasePaginator { + val prefix = getKoin().get().getPrefix(context.event) return getMainHelpPaginator(context.event, prefix) } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/impl/HelpExtension.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/impl/HelpExtension.kt index e2114892bd..6788d7f612 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/impl/HelpExtension.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/impl/HelpExtension.kt @@ -3,11 +3,12 @@ package com.kotlindiscord.kord.extensions.extensions.impl import com.kotlindiscord.kord.extensions.builders.ExtensibleBotBuilder -import com.kotlindiscord.kord.extensions.commands.* +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.chat.* import com.kotlindiscord.kord.extensions.commands.converters.impl.stringList -import com.kotlindiscord.kord.extensions.commands.parser.Arguments import com.kotlindiscord.kord.extensions.extensions.Extension import com.kotlindiscord.kord.extensions.extensions.base.HelpProvider +import com.kotlindiscord.kord.extensions.extensions.chatCommand import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider import com.kotlindiscord.kord.extensions.pagination.BasePaginator import com.kotlindiscord.kord.extensions.pagination.MessageButtonPaginator @@ -43,7 +44,7 @@ public class HelpExtension : HelpProvider, Extension() { public val translationsProvider: TranslationsProvider by inject() /** Message command registry. **/ - public val messageCommandsRegistry: MessageCommandRegistry by inject() + public val messageCommandsRegistry: ChatCommandRegistry by inject() /** Bot settings. **/ public val botSettings: ExtensibleBotBuilder by inject() @@ -53,7 +54,7 @@ public class HelpExtension : HelpProvider, Extension() { botSettings.extensionsBuilder.helpExtensionBuilder override suspend fun setup() { - command(::HelpArguments) { + chatCommand(::HelpArguments) { name = "extensions.help.commandName" aliasKey = "extensions.help.commandAliases" description = "extensions.help.commandDescription" @@ -148,7 +149,6 @@ public class HelpExtension : HelpProvider, Extension() { } return MessageButtonPaginator( - extension = this, keepEmbed = settings.deletePaginatorOnTimeout.not(), locale = locale, owner = event.message.author, @@ -174,13 +174,16 @@ public class HelpExtension : HelpProvider, Extension() { args: List ): BasePaginator = getCommandHelpPaginator(event, prefix, getCommand(event, args)) - override suspend fun getCommandHelpPaginator(context: MessageCommandContext<*>, args: List): BasePaginator = + override suspend fun getCommandHelpPaginator( + context: ChatCommandContext<*>, + args: List + ): BasePaginator = getCommandHelpPaginator(context, getCommand(context.event, args)) override suspend fun getCommandHelpPaginator( event: MessageCreateEvent, prefix: String, - command: MessageCommand? + command: ChatCommand? ): BasePaginator { val pages = Pages(COMMANDS_GROUP) val locale = event.getLocale() @@ -206,9 +209,9 @@ public class HelpExtension : HelpProvider, Extension() { } else { val (openingLine, desc, arguments) = formatCommandHelp(prefix, event, command, longDescription = true) - val commandName = if (command is MessageSubCommand) { + val commandName = if (command is ChatSubCommand) { command.getFullTranslatedName(locale) - } else if (command is GroupCommand) { + } else if (command is ChatGroupCommand) { command.getFullTranslatedName(locale) } else { command.getTranslatedName(locale) @@ -231,7 +234,6 @@ public class HelpExtension : HelpProvider, Extension() { } return MessageButtonPaginator( - extension = this, keepEmbed = settings.deletePaginatorOnTimeout.not(), locale = locale, owner = event.message.author, @@ -251,7 +253,7 @@ public class HelpExtension : HelpProvider, Extension() { } } - override suspend fun gatherCommands(event: MessageCreateEvent): List> = + override suspend fun gatherCommands(event: MessageCreateEvent): List> = messageCommandsRegistry.commands .filter { !it.hidden && it.enabled && it.runChecks(event, false) } .sortedBy { it.name } @@ -259,15 +261,15 @@ public class HelpExtension : HelpProvider, Extension() { override suspend fun formatCommandHelp( prefix: String, event: MessageCreateEvent, - command: MessageCommand, + command: ChatCommand, longDescription: Boolean ): Triple { val locale = event.getLocale() val defaultLocale = botSettings.i18nBuilder.defaultLocale - val commandName = if (command is MessageSubCommand) { + val commandName = if (command is ChatSubCommand) { command.getFullTranslatedName(locale) - } else if (command is GroupCommand) { + } else if (command is ChatGroupCommand) { command.getFullTranslatedName(locale) } else { command.getTranslatedName(locale) @@ -307,7 +309,7 @@ public class HelpExtension : HelpProvider, Extension() { } } - if (command is GroupCommand) { + if (command is ChatGroupCommand) { val subCommands = command.commands.filter { it.runChecks(event, false) } if (subCommands.isNotEmpty()) { @@ -379,7 +381,10 @@ public class HelpExtension : HelpProvider, Extension() { return Triple(openingLine.trim('\n'), description.trim('\n'), arguments.trim('\n')) } - override suspend fun getCommand(event: MessageCreateEvent, args: List): MessageCommand? { + override suspend fun getCommand( + event: MessageCreateEvent, + args: List + ): ChatCommand? { val firstArg = args.first() var command = messageCommandsRegistry.getCommand(firstArg, event) @@ -388,8 +393,8 @@ public class HelpExtension : HelpProvider, Extension() { } args.drop(1).forEach { - if (command is GroupCommand) { - val gc = command as GroupCommand + if (command is ChatGroupCommand) { + val gc = command as ChatGroupCommand command = if (gc.runChecks(event, false)) { gc.getCommand(it, event) diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/impl/SentryExtension.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/impl/SentryExtension.kt index b755d52fd0..e4cb42a79a 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/impl/SentryExtension.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/extensions/impl/SentryExtension.kt @@ -1,15 +1,17 @@ -@file:OptIn(KordPreview::class, TranslationNotSupported::class) +@file:OptIn(KordPreview::class) package com.kotlindiscord.kord.extensions.extensions.impl import com.kotlindiscord.kord.extensions.builders.ExtensibleBotBuilder +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.converters.impl.coalescedString import com.kotlindiscord.kord.extensions.commands.converters.impl.string -import com.kotlindiscord.kord.extensions.commands.parser.Arguments -import com.kotlindiscord.kord.extensions.commands.slash.TranslationNotSupported import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.extensions.chatCommand +import com.kotlindiscord.kord.extensions.extensions.ephemeralSlashCommand import com.kotlindiscord.kord.extensions.sentry.SentryAdapter import com.kotlindiscord.kord.extensions.sentry.sentryId +import com.kotlindiscord.kord.extensions.types.respond import com.kotlindiscord.kord.extensions.utils.respond import dev.kord.common.annotation.KordPreview import io.sentry.Sentry @@ -26,25 +28,25 @@ public class SentryExtension : Extension() { override val name: String = "sentry" /** Sentry adapter, for easy access to Sentry functions. **/ - public val sentry: SentryAdapter by inject() + public val sentryAdapter: SentryAdapter by inject() /** Bot settings. **/ public val botSettings: ExtensibleBotBuilder by inject() /** Sentry extension settings, from the bot builder. **/ - public val settings: ExtensibleBotBuilder.ExtensionsBuilder.SentryExtensionBuilder = + public val sentrySettings: ExtensibleBotBuilder.ExtensionsBuilder.SentryExtensionBuilder = botSettings.extensionsBuilder.sentryExtensionBuilder @Suppress("StringLiteralDuplication") // It's the command name override suspend fun setup() { - if (sentry.enabled) { - slashCommand(::FeedbackSlashArgs) { + if (sentryAdapter.enabled) { + ephemeralSlashCommand(::FeedbackSlashArgs) { name = "extensions.sentry.commandName" description = "extensions.sentry.commandDescription.short" action { - if (!sentry.hasEventId(arguments.id)) { - ephemeralFollowUp { + if (!sentryAdapter.hasEventId(arguments.id)) { + respond { content = translate("extensions.sentry.error.invalidId") } @@ -59,25 +61,25 @@ public class SentryExtension : Extension() { ) Sentry.captureUserFeedback(feedback) - sentry.removeEventId(arguments.id) + sentryAdapter.removeEventId(arguments.id) - ephemeralFollowUp { + respond { content = translate("extensions.sentry.thanks") } } } - command(::FeedbackMessageArgs) { + chatCommand(::FeedbackMessageArgs) { name = "extensions.sentry.commandName" description = "extensions.sentry.commandDescription.long" aliasKey = "extensions.sentry.commandAliases" action { - if (!sentry.hasEventId(arguments.id)) { + if (!sentryAdapter.hasEventId(arguments.id)) { message.respond( translate("extensions.sentry.error.invalidId"), - pingInReply = settings.pingInReply + pingInReply = sentrySettings.pingInReply ) return@action @@ -92,7 +94,7 @@ public class SentryExtension : Extension() { ) Sentry.captureUserFeedback(feedback) - sentry.removeEventId(arguments.id) + sentryAdapter.removeEventId(arguments.id) message.respond( translate("extensions.sentry.thanks") diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/i18n/ResourceBundleTranslations.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/i18n/ResourceBundleTranslations.kt index 7d4886ca26..31f7707104 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/i18n/ResourceBundleTranslations.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/i18n/ResourceBundleTranslations.kt @@ -26,12 +26,14 @@ public class ResourceBundleTranslations( ) private val bundles: MutableMap, ResourceBundle> = mutableMapOf() + private val overrideBundles: MutableMap, ResourceBundle> = mutableMapOf() public override fun hasKey(key: String, locale: Locale, bundleName: String?): Boolean { return try { - val bundleObj = getBundle(locale, bundleName) + val (bundle, _) = getBundles(locale, bundleName) - bundleObj.keys.toList().contains(key) + // Overrides aren't for adding keys, so we don't check them + bundle.keys.toList().contains(key) } catch (e: MissingResourceException) { logger.trace { "Failed to get bundle $bundleName for locale $locale" } @@ -40,7 +42,7 @@ public class ResourceBundleTranslations( } @Throws(MissingResourceException::class) - private fun getBundle(locale: Locale, bundleName: String?): ResourceBundle { + private fun getBundles(locale: Locale, bundleName: String?): Pair { var bundle = "translations." + (bundleName ?: KORDEX_KEY) if (bundle.count { it == '.' } < 2) { @@ -49,15 +51,28 @@ public class ResourceBundleTranslations( val bundleKey = bundle to locale - logger.trace { "Getting bundle $bundleKey for locale $locale" } - bundles[bundleKey] = bundles[bundleKey] ?: ResourceBundle.getBundle(bundle, locale, Control) + if (bundles[bundleKey] == null) { + logger.trace { "Getting bundle $bundle for locale $locale" } + bundles[bundleKey] = ResourceBundle.getBundle(bundle, locale, Control) - return bundles[bundleKey]!! + try { + val overrideBundle = bundle + "_override" + + logger.trace { "Getting override bundle $overrideBundle for locale $locale" } + + overrideBundles[bundleKey] = ResourceBundle.getBundle(overrideBundle, locale, Control) + } catch (e: MissingResourceException) { + logger.trace { "No override bundle found." } + } + } + + return bundles[bundleKey]!! to overrideBundles[bundleKey] } @Throws(MissingResourceException::class) public override fun get(key: String, locale: Locale, bundleName: String?): String { - val result = getBundle(locale, bundleName).getString(key) + val (bundle, overrideBundle) = getBundles(locale, bundleName) + val result = overrideBundle?.getStringOrNull(key) ?: bundle.getString(key) logger.trace { "Result: $key -> $result" } @@ -65,12 +80,16 @@ public class ResourceBundleTranslations( } override fun translate(key: String, locale: Locale, bundleName: String?, replacements: Array): String { - return try { - var string = get(key, locale, bundleName) + var string = try { + get(key, locale, bundleName) + } catch (e: MissingResourceException) { + key + } + return try { if (string == key && bundleName != null) { // Fall through to the default bundle if the key isn't found - logger.trace { "$key not found in bundle $bundleName - falling through to $KORDEX_KEY" } + logger.trace { "'$key' not found in bundle '$bundleName' - falling through to '$KORDEX_KEY'" } string = get(key, locale, KORDEX_KEY) } @@ -79,12 +98,26 @@ public class ResourceBundleTranslations( formatter.format(replacements) } catch (e: MissingResourceException) { - logger.trace { "Unable to find translation for key '$key' in bundle '$bundleName'" } + logger.trace { + if (bundleName == null) { + "Unable to find translation for key '$key' in bundle '$KORDEX_KEY'" + } else { + "Unable to find translation for key '$key' in bundles: '$bundleName', '$KORDEX_KEY'" + } + } key } } + private fun ResourceBundle.getStringOrNull(key: String): String? { + return try { + getString(key) + } catch (e: MissingResourceException) { + null + } + } + private object Control : ResourceBundle.Control() { override fun getFormats(baseName: String?): MutableList { if (baseName == null) { diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/i18n/SupportedLocales.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/i18n/SupportedLocales.kt index c04d790d01..f80d8908c6 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/i18n/SupportedLocales.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/i18n/SupportedLocales.kt @@ -6,7 +6,7 @@ import java.util.* * List of supported locales. These are locales with **merged** translations. * * If you've written a translation, don't try to modify this using reflection or something - instead, contribute it - * back: https://crowdin.com/project/kordex + * back: https://hosted.weblate.org/projects/kord-extensions/main/ */ public object SupportedLocales { public val CHINESE_SIMPLIFIED: Locale = Locale("zh", "cn") @@ -14,37 +14,59 @@ public object SupportedLocales { public val FINNISH: Locale = Locale("fi", "fi") public val FRENCH: Locale = Locale("fr", "fr") public val GERMAN: Locale = Locale("de", "de") + public val POLISH: Locale = Locale("pl", "pl") + public val PORTUGUESE: Locale = Locale("pt", "pt") + public val RUSSIAN: Locale = Locale("ru", "ru") public val ALL_LOCALES: Map = mapOf( - "中文" to CHINESE_SIMPLIFIED, - "汉语" to CHINESE_SIMPLIFIED, - "普通话" to CHINESE_SIMPLIFIED, - "简体中文" to CHINESE_SIMPLIFIED, "chinese" to CHINESE_SIMPLIFIED, "zh" to CHINESE_SIMPLIFIED, "zh_cn" to CHINESE_SIMPLIFIED, + "中文" to CHINESE_SIMPLIFIED, + "普通话" to CHINESE_SIMPLIFIED, + "汉语" to CHINESE_SIMPLIFIED, + "简体中文" to CHINESE_SIMPLIFIED, - "english" to ENGLISH, "en" to ENGLISH, "en_gb" to ENGLISH, "en_us" to ENGLISH, + "english" to ENGLISH, + "fi" to FINNISH, + "fi_fi" to FINNISH, + "finnish" to FINNISH, "suomen kieli" to FINNISH, "suomen" to FINNISH, "suomi" to FINNISH, - "finnish" to FINNISH, - "fi" to FINNISH, - "fi_fi" to FINNISH, - "français" to FRENCH, - "francais" to FRENCH, - "french" to FRENCH, "fr" to FRENCH, "fr_fr" to FRENCH, + "francais" to FRENCH, + "français" to FRENCH, + "french" to FRENCH, - "deutsch" to GERMAN, - "german" to GERMAN, "de" to GERMAN, "de_de" to GERMAN, + "deutsch" to GERMAN, + "german" to GERMAN, + + "portugues" to PORTUGUESE, + "portuguese" to PORTUGUESE, + "português" to PORTUGUESE, + "pt" to PORTUGUESE, + "pt_pt" to PORTUGUESE, + + "pl" to POLISH, + "pl_pl" to POLISH, + "polish" to POLISH, + "polska" to POLISH, + "polskie" to POLISH, + + "ru" to RUSSIAN, + "ru_ru" to RUSSIAN, + "russian" to RUSSIAN, + "русская" to RUSSIAN, + "русскии" to RUSSIAN, + "русский" to RUSSIAN, ) } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/BaseButtonPaginator.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/BaseButtonPaginator.kt index 384a3ba008..fb2fcaae85 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/BaseButtonPaginator.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/BaseButtonPaginator.kt @@ -3,65 +3,74 @@ package com.kotlindiscord.kord.extensions.pagination import com.kotlindiscord.kord.extensions.checks.types.Check -import com.kotlindiscord.kord.extensions.components.Components -import com.kotlindiscord.kord.extensions.components.builders.DisabledButtonBuilder -import com.kotlindiscord.kord.extensions.components.builders.InteractiveButtonBuilder -import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.components.ComponentContainer +import com.kotlindiscord.kord.extensions.components.buttons.DisabledInteractionButton +import com.kotlindiscord.kord.extensions.components.buttons.PublicInteractionButton +import com.kotlindiscord.kord.extensions.components.publicButton +import com.kotlindiscord.kord.extensions.components.types.emoji import com.kotlindiscord.kord.extensions.pagination.pages.Pages import com.kotlindiscord.kord.extensions.utils.capitalizeWords +import com.kotlindiscord.kord.extensions.utils.scheduling.Scheduler +import com.kotlindiscord.kord.extensions.utils.scheduling.Task import dev.kord.common.annotation.KordPreview import dev.kord.common.entity.ButtonStyle +import dev.kord.core.behavior.UserBehavior import dev.kord.core.entity.ReactionEmoji -import dev.kord.core.entity.User -import dev.kord.core.event.interaction.InteractionCreateEvent +import dev.kord.core.event.interaction.ComponentInteractionCreateEvent import java.util.* -/** Last row number. **/ -public const val LAST_ROW: Int = 4 - -/** Second row number. **/ -public const val SECOND_ROW: Int = 1 - /** * Abstract class containing some common functionality needed by interactive button-based paginators. */ public abstract class BaseButtonPaginator( - extension: Extension, pages: Pages, - owner: User? = null, + owner: UserBehavior? = null, timeoutSeconds: Long? = null, keepEmbed: Boolean = true, switchEmoji: ReactionEmoji = if (pages.groups.size == 2) EXPAND_EMOJI else SWITCH_EMOJI, bundle: String? = null, locale: Locale? = null, -) : BasePaginator(extension, pages, owner, timeoutSeconds, keepEmbed, switchEmoji, bundle, locale) { - /** [Components] instance managing the buttons for this paginator. **/ - public abstract var components: Components +) : BasePaginator(pages, owner, timeoutSeconds, keepEmbed, switchEmoji, bundle, locale) { + /** [ComponentContainer] instance managing the buttons for this paginator. **/ + public open var components: ComponentContainer = ComponentContainer() + + /** Scheduler used to schedule the paginator's timeout. **/ + public var scheduler: Scheduler = Scheduler() + + /** Scheduler used to schedule the paginator's timeout. **/ + public var task: Task? = if (timeoutSeconds != null) { + scheduler.schedule(timeoutSeconds) { destroy() } + } else { + null + } + + private val lastRowNumber by lazy { components.rows.size - 1 } + private val secondRowNumber = 1 /** Button builder representing the button that switches to the first page. **/ - public open var firstPageButton: InteractiveButtonBuilder? = null + public open var firstPageButton: PublicInteractionButton? = null /** Button builder representing the button that switches to the previous page. **/ - public open var backButton: InteractiveButtonBuilder? = null + public open var backButton: PublicInteractionButton? = null /** Button builder representing the button that switches to the next page. **/ - public open var nextButton: InteractiveButtonBuilder? = null + public open var nextButton: PublicInteractionButton? = null /** Button builder representing the button that switches to the last page. **/ - public open var lastPageButton: InteractiveButtonBuilder? = null + public open var lastPageButton: PublicInteractionButton? = null /** Button builder representing the button that switches between groups. **/ - public open var switchButton: InteractiveButtonBuilder? = null + public open var switchButton: PublicInteractionButton? = null /** Group-specific buttons, if any. **/ - public open val groupButtons: MutableMap = mutableMapOf() + public open val groupButtons: MutableMap = mutableMapOf() /** Whether it's possible for us to have a row of group-switching buttons. **/ @Suppress("MagicNumber") - public val canUseSwitchingButtons: Boolean = allGroups.size in 3..5 && "" !in allGroups + public val canUseSwitchingButtons: Boolean by lazy { allGroups.size in 3..5 && "" !in allGroups } /** A button-oriented check function that matches based on the [owner] property. **/ - public val defaultCheck: Check = { + public val defaultCheck: Check = { if (!active) { fail() } else if (owner == null) { @@ -73,14 +82,15 @@ public abstract class BaseButtonPaginator( } } - override suspend fun setup() { - components.onTimeout { - destroy() - } + override suspend fun destroy() { + runTimeoutCallbacks() + task?.cancel() + } + override suspend fun setup() { if (pages.size > 1) { // Add navigation buttons... - firstPageButton = components.interactiveButton { + firstPageButton = components.publicButton { deferredAck = true style = ButtonStyle.Secondary @@ -92,10 +102,11 @@ public abstract class BaseButtonPaginator( goToPage(0) send() + task?.restart() } } - backButton = components.interactiveButton { + backButton = components.publicButton { deferredAck = true style = ButtonStyle.Secondary @@ -107,10 +118,11 @@ public abstract class BaseButtonPaginator( previousPage() send() + task?.restart() } } - nextButton = components.interactiveButton { + nextButton = components.publicButton { deferredAck = true style = ButtonStyle.Secondary @@ -122,10 +134,11 @@ public abstract class BaseButtonPaginator( nextPage() send() + task?.restart() } } - lastPageButton = components.interactiveButton { + lastPageButton = components.publicButton { deferredAck = true style = ButtonStyle.Secondary @@ -137,13 +150,14 @@ public abstract class BaseButtonPaginator( goToPage(pages.size - 1) send() + task?.restart() } } } if (pages.size > 1 || !keepEmbed) { // Add the destroy button - components.interactiveButton(LAST_ROW) { + components.publicButton(lastRowNumber) { deferredAck = true check(defaultCheck) @@ -171,7 +185,7 @@ public abstract class BaseButtonPaginator( // Add group-switching buttons allGroups.forEach { group -> - groupButtons[group] = components.interactiveButton(SECOND_ROW) { + groupButtons[group] = components.publicButton(secondRowNumber) { deferredAck = true label = translate(group).capitalizeWords(localeObj) style = ButtonStyle.Secondary @@ -180,13 +194,14 @@ public abstract class BaseButtonPaginator( action { switchGroup(group) + task?.restart() } } } } else { // Add the singular switch button - switchButton = components.interactiveButton(LAST_ROW) { + switchButton = components.publicButton(lastRowNumber) { deferredAck = true check(defaultCheck) @@ -203,12 +218,13 @@ public abstract class BaseButtonPaginator( nextGroup() send() + task?.restart() } } } } - components.sortIntoRows() + components.sort() updateButtons() } @@ -259,7 +275,7 @@ public abstract class BaseButtonPaginator( /** * Convenience function that enables and disables buttons as necessary, depending on the current page number. */ - public fun updateButtons() { + public suspend fun updateButtons() { if (currentPageNum <= 0) { setDisabledButton(firstPageButton) setDisabledButton(backButton) @@ -296,10 +312,10 @@ public abstract class BaseButtonPaginator( } /** Replace an enabled interactive button in [components] with a disabled button of the same ID. **/ - public fun setDisabledButton(oldButton: InteractiveButtonBuilder?): Boolean { + public suspend fun setDisabledButton(oldButton: PublicInteractionButton?): Boolean { oldButton ?: return false - val newButton = DisabledButtonBuilder() + val newButton = DisabledInteractionButton() // Copy properties from the old button newButton.id = oldButton.id @@ -307,35 +323,13 @@ public abstract class BaseButtonPaginator( newButton.partialEmoji = oldButton.partialEmoji newButton.style = oldButton.style - // Find the old button and replace it - components.rows.forEach { row -> - val index = row.indexOfFirst { it is InteractiveButtonBuilder && it.id == oldButton.id } - - if (index != -1) { - row[index] = newButton - - return true - } - } - - return false + return components.replace(oldButton, newButton) } /** Replace a disabled button in [components] with the given interactive button of the same ID.. **/ - public fun setEnabledButton(newButton: InteractiveButtonBuilder?): Boolean { + public suspend fun setEnabledButton(newButton: PublicInteractionButton?): Boolean { newButton ?: return false - // Find the disabled button and replace it - components.rows.forEach { row -> - val index = row.indexOfFirst { it is DisabledButtonBuilder && it.id == newButton.id } - - if (index != -1) { - row[index] = newButton - - return true - } - } - - return false + return components.replace(newButton.id, newButton) } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/BasePaginator.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/BasePaginator.kt index 1631a5b9d5..8c709128c4 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/BasePaginator.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/BasePaginator.kt @@ -1,13 +1,12 @@ package com.kotlindiscord.kord.extensions.pagination import com.kotlindiscord.kord.extensions.ExtensibleBot -import com.kotlindiscord.kord.extensions.extensions.Extension import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider import com.kotlindiscord.kord.extensions.pagination.pages.Page import com.kotlindiscord.kord.extensions.pagination.pages.Pages import dev.kord.core.Kord +import dev.kord.core.behavior.UserBehavior import dev.kord.core.entity.ReactionEmoji -import dev.kord.core.entity.User import dev.kord.rest.builder.message.EmbedBuilder import mu.KotlinLogging import org.koin.core.component.KoinComponent @@ -43,7 +42,6 @@ public val EXPAND_EMOJI: ReactionEmoji.Unicode = ReactionEmoji.Unicode("\u2139\u * * **Note:** This is going to be renamed - it's not ready for use yet! * - * @param extension Extension that this paginator was created for * @param pages Pages object containing this paginator's pages * @param owner Optional paginator owner - setting this will prevent other users from interacting with the paginator * @param timeoutSeconds How long (in seconds) to wait before destroying the paginator, if needed @@ -53,9 +51,8 @@ public val EXPAND_EMOJI: ReactionEmoji.Unicode = ReactionEmoji.Unicode("\u2139\u * @param bundle Translation bundle to use for this paginator */ public abstract class BasePaginator( - public open val extension: Extension, public open val pages: Pages, - public open val owner: User? = null, + public open val owner: UserBehavior? = null, public open val timeoutSeconds: Long? = null, public open val keepEmbed: Boolean = true, public open val switchEmoji: ReactionEmoji = if (pages.groups.size == 2) EXPAND_EMOJI else SWITCH_EMOJI, diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/EphemeralResponsePaginator.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/EphemeralResponsePaginator.kt new file mode 100644 index 0000000000..b1832f3234 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/EphemeralResponsePaginator.kt @@ -0,0 +1,82 @@ +@file:OptIn(KordPreview::class) + +package com.kotlindiscord.kord.extensions.pagination + +import com.kotlindiscord.kord.extensions.pagination.builders.PaginatorBuilder +import com.kotlindiscord.kord.extensions.pagination.pages.Pages +import dev.kord.common.annotation.KordPreview +import dev.kord.core.behavior.UserBehavior +import dev.kord.core.behavior.interaction.EphemeralInteractionResponseBehavior +import dev.kord.core.behavior.interaction.edit +import dev.kord.core.entity.ReactionEmoji +import dev.kord.rest.builder.message.modify.embed +import java.util.* + +/** + * Class representing a button-based paginator that operates by editing the given ephemeral interaction response. + * + * @param interaction Interaction response behaviour to work with. + */ +public class EphemeralResponsePaginator( + pages: Pages, + owner: UserBehavior? = null, + timeoutSeconds: Long? = null, + switchEmoji: ReactionEmoji = if (pages.groups.size == 2) EXPAND_EMOJI else SWITCH_EMOJI, + bundle: String? = null, + locale: Locale? = null, + + public val interaction: EphemeralInteractionResponseBehavior, +) : BaseButtonPaginator(pages, owner, timeoutSeconds, true, switchEmoji, bundle, locale) { + /** Whether this paginator has been set up for the first time. **/ + public var isSetup: Boolean = false + + override suspend fun send() { + if (!isSetup) { + isSetup = true + + setup() + } else { + updateButtons() + } + + interaction.edit { + embed { applyPage() } + + with(this@EphemeralResponsePaginator.components) { + this@edit.applyToMessage() + } + } + } + + override suspend fun destroy() { + if (!active) { + return + } + + active = false + + interaction.edit { + embed { applyPage() } + + this.components = mutableListOf() + } + + super.destroy() + } +} + +/** Convenience function for creating an interaction button paginator from a paginator builder. **/ +@Suppress("FunctionNaming") // Factory function +public fun EphemeralResponsePaginator( + builder: PaginatorBuilder, + interaction: EphemeralInteractionResponseBehavior +): EphemeralResponsePaginator = EphemeralResponsePaginator( + pages = builder.pages, + owner = builder.owner, + timeoutSeconds = builder.timeoutSeconds, + bundle = builder.bundle, + locale = builder.locale, + interaction = interaction, + + switchEmoji = builder.switchEmoji ?: if (builder.pages.groups.size == 2) EXPAND_EMOJI else SWITCH_EMOJI, +) diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/MessageButtonPaginator.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/MessageButtonPaginator.kt index a9df0d45dd..00eab34b18 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/MessageButtonPaginator.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/MessageButtonPaginator.kt @@ -2,17 +2,15 @@ package com.kotlindiscord.kord.extensions.pagination -import com.kotlindiscord.kord.extensions.components.Components -import com.kotlindiscord.kord.extensions.extensions.Extension import com.kotlindiscord.kord.extensions.pagination.builders.PaginatorBuilder import com.kotlindiscord.kord.extensions.pagination.pages.Pages import dev.kord.common.annotation.KordPreview +import dev.kord.core.behavior.UserBehavior import dev.kord.core.behavior.channel.MessageChannelBehavior import dev.kord.core.behavior.channel.createMessage import dev.kord.core.behavior.edit import dev.kord.core.entity.Message import dev.kord.core.entity.ReactionEmoji -import dev.kord.core.entity.User import dev.kord.rest.builder.message.create.allowedMentions import dev.kord.rest.builder.message.create.embed import dev.kord.rest.builder.message.modify.allowedMentions @@ -27,9 +25,8 @@ import java.util.* * @param targetChannel Target channel to send the paginator to, if [targetMessage] isn't provided. */ public class MessageButtonPaginator( - extension: Extension, pages: Pages, - owner: User? = null, + owner: UserBehavior? = null, timeoutSeconds: Long? = null, keepEmbed: Boolean = true, switchEmoji: ReactionEmoji = if (pages.groups.size == 2) EXPAND_EMOJI else SWITCH_EMOJI, @@ -39,15 +36,13 @@ public class MessageButtonPaginator( public val pingInReply: Boolean = true, public val targetChannel: MessageChannelBehavior? = null, public val targetMessage: Message? = null, -) : BaseButtonPaginator(extension, pages, owner, timeoutSeconds, keepEmbed, switchEmoji, bundle, locale) { +) : BaseButtonPaginator(pages, owner, timeoutSeconds, keepEmbed, switchEmoji, bundle, locale) { init { if (targetChannel == null && targetMessage == null) { throw IllegalArgumentException("Must provide either a target channel or target message") } } - override var components: Components = Components(extension) - /** Specific channel to send the paginator to. **/ public val channel: MessageChannelBehavior = targetMessage?.channel ?: targetChannel!! @@ -55,8 +50,6 @@ public class MessageButtonPaginator( public var message: Message? = null override suspend fun send() { - components.stop() - if (message == null) { setup() @@ -67,7 +60,7 @@ public class MessageButtonPaginator( embed { applyPage() } with(this@MessageButtonPaginator.components) { - this@createMessage.setup(timeoutSeconds) + this@createMessage.applyToMessage() } } } else { @@ -77,7 +70,7 @@ public class MessageButtonPaginator( embed { applyPage() } with(this@MessageButtonPaginator.components) { - this@edit.setup(timeoutSeconds) + this@edit.applyToMessage() } } } @@ -101,8 +94,7 @@ public class MessageButtonPaginator( } } - runTimeoutCallbacks() - components.stop() + super.destroy() } } @@ -116,7 +108,6 @@ public fun MessageButtonPaginator( builder: PaginatorBuilder ): MessageButtonPaginator = MessageButtonPaginator( - extension = builder.extension, pages = builder.pages, owner = builder.owner, timeoutSeconds = builder.timeoutSeconds, diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/Paginator.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/Paginator.kt deleted file mode 100644 index d4ddcba78a..0000000000 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/Paginator.kt +++ /dev/null @@ -1,352 +0,0 @@ -package com.kotlindiscord.kord.extensions.pagination - -import com.kotlindiscord.kord.extensions.ExtensibleBot -import com.kotlindiscord.kord.extensions.pagination.pages.Page -import com.kotlindiscord.kord.extensions.pagination.pages.Pages -import com.kotlindiscord.kord.extensions.utils.respond -import com.kotlindiscord.kord.extensions.utils.waitFor -import dev.kord.common.annotation.KordPreview -import dev.kord.core.Kord -import dev.kord.core.behavior.MessageBehavior -import dev.kord.core.behavior.channel.MessageChannelBehavior -import dev.kord.core.behavior.channel.createEmbed -import dev.kord.core.behavior.edit -import dev.kord.core.entity.Message -import dev.kord.core.entity.ReactionEmoji -import dev.kord.core.entity.User -import dev.kord.core.entity.channel.DmChannel -import dev.kord.core.event.Event -import dev.kord.core.event.message.ReactionAddEvent -import dev.kord.core.event.message.ReactionRemoveEvent -import dev.kord.rest.builder.message.create.embed -import dev.kord.rest.builder.message.modify.embed -import kotlinx.coroutines.delay -import mu.KotlinLogging -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import java.util.* - -private const val WRONG_TYPE = "Wrong event type!" - -private val logger = KotlinLogging.logger {} - -/** - * Paginator for, well, pagination. - * - * This paginator is fairly extensible, supporting subclassing of this class, as well as the [Pages] and [Page] - * classes. It's designed with page groups in mind, which means you can provide groups of pages that can be switched - * between using a dedicated reaction. - * - * @param bot The bot object this paginator was created for - * @param targetChannel The channel this paginator should be created within - * @param targetMessage The message this paginator should be created in response to - * @param pages Set of pages this paginator should paginate - * @param pingInReply When [targetMessage] is provided, whether to ping the message author in the reply - * @param owner Optional paginator owner, if you want to prevent other users from using the reactions - * @param timeout Optional timeout, after which the paginator will be destroyed - * @param keepEmbed Whether to keep the embed after the paginator is destroyed, `false` by default - * @param switchEmoji If you have multiple groups, this is the emoji used to switch between them - * @param locale Locale to use for translations - */ -@Deprecated( - "The paginator has been replaced with much better, button-based variants. You can easily get at them " + - "from your commands by using the `paginator` DSL, or take a look at `InteractionButtonPaginator` or " + - "`MessageButtonPaginator` for interaction-based and message-based implementations respectively.", - - level = DeprecationLevel.WARNING -) -public open class Paginator( - public val pages: Pages, - public val targetChannel: MessageChannelBehavior? = null, - public val targetMessage: Message? = null, - public val owner: User? = null, - public val timeout: Long? = null, - public val keepEmbed: Boolean = true, - public val pingInReply: Boolean = true, - public val switchEmoji: ReactionEmoji = if (pages.groups.size == 2) EXPAND_EMOJI else SWITCH_EMOJI, - - locale: Locale? = null -) : KoinComponent { - /** Current instance of the bot. **/ - public open val bot: ExtensibleBot by inject() - - /** Kord instance, backing the ExtensibleBot. **/ - public val kord: Kord by inject() - - /** Locale to use for translations. **/ - public val locale: Locale = locale ?: bot.settings.i18nBuilder.defaultLocale - - /** What to do after the paginator times out. **/ - public val timeoutCallbacks: MutableList Unit> = mutableListOf() - - init { - if (targetChannel == null && targetMessage == null) { - throw IllegalArgumentException("Must provide either a target channel or target message") - } - } - - /** Basic emojis that should be added to every paginator. **/ - public open val emojis: Array = arrayOf( - FIRST_PAGE_EMOJI, - LEFT_EMOJI, - RIGHT_EMOJI, - LAST_PAGE_EMOJI, - ) - - /** Reactions to add to the paginator after it's been sent. **/ - public open val reactions: MutableList = mutableListOf() - - /** Whether this paginator is currently active and processing events. **/ - public open var active: Boolean = true - - /** Currently-displayed page index. **/ - public var currentPageNum: Int = 0 - - /** Currently-displayed page group. **/ - public var currentGroup: String = pages.defaultGroup - - /** Set of all page groups. **/ - public open var allGroups: List = pages.groups.map { it.key } - - /** Currently-displayed page object. **/ - public open var currentPage: Page = pages.get(currentGroup, currentPageNum) - - /** Convenience function to send the current page to the channel, editing if a message is passed. **/ - public open suspend fun sendCurrentPage(message: MessageBehavior? = null): MessageBehavior { - val groupEmoji = if (pages.groups.size > 1) { - currentGroup - } else { - null - } - - val builder = currentPage.build( - locale, - currentPageNum, - pages.size, - groupEmoji, - allGroups.indexOf(currentGroup), - allGroups.size - ) - - return if (message != null) { - message.edit { embed { builder() } } - } else if (targetChannel != null) { - targetChannel.createEmbed { builder() } - } else if (targetMessage != null) { - targetMessage.respond(pingInReply = pingInReply) { - embed { builder() } - } - } else { - throw IllegalArgumentException("Must provide either a target channel or target message") - } - } - - /** Send the embed to the channel given in the constructor. **/ - @KordPreview - public open suspend fun send() { - pages.validate() // Will throw if there's a problem - - val message = sendCurrentPage() - - if (pages.size > 1) { - reactions += emojis - } - - if (pages.groups.size > 1) { - reactions += switchEmoji - } - - if (message.getChannelOrNull() !is DmChannel && reactions.isNotEmpty()) { - reactions += FINISH_EMOJI - } - - if (reactions.isNotEmpty()) { - reactions.forEach { message.addReaction(it) } - - val guildCondition: suspend Event.() -> Boolean = { - this is ReactionAddEvent && - message.id == this.messageId && - this.userId != kord.selfId && - (owner == null || owner.id == this.userId) && - active - } - - val dmCondition: suspend Event.() -> Boolean = { - when (this) { - is ReactionAddEvent -> message.id == this.messageId && - this.userId != kord.selfId && - (owner == null || owner.id == this.userId) && - active - - is ReactionRemoveEvent -> message.id == this.messageId && - this.userId != kord.selfId && - (owner == null || owner.id == this.userId) && - active - - else -> false - } - } - - while (true) { - val condition = if (message.getChannelOrNull() is DmChannel) { - dmCondition - } else { - guildCondition - } - - val event = if (timeout != null) { - kord.waitFor(timeout = timeout, condition = condition) - } else { - kord.waitFor(condition = condition) - } ?: break - - processEvent(event) - } - - if (timeout != null) { - destroy(message) - runTimeoutCallbacks() - } - } else { - if (timeout != null) { - delay(timeout) - - if (!keepEmbed) { - destroy(message) - } - - runTimeoutCallbacks() - } - } - } - - /** - * Paginator event handler. - * - * @param event [Event] to process. - */ - public open suspend fun processEvent(event: Event) { - val emoji = when (event) { - is ReactionAddEvent -> event.emoji - is ReactionRemoveEvent -> event.emoji - - else -> error(WRONG_TYPE) - } - - val message = when (event) { - is ReactionAddEvent -> event.message.asMessage() - is ReactionRemoveEvent -> event.message.asMessage() - - else -> error(WRONG_TYPE) - } - - val userId = when (event) { - is ReactionAddEvent -> event.userId - is ReactionRemoveEvent -> event.userId - - else -> error(WRONG_TYPE) - } - - logger.debug { "Paginator received emoji ${emoji.name}" } - - val channel = message.getChannelOrNull() - - if (channel !is DmChannel) { - message.deleteReaction(userId, emoji) - } - - when (emoji) { - FIRST_PAGE_EMOJI -> goToPage(message, 0) - LEFT_EMOJI -> goToPage(message, currentPageNum - 1) - RIGHT_EMOJI -> goToPage(message, currentPageNum + 1) - LAST_PAGE_EMOJI -> goToPage(message, pages.size - 1) - FINISH_EMOJI -> if (channel !is DmChannel) destroy(message) - - switchEmoji -> switchGroup(message) - - else -> return - } - } - - /** Convenience function to switch the currently displayed group. **/ - public open suspend fun switchGroup(message: MessageBehavior) { - val current = currentGroup - val nextIndex = allGroups.indexOf(current) + 1 - - currentGroup = if (nextIndex >= allGroups.size) { - allGroups.first() - } else { - allGroups[nextIndex] - } - - currentPage = pages.get(currentGroup, currentPageNum) - - sendCurrentPage(message) - } - - /** - * Switch to another page in the current group. - * - * @param page Page number to display. - */ - public open suspend fun goToPage(message: MessageBehavior, page: Int) { - if (page == currentPageNum) { - return - } - - if (page < 0 || page > pages.size - 1) { - return - } - - currentPageNum = page - currentPage = pages.get(currentGroup, currentPageNum) - - sendCurrentPage(message) - } - - /** - * Destroy the paginator. - * - * This will stop the paginator from processing events, and delete its message if [keepEmbed] is `false`. - */ - public open suspend fun destroy(message: MessageBehavior) { - if (!active) { - return - } - - if (!keepEmbed) { - message.delete() - } else { - if (message.asMessage().getChannelOrNull() !is DmChannel) { - message.deleteAllReactions() - } else { - reactions.forEach { message.deleteOwnReaction(it) } - } - } - - active = false - } - - /** - * Register a callback that is called after the paginator times out. - * - * If there is no [timeout] set, your callbacks will never be called! - */ - public open fun onTimeout(body: suspend () -> Unit): Paginator { - timeoutCallbacks.add(body) - - return this - } - - /** @suppress Call the timeout callbacks. **/ - @Suppress("TooGenericExceptionCaught") // Come on, now. - public open suspend fun runTimeoutCallbacks() { - timeoutCallbacks.forEach { - try { - it.invoke() - } catch (t: Throwable) { - logger.error(t) { "Error thrown by timeout callback: $it" } - } - } - } -} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/InteractionButtonPaginator.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/PublicFollowUpPaginator.kt similarity index 53% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/InteractionButtonPaginator.kt rename to kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/PublicFollowUpPaginator.kt index 1244c295fb..1097f92f2b 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/InteractionButtonPaginator.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/PublicFollowUpPaginator.kt @@ -2,60 +2,48 @@ package com.kotlindiscord.kord.extensions.pagination -import com.kotlindiscord.kord.extensions.commands.slash.SlashCommandContext -import com.kotlindiscord.kord.extensions.components.Components -import com.kotlindiscord.kord.extensions.extensions.Extension import com.kotlindiscord.kord.extensions.pagination.builders.PaginatorBuilder import com.kotlindiscord.kord.extensions.pagination.pages.Pages import dev.kord.common.annotation.KordPreview +import dev.kord.core.behavior.UserBehavior +import dev.kord.core.behavior.interaction.InteractionResponseBehavior import dev.kord.core.behavior.interaction.edit +import dev.kord.core.behavior.interaction.followUp import dev.kord.core.entity.ReactionEmoji -import dev.kord.core.entity.User import dev.kord.core.entity.interaction.PublicFollowupMessage import dev.kord.rest.builder.message.create.embed import dev.kord.rest.builder.message.modify.embed import java.util.* /** - * Class representing a button-based paginator that operates on public-acked interactions. Essentially, use this with - * slash commands. + * Class representing a button-based paginator that operates by creating and editing a follow-up message for the + * given public interaction response. * - * @param parentContext Parent slash command context to be worked with. + * @param interaction Interaction response behaviour to work with. */ -public class InteractionButtonPaginator( - extension: Extension, +public class PublicFollowUpPaginator( pages: Pages, - owner: User? = null, + owner: UserBehavior? = null, timeoutSeconds: Long? = null, keepEmbed: Boolean = true, switchEmoji: ReactionEmoji = if (pages.groups.size == 2) EXPAND_EMOJI else SWITCH_EMOJI, bundle: String? = null, locale: Locale? = null, - public val parentContext: SlashCommandContext<*>, -) : BaseButtonPaginator(extension, pages, owner, timeoutSeconds, keepEmbed, switchEmoji, bundle, locale) { - init { - if (parentContext.isEphemeral == true) { - error("Paginators cannot operate with ephemeral interactions.") - } - } - - override var components: Components = Components(extension, parentContext) - - /** Follow-up message containing all of the buttons. **/ + public val interaction: InteractionResponseBehavior, +) : BaseButtonPaginator(pages, owner, timeoutSeconds, keepEmbed, switchEmoji, bundle, locale) { + /** Follow-up interaction to use for this paginator's embeds. Will be created by [send]. **/ public var embedInteraction: PublicFollowupMessage? = null override suspend fun send() { - components.stop() - if (embedInteraction == null) { setup() - embedInteraction = parentContext.publicFollowUp { + embedInteraction = interaction.followUp { embed { applyPage() } - with(this@InteractionButtonPaginator.components) { - this@publicFollowUp.setup(timeoutSeconds) + with(this@PublicFollowUpPaginator.components) { + this@followUp.applyToMessage() } } } else { @@ -64,8 +52,8 @@ public class InteractionButtonPaginator( embedInteraction!!.edit { embed { applyPage() } - with(this@InteractionButtonPaginator.components) { - this@edit.setup(timeoutSeconds) + with(this@PublicFollowUpPaginator.components) { + this@edit.applyToMessage() } } } @@ -79,34 +67,32 @@ public class InteractionButtonPaginator( active = false if (!keepEmbed) { - embedInteraction!!.delete() + embedInteraction?.delete() } else { - embedInteraction!!.edit { + embedInteraction?.edit { embed { applyPage() } this.components = mutableListOf() } } - runTimeoutCallbacks() - components.stop() + super.destroy() } } /** Convenience function for creating an interaction button paginator from a paginator builder. **/ @Suppress("FunctionNaming") // Factory function -public fun InteractionButtonPaginator( +public fun PublicFollowUpPaginator( builder: PaginatorBuilder, - parentContext: SlashCommandContext<*> -): InteractionButtonPaginator = InteractionButtonPaginator( - extension = builder.extension, + interaction: InteractionResponseBehavior +): PublicFollowUpPaginator = PublicFollowUpPaginator( pages = builder.pages, owner = builder.owner, timeoutSeconds = builder.timeoutSeconds, keepEmbed = builder.keepEmbed, bundle = builder.bundle, locale = builder.locale, - parentContext = parentContext, + interaction = interaction, switchEmoji = builder.switchEmoji ?: if (builder.pages.groups.size == 2) EXPAND_EMOJI else SWITCH_EMOJI, ) diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/PublicResponsePaginator.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/PublicResponsePaginator.kt new file mode 100644 index 0000000000..c0255b927d --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/PublicResponsePaginator.kt @@ -0,0 +1,84 @@ +@file:OptIn(KordPreview::class) + +package com.kotlindiscord.kord.extensions.pagination + +import com.kotlindiscord.kord.extensions.pagination.builders.PaginatorBuilder +import com.kotlindiscord.kord.extensions.pagination.pages.Pages +import dev.kord.common.annotation.KordPreview +import dev.kord.core.behavior.UserBehavior +import dev.kord.core.behavior.interaction.PublicInteractionResponseBehavior +import dev.kord.core.behavior.interaction.edit +import dev.kord.core.entity.ReactionEmoji +import dev.kord.rest.builder.message.modify.embed +import java.util.* + +/** + * Class representing a button-based paginator that operates by editing the given public interaction response. + * + * @param interaction Interaction response behaviour to work with. + */ +public class PublicResponsePaginator( + pages: Pages, + owner: UserBehavior? = null, + timeoutSeconds: Long? = null, + keepEmbed: Boolean = true, + switchEmoji: ReactionEmoji = if (pages.groups.size == 2) EXPAND_EMOJI else SWITCH_EMOJI, + bundle: String? = null, + locale: Locale? = null, + + public val interaction: PublicInteractionResponseBehavior, +) : BaseButtonPaginator(pages, owner, timeoutSeconds, keepEmbed, switchEmoji, bundle, locale) { + /** Whether this paginator has been set up for the first time. **/ + public var isSetup: Boolean = false + + override suspend fun send() { + if (!isSetup) { + isSetup = true + + setup() + } else { + updateButtons() + } + + interaction.edit { + embed { applyPage() } + + with(this@PublicResponsePaginator.components) { + this@edit.applyToMessage() + } + } + } + + override suspend fun destroy() { + if (!active) { + return + } + + active = false + + interaction.edit { + embed { applyPage() } + + this.components = mutableListOf() + } + + super.destroy() + } +} + +/** Convenience function for creating an interaction button paginator from a paginator builder. **/ +@Suppress("FunctionNaming") // Factory function +public fun PublicResponsePaginator( + builder: PaginatorBuilder, + interaction: PublicInteractionResponseBehavior +): PublicResponsePaginator = PublicResponsePaginator( + pages = builder.pages, + owner = builder.owner, + timeoutSeconds = builder.timeoutSeconds, + keepEmbed = builder.keepEmbed, + bundle = builder.bundle, + locale = builder.locale, + interaction = interaction, + + switchEmoji = builder.switchEmoji ?: if (builder.pages.groups.size == 2) EXPAND_EMOJI else SWITCH_EMOJI, +) diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/_Functions.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/_Functions.kt new file mode 100644 index 0000000000..775d286f0b --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/_Functions.kt @@ -0,0 +1,48 @@ +package com.kotlindiscord.kord.extensions.pagination + +import com.kotlindiscord.kord.extensions.pagination.builders.PaginatorBuilder +import dev.kord.core.behavior.interaction.EphemeralInteractionResponseBehavior +import dev.kord.core.behavior.interaction.PublicInteractionResponseBehavior +import java.util.* + +/** Create a paginator that edits the original interaction. **/ +public suspend inline fun PublicInteractionResponseBehavior.editingPaginator( + locale: Locale? = null, + defaultGroup: String = "", + builder: (PaginatorBuilder).() -> Unit +): PublicResponsePaginator { + val pages = PaginatorBuilder(locale = locale, defaultGroup = defaultGroup) + + builder(pages) + + return PublicResponsePaginator(pages, this) +} + +/** Create a paginator that creates a follow-up message, and edits that. **/ +public suspend inline fun PublicInteractionResponseBehavior.respondingPaginator( + locale: Locale? = null, + defaultGroup: String = "", + builder: (PaginatorBuilder).() -> Unit +): PublicFollowUpPaginator { + val pages = PaginatorBuilder(locale = locale, defaultGroup = defaultGroup) + + builder(pages) + + return PublicFollowUpPaginator(pages, this) +} + +/** + * Create a paginator that edits the original interaction. This is the only option for an ephemeral interaction, as + * it's impossible to edit an ephemeral follow-up. + */ +public suspend inline fun EphemeralInteractionResponseBehavior.editingPaginator( + locale: Locale? = null, + defaultGroup: String = "", + builder: (PaginatorBuilder).() -> Unit +): EphemeralResponsePaginator { + val pages = PaginatorBuilder(locale = locale, defaultGroup = defaultGroup) + + builder(pages) + + return EphemeralResponsePaginator(pages, this) +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/builders/PaginatorBuilder.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/builders/PaginatorBuilder.kt index e33c4e8902..7da66f8cec 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/builders/PaginatorBuilder.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/pagination/builders/PaginatorBuilder.kt @@ -1,10 +1,10 @@ package com.kotlindiscord.kord.extensions.pagination.builders -import com.kotlindiscord.kord.extensions.extensions.Extension import com.kotlindiscord.kord.extensions.pagination.pages.Page import com.kotlindiscord.kord.extensions.pagination.pages.Pages +import dev.kord.core.behavior.UserBehavior import dev.kord.core.entity.ReactionEmoji -import dev.kord.core.entity.User +import dev.kord.rest.builder.message.EmbedBuilder import java.util.* /** @@ -15,7 +15,6 @@ import java.util.* * @param defaultGroup Default page group, if any */ public class PaginatorBuilder( - public val extension: Extension, public var locale: Locale? = null, public val defaultGroup: String = "" ) { @@ -23,7 +22,7 @@ public class PaginatorBuilder( public val pages: Pages = Pages(defaultGroup) /** Paginator owner, if only one person should be able to interact. **/ - public var owner: User? = null + public var owner: UserBehavior? = null /** Paginator timeout, in seconds. When elapsed, the paginator will be destroyed. **/ public var timeoutSeconds: Long? = null @@ -42,4 +41,19 @@ public class PaginatorBuilder( /** Add a page to [pages], using the given group. **/ public fun page(group: String, page: Page): Unit = pages.addPage(group, page) + + /** Add a page to [pages], using the default group. **/ + public fun page( + bundle: String? = null, + builder: suspend EmbedBuilder.() -> Unit + ): Unit = + page(Page(builder = builder, bundle = bundle)) + + /** Add a page to [pages], using the given group. **/ + public fun page( + group: String, + bundle: String? = null, + builder: suspend EmbedBuilder.() -> Unit + ): Unit = + page(group, Page(builder = builder, bundle = bundle)) } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/registry/AbstractDeconstructingApplicationCommandRegistryStorage.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/registry/AbstractDeconstructingApplicationCommandRegistryStorage.kt new file mode 100644 index 0000000000..73c01d3602 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/registry/AbstractDeconstructingApplicationCommandRegistryStorage.kt @@ -0,0 +1,79 @@ +package com.kotlindiscord.kord.extensions.registry + +import com.kotlindiscord.kord.extensions.commands.application.ApplicationCommand +import dev.kord.common.entity.Snowflake +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapNotNull + +/** + * Abstract class which can be used to implement a simple networked key-value based registry storage + * for [ApplicationCommand]s. + * + * For simplicity the parameter / return types of the abstract methods are all [String]s. + */ +public abstract class AbstractDeconstructingApplicationCommandRegistryStorage> : + RegistryStorage { + + /** + * Mapping of command-key to command-object. + */ + protected open val commandMapping: MutableMap = mutableMapOf() + + /** + * Upserts simplified data. + * The key is the command id, which is returned by the create request from discord. + * The value is the command name, which must be unique across the registry. + */ + protected abstract suspend fun upsert(key: String, value: String) + + /** + * Reads simplified data from the storage. + * + * The key is the command id. + * + * Returns the command name associated with this key. + */ + protected abstract suspend fun read(key: String): String? + + /** + * Deletes and returns simplified data. + * + * The key is the command id. + * + * Returns the command name associated with this key. + */ + protected abstract suspend fun delete(key: String): String? + + /** + * Returns all entries in this registry as simplified data. + * + * The key is the command id. + * The value is the command name associated with this key. + */ + protected abstract fun entries(): Flow> + + override fun constructUniqueIdentifier(data: T): String = "${data.name}-${data.type.value}-${data.guildId ?: 0}" + + override suspend fun register(data: T) { + commandMapping[constructUniqueIdentifier(data)] = data + } + + override suspend fun set(id: Snowflake, data: T) { + val key = constructUniqueIdentifier(data) + commandMapping[key] = data + upsert(id.asString, key) + } + + override suspend fun get(id: Snowflake): T? { + val key = read(id.asString) ?: return null + return commandMapping[key] + } + + override suspend fun remove(id: Snowflake): T? { + val key = delete(id.asString) ?: return null + return commandMapping[key] + } + + override fun entryFlow(): Flow> = entries() + .mapNotNull { commandMapping[it.value]?.let { cmd -> RegistryStorage.StorageEntry(Snowflake(it.key), cmd) } } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/registry/DefaultLocalRegistryStorage.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/registry/DefaultLocalRegistryStorage.kt new file mode 100644 index 0000000000..53a9e9a977 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/registry/DefaultLocalRegistryStorage.kt @@ -0,0 +1,34 @@ +package com.kotlindiscord.kord.extensions.registry + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.map + +/** + * Default "local" implementation of [RegistryStorage] which internally + * uses a mutableMap. + */ +public open class DefaultLocalRegistryStorage : RegistryStorage { + + protected open val registry: MutableMap = mutableMapOf() + + override suspend fun register(data: T) { + // we don't need to do anything here + } + + override suspend fun set(id: K, data: T) { + registry[id] = data + } + + override suspend fun get(id: K): T? = registry[id] + + override suspend fun remove(id: K): T? = registry.remove(id) + + override fun entryFlow(): Flow> { + return registry.entries + .asFlow() + .map { RegistryStorage.StorageEntry(it.key, it.value) } + } + + override fun constructUniqueIdentifier(data: T): String = data.hashCode().toString() +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/registry/RegistryStorage.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/registry/RegistryStorage.kt new file mode 100644 index 0000000000..bfa4746b79 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/registry/RegistryStorage.kt @@ -0,0 +1,65 @@ +package com.kotlindiscord.kord.extensions.registry + +import kotlinx.coroutines.flow.Flow + +/** + * Interface for interaction based registries like ComponentRegistry and ApplicationCommandRegistry. + * + * The purpose of this interface is to provide a generic way to store Components/ApplicationCommands + * in a dynamic manner. + */ +public interface RegistryStorage { + + /** + * Lets the registry know about the specified type [T], this may store the object in a local map, + * which is used for reconstructing later. + */ + public suspend fun register(data: T) + + /** + * Creates or updates an existing entry at the given unique key. + * + * This may deconstruct the given data and only persists a partial object. + */ + public suspend fun set(id: K, data: T) + + /** + * Reads a value from the registry at the given key. + * + * This may reconstruct the data from a partial object. + */ + public suspend fun get(id: K): T? + + /** + * Deletes a value from the registry with the given key. + * + * The return value may be a reconstructed object from partial data. + */ + public suspend fun remove(id: K): T? + + /** + * Creates a flow of all entries in this registry. + * + * The objects in this flow may be reconstructed from partial data. + */ + public fun entryFlow(): Flow> + + /** + * Constructs a unique key for the given data. + */ + public fun constructUniqueIdentifier(data: T): String + + /** + * Data class to represent an entry in the [RegistryStorage]. + */ + public data class StorageEntry( + /** + * The key of this entry. + */ + val key: K, + /** + * The value of this entry. + */ + val value: V + ) +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/BreadcrumbType.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/BreadcrumbType.kt new file mode 100644 index 0000000000..339766fda2 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/BreadcrumbType.kt @@ -0,0 +1,39 @@ +package com.kotlindiscord.kord.extensions.sentry + +/** + * Sealed class representing all the types of breadcrumbs that Sentry supports. + * + * @param name The breadcrumb type name, sent to Sentry + * @param requiredKeys Array of required keys that must be present in the breadcrumb data for it to be valid, if any + */ +public sealed class BreadcrumbType(public val name: String, public vararg val requiredKeys: String) { + /** Typically a debug log message. **/ + public object Debug : BreadcrumbType("debug") + + /** The default breadcrumb type. **/ + public object Default : BreadcrumbType("default") + + /** A detected or unhandled error. **/ + public object Error : BreadcrumbType("error") + + /** A HTTP request sent by your bot. **/ + public object HTTP : BreadcrumbType("http") + + /** Information on what's been going on. **/ + public object Info : BreadcrumbType("info") + + /** Navigation action, requiring from/to data keys. **/ + public object Navigation : BreadcrumbType("navigation", "from", "to") + + /** A query made by a user. **/ + public object Query : BreadcrumbType("query") + + /** A tracing event. **/ + public object Transaction : BreadcrumbType("transaction") + + /** A UI interaction. **/ + public object UI : BreadcrumbType("ui") + + /** A user interaction. **/ + public object User : BreadcrumbType("user") +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/Converters.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/Converters.kt index 906804f4d3..eb7cee3101 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/Converters.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/Converters.kt @@ -7,11 +7,13 @@ package com.kotlindiscord.kord.extensions.sentry +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Arguments import dev.kord.common.annotation.KordPreview import io.sentry.protocol.SentryId +// TODO: Move to annotation + /** * Create a Sentry ID argument converter, for single arguments. * diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/SentryAdapter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/SentryAdapter.kt index 87584f5e02..afeccab974 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/SentryAdapter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/SentryAdapter.kt @@ -101,9 +101,12 @@ public open class SentryAdapter { */ public fun sendFeedback( id: SentryId, + comments: String? = null, email: String? = null, - name: String? = null + name: String? = null, + + removeId: Boolean = true ) { if (!enabled) error("Sentry integration has not yet been configured.") @@ -114,29 +117,10 @@ public open class SentryAdapter { if (name != null) feedback.name = name Sentry.captureUserFeedback(feedback) - } - - /** - * Convenience function for creating a Breadcrumb object. - */ - public fun createBreadcrumb( - category: String? = null, - level: SentryLevel? = null, - message: String? = null, - type: String? = null, - - data: Map = mapOf() - ): Breadcrumb { - val breadcrumbObj = Breadcrumb() - - if (category != null) breadcrumbObj.category = category - if (level != null) breadcrumbObj.level = level - if (message != null) breadcrumbObj.message = message - if (type != null) breadcrumbObj.type = type - data.toSortedMap().forEach { (key, value) -> breadcrumbObj.setData(key, value) } - - return breadcrumbObj + if (removeId) { + removeEventId(id) + } } /** Register an event ID that a user may provide feedback for. **/ diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/SentryContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/SentryContext.kt new file mode 100644 index 0000000000..3682b7b35a --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/SentryContext.kt @@ -0,0 +1,208 @@ +@file:Suppress("TooGenericExceptionCaught") + +package com.kotlindiscord.kord.extensions.sentry + +import io.sentry.* +import io.sentry.protocol.SentryId +import mu.KLogger +import mu.KotlinLogging +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +/** + * Context object for keeping track of Sentry breadcrumbs and providing convenient APIs for submitting them, along with + * transaction functions. + * + * Generally speaking, you'll probably want to use this instead of touching Sentry (or the adapter) directly. + */ +public class SentryContext : KoinComponent { + /** Quick access to the Sentry adapter, if required. **/ + public val adapter: SentryAdapter by inject() + + /** + * List of Sentry breadcrumbs referred to as part of this context, You likely won't need to touch this directly, + * but it's available for more advanced use-cases. + */ + public val breadcrumbs: MutableList = mutableListOf() + + /** Create a transaction with the given name and operation, and use it to measure the given callable. **/ + public inline fun transaction(name: String, operation: String, body: (ITransaction).() -> Unit) { + val transaction = Sentry.startTransaction(name, operation) + + transaction(transaction, body) + } + + /** Use the given transaction to measure the given callable. **/ + public inline fun transaction(transaction: ITransaction, body: (ITransaction).() -> Unit) { + try { + body(transaction) + } catch (t: Throwable) { + transaction.throwable = t + transaction.status = SpanStatus.INTERNAL_ERROR + } finally { + transaction.finish() + } + } + + /** Register a breadcrumb of the given [type], using the [builder] to modify it and add context. **/ + public inline fun breadcrumb(type: BreadcrumbType = BreadcrumbType.Default, builder: Breadcrumb.() -> Unit) { + val breadcrumb = Breadcrumb() + breadcrumb.type = type.name + + builder(breadcrumb) + + if (!type.requiredKeys.all { breadcrumb.data.containsKey(it) }) { + val logger: KLogger = KotlinLogging.logger("com.kotlindiscord.kord.extensions.sentry.SentryContext") + + logger.warn { + "Ignoring provided breadcrumb type \"${type.name}\" - the following data keys are required: " + + type.requiredKeys.joinToString() + } + + breadcrumb.type = BreadcrumbType.Default.name + } + + breadcrumbs.add(breadcrumb) + } + + /** Register a [breadcrumb] object that's already been created. **/ + public fun breadcrumb(breadcrumb: Breadcrumb): Boolean = + breadcrumbs.add(breadcrumb) + + /** Capture a [SentryEvent], submitting it to Sentry with the breadcrumbs in this context. **/ + public inline fun captureEvent( + event: SentryEvent, + crossinline body: (Scope).() -> Unit + ): SentryId { + lateinit var id: SentryId + + Sentry.withScope { + body(it) + + breadcrumbs.forEach(it::addBreadcrumb) + + id = Sentry.captureEvent(event) + } + + adapter.addEventId(id) + + return id + } + + /** Capture a [SentryEvent], submitting it to Sentry with the breadcrumbs in this context. **/ + public inline fun captureEvent( + event: SentryEvent, + hint: Any?, + crossinline body: (Scope).() -> Unit + ): SentryId { + lateinit var id: SentryId + + Sentry.withScope { + body(it) + + breadcrumbs.forEach(it::addBreadcrumb) + + id = Sentry.captureEvent(event, hint) + } + + adapter.addEventId(id) + + return id + } + + /** Capture a [Throwable] exception, submitting it to Sentry with the breadcrumbs in this context. **/ + public inline fun captureException( + t: Throwable, + crossinline body: (Scope).() -> Unit + ): SentryId { + lateinit var id: SentryId + + Sentry.withScope { + body(it) + + breadcrumbs.forEach(it::addBreadcrumb) + + id = Sentry.captureException(t) + } + + adapter.addEventId(id) + + return id + } + + /** Capture a [Throwable] exception, submitting it to Sentry with the breadcrumbs in this context. **/ + public inline fun captureException( + t: Throwable, + hint: Any?, + crossinline body: (Scope).() -> Unit + ): SentryId { + lateinit var id: SentryId + + Sentry.withScope { + body(it) + + breadcrumbs.forEach(it::addBreadcrumb) + + id = Sentry.captureException(t, hint) + } + + adapter.addEventId(id) + + return id + } + + /** Capture a [UserFeedback] object, submitting it to Sentry with the breadcrumbs in this context. **/ + public inline fun captureFeedback( + feedback: UserFeedback, + crossinline body: (Scope).() -> Unit + ) { + Sentry.withScope { + body(it) + + breadcrumbs.forEach(it::addBreadcrumb) + + Sentry.captureUserFeedback(feedback) + } + } + + /** Capture a [message] String, submitting it to Sentry with the breadcrumbs in this context. **/ + public inline fun captureMessage( + message: String, + crossinline body: (Scope).() -> Unit + ): SentryId { + lateinit var id: SentryId + + Sentry.withScope { + body(it) + + breadcrumbs.forEach(it::addBreadcrumb) + + id = Sentry.captureMessage(message) + } + + adapter.addEventId(id) + + return id + } + + /** Capture a [message] String, submitting it to Sentry with the breadcrumbs in this context. **/ + public inline fun captureMessage( + message: String, + level: SentryLevel, + crossinline body: (Scope).() -> Unit + ): SentryId { + lateinit var id: SentryId + + Sentry.withScope { + body(it) + + breadcrumbs.forEach(it::addBreadcrumb) + + id = Sentry.captureMessage(message, level) + } + + adapter.addEventId(id) + + return id + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/SentryIdConverter.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/SentryIdConverter.kt index a15ab015e9..259c7793b3 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/SentryIdConverter.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/SentryIdConverter.kt @@ -2,12 +2,13 @@ package com.kotlindiscord.kord.extensions.sentry -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.SingleConverter -import com.kotlindiscord.kord.extensions.commands.parser.Argument import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder import io.sentry.protocol.SentryId @@ -27,7 +28,7 @@ public class SentryIdConverter : SingleConverter() { try { this.parsed = SentryId(arg) } catch (e: IllegalArgumentException) { - throw CommandException( + throw DiscordRelayedException( context.translate("extensions.sentry.converter.error.invalid", replacements = arrayOf(arg)) ) } @@ -37,4 +38,18 @@ public class SentryIdConverter : SingleConverter() { override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val optionValue = (option as? OptionValue.StringOptionValue)?.value ?: return false + + try { + this.parsed = SentryId(optionValue) + } catch (e: IllegalArgumentException) { + throw DiscordRelayedException( + context.translate("extensions.sentry.converter.error.invalid", replacements = arrayOf(optionValue)) + ) + } + + return true + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/Utils.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/_Utils.kt similarity index 69% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/Utils.kt rename to kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/_Utils.kt index bec6d7e095..a7b2054b8b 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/Utils.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/sentry/_Utils.kt @@ -1,8 +1,9 @@ +@file:Suppress("TooGenericExceptionCaught") + package com.kotlindiscord.kord.extensions.sentry -import io.sentry.Breadcrumb -import io.sentry.Scope -import io.sentry.SentryLevel +import io.sentry.* +import io.sentry.Sentry.startTransaction import io.sentry.protocol.User /** @@ -63,3 +64,22 @@ public fun Scope.breadcrumb( this.addBreadcrumb(breadcrumbObj, hint) } + +/** Convenience function for creating and testing a sub-transaction. **/ +public inline fun ITransaction.transaction(name: String, operation: String, body: (ITransaction).() -> T) { + val transaction = startTransaction(name, operation) + + transaction(transaction, body) +} + +/** Convenience function for testing a sub-transaction. **/ +public inline fun ITransaction.transaction(transaction: ITransaction, body: (ITransaction).() -> T) { + try { + body(transaction) + } catch (t: Throwable) { + transaction.throwable = t + transaction.status = SpanStatus.INTERNAL_ERROR + } finally { + transaction.finish() + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/time/TimestampType.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/time/TimestampType.kt index f43e60764c..2202f18091 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/time/TimestampType.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/time/TimestampType.kt @@ -32,4 +32,23 @@ public sealed class TimestampType(public val string: String?) { /** Format the given [Long] value according to the current timestamp type. **/ public fun format(value: Long): String = "" + + public companion object { + /** + * Parse Discord's format specifiers to a specific format. + */ + public fun fromFormatSpecifier(string: String?): TimestampType? { + return when (string) { + "f" -> ShortDateTime + "F" -> LongDateTime + "d" -> ShortDate + "D" -> LongDate + "t" -> ShortTime + "T" -> LongTime + "R" -> RelativeTime + null -> Default + else -> null + } + } + } } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/types/EphemeralInteractionContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/types/EphemeralInteractionContext.kt new file mode 100644 index 0000000000..f1342a970e --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/types/EphemeralInteractionContext.kt @@ -0,0 +1,70 @@ +package com.kotlindiscord.kord.extensions.types + +import com.kotlindiscord.kord.extensions.pagination.EphemeralResponsePaginator +import com.kotlindiscord.kord.extensions.pagination.PublicFollowUpPaginator +import com.kotlindiscord.kord.extensions.pagination.builders.PaginatorBuilder +import dev.kord.core.behavior.interaction.EphemeralInteractionResponseBehavior +import dev.kord.core.behavior.interaction.edit +import dev.kord.core.behavior.interaction.followUp +import dev.kord.core.behavior.interaction.followUpEphemeral +import dev.kord.core.entity.interaction.EphemeralFollowupMessage +import dev.kord.core.entity.interaction.PublicFollowupMessage +import dev.kord.rest.builder.message.create.FollowupMessageCreateBuilder +import dev.kord.rest.builder.message.modify.InteractionResponseModifyBuilder +import java.util.* + +/** Interface representing an ephemeral-only interaction action context. **/ +public interface EphemeralInteractionContext { + /** Response created by acknowledging the interaction ephemerally. **/ + public val interactionResponse: EphemeralInteractionResponseBehavior +} + +/** + * Respond to the current interaction with an ephemeral followup. + * + * **Note:** Calling this twice (or at all after [edit]) will result in a public followup! + */ +public suspend inline fun EphemeralInteractionContext.respond( + builder: FollowupMessageCreateBuilder.() -> Unit +): EphemeralFollowupMessage = interactionResponse.followUpEphemeral(builder) + +/** Respond to the current interaction with a public followup. **/ +public suspend inline fun PublicInteractionContext.respondPublic( + builder: FollowupMessageCreateBuilder.() -> Unit +): PublicFollowupMessage = interactionResponse.followUp(builder) + +/** + * Edit the current interaction's response. + */ +public suspend inline fun EphemeralInteractionContext.edit( + builder: InteractionResponseModifyBuilder.() -> Unit +): Unit = interactionResponse.edit(builder) + +/** + * Create a paginator that edits the original interaction. This is the only option for an ephemeral interaction, as + * it's impossible to edit an ephemeral follow-up. + */ +public suspend inline fun EphemeralInteractionContext.editingPaginator( + defaultGroup: String = "", + locale: Locale? = null, + builder: (PaginatorBuilder).() -> Unit +): EphemeralResponsePaginator { + val pages = PaginatorBuilder(locale = locale, defaultGroup = defaultGroup) + + builder(pages) + + return EphemeralResponsePaginator(pages, interactionResponse) +} + +/** Create a paginator that creates a follow-up message, and edits that. **/ +public suspend inline fun EphemeralInteractionContext.publicRespondingPaginator( + defaultGroup: String = "", + locale: Locale? = null, + builder: (PaginatorBuilder).() -> Unit +): PublicFollowUpPaginator { + val pages = PaginatorBuilder(locale = locale, defaultGroup = defaultGroup) + + builder(pages) + + return PublicFollowUpPaginator(pages, interactionResponse) +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/types/FailureReason.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/types/FailureReason.kt new file mode 100644 index 0000000000..2e300335e4 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/types/FailureReason.kt @@ -0,0 +1,38 @@ +package com.kotlindiscord.kord.extensions.types + +import com.kotlindiscord.kord.extensions.ArgumentParsingException +import com.kotlindiscord.kord.extensions.DiscordRelayedException + +/** + * Sealed class representing the reason you're dealing with a failure message right now. + * + * If you need to extract the nested throwable, it's recommended that you cast to the required sealed type first to + * make exception typing easier. + * + * @param error Throwable that triggered this failure, if any. + */ +public sealed class FailureReason(public val error: E) { + /** Sealed class representing a basic check failure. **/ + public sealed class BaseCheckFailure(error: E) : + FailureReason(error) + + /** Type representing an error thrown during command/component execution. **/ + public class ExecutionError(error: Throwable) : + FailureReason(error) + + /** Type representing a relayed exception that was thrown during command execution. **/ + public class RelayedFailure(error: DiscordRelayedException) : + FailureReason(error) + + /** Type representing an argument parsing failure, for command types with arguments. **/ + public class ArgumentParsingFailure(error: ArgumentParsingException) : + FailureReason(error) + + /** Type representing a standard "provided" check failure (provided via `check {}`). **/ + public class ProvidedCheckFailure(error: DiscordRelayedException) : + BaseCheckFailure(error) + + /** Type representing a failure caused by the bot having insufficient permissions. **/ + public class OwnPermissionsCheckFailure(error: DiscordRelayedException) : + BaseCheckFailure(error) +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/types/Lockable.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/types/Lockable.kt new file mode 100644 index 0000000000..39906b1cf8 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/types/Lockable.kt @@ -0,0 +1,37 @@ +package com.kotlindiscord.kord.extensions.types + +import kotlinx.coroutines.sync.Mutex + +/** Interface representing something with a [Mutex] that can be locked. **/ +public interface Lockable { + /** Mutex object to use for locking. **/ + public var mutex: Mutex? + + /** Set this to `true` to lock execution with a [Mutex]. **/ + public var locking: Boolean + + /** Lock the mutex (if locking is enabled), call the supplied callable, and unlock. **/ + public suspend fun withLock(body: suspend () -> T) { + try { + lock() + + body() + } finally { + unlock() + } + } + + /** Lock the mutex, if locking is enabled - suspending until it's unlocked. **/ + public suspend fun lock() { + if (locking) { + mutex?.lock() + } + } + + /** Unlock the mutex, if it's locked. **/ + public fun unlock() { + if (locking) { + mutex?.unlock() + } + } +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/types/PublicInteractionContext.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/types/PublicInteractionContext.kt new file mode 100644 index 0000000000..6f97dd08bf --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/types/PublicInteractionContext.kt @@ -0,0 +1,63 @@ +package com.kotlindiscord.kord.extensions.types + +import com.kotlindiscord.kord.extensions.pagination.PublicFollowUpPaginator +import com.kotlindiscord.kord.extensions.pagination.PublicResponsePaginator +import com.kotlindiscord.kord.extensions.pagination.builders.PaginatorBuilder +import dev.kord.core.behavior.interaction.PublicInteractionResponseBehavior +import dev.kord.core.behavior.interaction.edit +import dev.kord.core.behavior.interaction.followUp +import dev.kord.core.behavior.interaction.followUpEphemeral +import dev.kord.core.entity.interaction.EphemeralFollowupMessage +import dev.kord.core.entity.interaction.PublicFollowupMessage +import dev.kord.rest.builder.message.create.FollowupMessageCreateBuilder +import dev.kord.rest.builder.message.modify.InteractionResponseModifyBuilder +import java.util.* + +/** Interface representing a public-only interaction action context. **/ +public interface PublicInteractionContext { + /** Response created by acknowledging the interaction publicly. **/ + public val interactionResponse: PublicInteractionResponseBehavior +} + +/** Respond to the current interaction with a public followup. **/ +public suspend inline fun PublicInteractionContext.respond( + builder: FollowupMessageCreateBuilder.() -> Unit +): PublicFollowupMessage = interactionResponse.followUp(builder) + +/** Respond to the current interaction with a public followup. **/ +public suspend inline fun PublicInteractionContext.respondEphemeral( + builder: FollowupMessageCreateBuilder.() -> Unit +): EphemeralFollowupMessage = interactionResponse.followUpEphemeral(builder) + +/** + * Edit the current interaction's response. + */ +public suspend inline fun PublicInteractionContext.edit( + builder: InteractionResponseModifyBuilder.() -> Unit +): Unit = interactionResponse.edit(builder) + +/** Create a paginator that edits the original interaction. **/ +public suspend inline fun PublicInteractionContext.editingPaginator( + defaultGroup: String = "", + locale: Locale? = null, + builder: (PaginatorBuilder).() -> Unit +): PublicResponsePaginator { + val pages = PaginatorBuilder(locale = locale, defaultGroup = defaultGroup) + + builder(pages) + + return PublicResponsePaginator(pages, interactionResponse) +} + +/** Create a paginator that creates a follow-up message, and edits that. **/ +public suspend inline fun PublicInteractionContext.respondingPaginator( + defaultGroup: String = "", + locale: Locale? = null, + builder: (PaginatorBuilder).() -> Unit +): PublicFollowUpPaginator { + val pages = PaginatorBuilder(locale = locale, defaultGroup = defaultGroup) + + builder(pages) + + return PublicFollowUpPaginator(pages, interactionResponse) +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Environment.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Environment.kt index c9765a7902..548dd0d017 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Environment.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Environment.kt @@ -22,7 +22,7 @@ private val envMap: MutableMap = mutableMapOf() * @param name Environmental variable to get the value for. * @return The value of the environmental variable, or `null` if it doesn't exist. */ -public fun env(name: String): String? { +public fun envOrNull(name: String): String? { if (firstLoad) { firstLoad = false @@ -64,7 +64,7 @@ public fun env(name: String): String? { continue } - logger.debug { "${split[0]} -> ${split[1]}" } + logger.trace { "${split[0]} -> ${split[1]}" } envMap[split[0]] = split[1] } @@ -73,3 +73,23 @@ public fun env(name: String): String? { return envMap[name] ?: System.getenv()[name] } + +/** + * Returns the value of an environmental variable, loading from a `.env` file in the current working directory if + * possible. + * + * This function caches the contents of the `.env` file the first time it's called - there's no way to parse the file + * again later. + * + * This function will throw an exception if the environmental variable can't be found. + * + * @param name Environmental variable to get the value for. + * + * @throws RuntimeException Thrown if the environmental variable can't be found. + * @return The value of the environmental variable. + */ +public fun env(name: String): String = + envOrNull(name) ?: error( + "Missing environmental variable '$name' - please set this by adding it to a `.env` file, or using your" + + "system or process manager's environment management commands and tools." + ) diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Guilds.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Guilds.kt new file mode 100644 index 0000000000..ef6d673fd3 --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Guilds.kt @@ -0,0 +1,40 @@ +package com.kotlindiscord.kord.extensions.utils + +import dev.kord.common.entity.Permission +import dev.kord.common.entity.Permissions +import dev.kord.core.Kord +import dev.kord.core.behavior.GuildBehavior +import dev.kord.core.entity.Member +import dev.kord.core.entity.channel.GuildChannel + +/** + * Retrieves the member of the bot itself on this [GuildBehavior]. + * + * @see Kord.selfId + */ +public suspend fun GuildBehavior.selfMember(): Member = getMember(kord.selfId) + +/** + * Checks whether the bot has at least [requiredPermissions] in this [GuildChannel]. + * + * @see GuildBehavior.botHasPermissions + */ +public suspend fun GuildChannel.botHasPermissions(vararg requiredPermissions: Permission): Boolean = + guild.botHasPermissions(this, Permissions(requiredPermissions.asIterable())) + +/** + * Checks whether the bot globally has at least [requiredPermissions] on this guild. + * + * @see GuildChannel.botHasPermissions + */ +public suspend fun GuildBehavior.botHasPermissions(vararg requiredPermissions: Permission): Boolean = + botHasPermissions(null, Permissions(requiredPermissions.asIterable())) + +private suspend fun GuildBehavior.botHasPermissions(channel: GuildChannel?, requiredPermissions: Permissions): Boolean { + val selfMember = selfMember() + val effectivePermissions = + channel?.run { permissionsForMember(selfMember) } + ?: selfMember.getPermissions() + + return requiredPermissions in effectivePermissions +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Koin.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Koin.kt index d1fdbef17c..c593a8b24a 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Koin.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Koin.kt @@ -21,4 +21,5 @@ public fun loadModule( } /** Retrieve the current [Koin] instance. **/ -public fun getKoin(): Koin = KoinPlatformTools.defaultContext().get() +public fun getKoin(): Koin = + KoinPlatformTools.defaultContext().get() diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Member.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Member.kt index 8d4a778934..f3ff592a3e 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Member.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Member.kt @@ -1,6 +1,8 @@ package com.kotlindiscord.kord.extensions.utils import dev.kord.common.entity.Permission +import dev.kord.core.behavior.RoleBehavior +import dev.kord.core.entity.Guild import dev.kord.core.entity.Member import dev.kord.core.entity.Role import kotlinx.coroutines.flow.toList @@ -11,7 +13,7 @@ import kotlinx.coroutines.flow.toList * @param role Role to check for * @return true if the user has the given role, false otherwise */ -public suspend fun Member.hasRole(role: Role): Boolean = roles.toList().contains(role) +public fun Member.hasRole(role: RoleBehavior): Boolean = roleIds.contains(role.id) /** * Check if the user has all of the given roles. @@ -19,7 +21,7 @@ public suspend fun Member.hasRole(role: Role): Boolean = roles.toList().contains * @param roles Roles to check for. * @return `true` if the user has all of the given roles, `false` otherwise. */ -public suspend inline fun Member.hasRoles(vararg roles: Role): Boolean = hasRoles(roles.toList()) +public fun Member.hasRoles(vararg roles: RoleBehavior): Boolean = hasRoles(roles.toList()) /** * Check if the user has all of the given roles. @@ -27,11 +29,11 @@ public suspend inline fun Member.hasRoles(vararg roles: Role): Boolean = hasRole * @param roles Roles to check for. * @return `true` if the user has all of the given roles, `false` otherwise. */ -public suspend fun Member.hasRoles(roles: Collection): Boolean = +public fun Member.hasRoles(roles: Collection): Boolean = if (roles.isEmpty()) { true } else { - this.roles.toList().containsAll(roles) + this.roleIds.containsAll(roles.map { it.id }) } /** @@ -85,3 +87,41 @@ public suspend fun Member.hasPermissions(perms: Collection): Boolean perms.all { it in permissions } } + +/** + * Checks if this [Member] can interact (delete/edit/assign/..) with the specified [Role]. + * + * This checks if the [Member] has any role which is higher in hierarchy than [Role]. + * The logic also accounts for [Guild] ownership. + * + * Throws an [IllegalArgumentException] if the role is from a different guild. + */ +public suspend fun Member.canInteract(role: Role): Boolean { + val guild = getGuild() + + if (guild.ownerId == this.id) return true + + val highestRole = getTopRole() ?: guild.getEveryoneRole() + return highestRole.canInteract(role) +} + +/** + * Checks if this [Member] can interact (kick/ban/..) with another [Member] + * + * This checks if the [Member] has any role which is higher in hierarchy than all [Role]s of the + * specified [Member] + * The logic also accounts for [Guild] ownership + * + * Throws an [IllegalArgumentException] if the member is from a different guild. + */ +public suspend fun Member.canInteract(member: Member): Boolean { + val guild = getGuild() + + if (isOwner()) return true + if (member.isOwner()) return false + + val highestRole = getTopRole() ?: guild.getEveryoneRole() + val otherHighestRole = member.getTopRole() ?: guild.getEveryoneRole() + + return highestRole.canInteract(otherHighestRole) +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Message.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Message.kt index a66c6874f5..fff9e35033 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Message.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Message.kt @@ -4,8 +4,12 @@ import com.kotlindiscord.kord.extensions.commands.CommandContext import dev.kord.common.entity.DiscordPartialMessage import dev.kord.common.entity.MessageFlag import dev.kord.common.entity.Snowflake +import dev.kord.core.Kord import dev.kord.core.behavior.MessageBehavior +import dev.kord.core.behavior.UserBehavior +import dev.kord.core.behavior.channel.MessageChannelBehavior import dev.kord.core.behavior.channel.createMessage +import dev.kord.core.behavior.interaction.PublicFollowupMessageBehavior import dev.kord.core.behavior.reply import dev.kord.core.cache.data.MessageData import dev.kord.core.entity.* @@ -18,8 +22,6 @@ import dev.kord.rest.request.RestRequestException import io.ktor.http.* import kotlinx.coroutines.* import mu.KotlinLogging -import org.apache.commons.text.StringTokenizer -import org.apache.commons.text.matcher.StringMatcherFactory private val logger = KotlinLogging.logger {} @@ -39,6 +41,19 @@ public suspend fun MessageBehavior.deleteIgnoringNotFound() { } } +/** + * Deletes a public follow-up, catching and ignoring a HTTP 404 (Not Found) exception. + */ +public suspend fun PublicFollowupMessageBehavior.deleteIgnoringNotFound() { + try { + delete() + } catch (e: RestRequestException) { + if (e.hasNotStatus(HttpStatusCode.NotFound)) { + throw e + } + } +} + /** * Deletes a message after a delay. * @@ -66,6 +81,33 @@ public fun MessageBehavior.delete(millis: Long, retry: Boolean = true): Job { } } +/** + * Deletes a public follow-up after a delay. + * + * This function **does not block**. + * + * @param millis The delay before deleting the message, in milliseconds. + * @return Job spawned by the CoroutineScope. + */ +public fun PublicFollowupMessageBehavior.delete(millis: Long, retry: Boolean = true): Job { + return kord.launch { + delay(millis) + + try { + this@delete.deleteIgnoringNotFound() + } catch (e: RestRequestException) { + val message = this@delete + + if (retry) { + logger.debug(e) { "Failed to delete message, retrying: $message" } + this@delete.delete(millis, false) + } else { + logger.error(e) { "Failed to delete message: $message" } + } + } + } +} + /** * Add a reaction to this message, using the Unicode emoji represented by the given string. * @@ -127,29 +169,6 @@ public val MessageData.authorId: Snowflake public val MessageData.authorIsBot: Boolean get() = author.bot.discordBoolean -/** - * Takes a [Message] object and parses it using a [StringTokenizer]. - * - * This tokenizes a string, splitting it into an array of strings using whitespace as a - * delimiter, but supporting quoted tokens (strings between quotes are treated as individual - * arguments). - * - * This is used to create an array of arguments for a command's input. - * - * @param delimiters An array of delimiters to split with, if not just a space - * @param quotes An array of quote characters, if you need something other than just `'` and `"` - * - * @return An array of parsed arguments - */ -public fun Message.parse( - delimiters: CharArray = charArrayOf(' '), - quotes: CharArray = charArrayOf('\'', '"') -): Array = - StringTokenizer(content) - .setDelimiterMatcher(StringMatcherFactory.INSTANCE.charSetMatcher(delimiters.joinToString())) - .setQuoteMatcher(StringMatcherFactory.INSTANCE.charSetMatcher(quotes.joinToString())) - .tokenArray - /** * Respond to a message in the channel it was sent to, mentioning the author. * @@ -372,3 +391,92 @@ public val Message.isUrgent: Boolean public val Message.isEphemeral: Boolean get() = data.flags.value?.contains(MessageFlag.Ephemeral) == true + +/** + * Wait for a message, using the given timeout (in milliseconds ) and filter function. + * + * Will return `null` if no message is found before the timeout. + */ +public suspend fun waitForMessage( + timeout: Long, + filter: (suspend (MessageCreateEvent).() -> Boolean) = { true } +): Message? { + val kord = getKoin().get() + val event = kord.waitFor(timeout, filter) + + return event?.message +} + +/** + * Wait for a message from a user, using the given timeout (in milliseconds) and extra filter function. + * + * Will return `null` if no message is found before the timeout. + */ +public suspend fun UserBehavior.waitForMessage( + timeout: Long, + filter: (suspend (MessageCreateEvent).() -> Boolean) = { true } +): Message? { + val kord = getKoin().get() + val event = kord.waitFor(timeout) { + message.author?.id == id && + filter() + } + + return event?.message +} + +/** + * Wait for a message in this channel, using the given timeout (in milliseconds) and extra filter function. + * + * Will return `null` if no message is found before the timeout. + */ +public suspend fun MessageChannelBehavior.waitForMessage( + timeout: Long, + filter: (suspend (MessageCreateEvent).() -> Boolean) = { true } +): Message? { + val kord = getKoin().get() + val event = kord.waitFor(timeout) { + message.channelId == id && + filter() + } + + return event?.message +} + +/** + * Wait for a message in reply to this one, using the given timeout (in milliseconds) and extra filter function. + * + * Will return `null` if no message is found before the timeout. + */ +public suspend fun MessageBehavior.waitForReply( + timeout: Long, + filter: (suspend (MessageCreateEvent).() -> Boolean) = { true } +): Message? { + val kord = getKoin().get() + val event = kord.waitFor(timeout) { + message.messageReference?.message?.id == id && + filter() + } + + return event?.message +} + +/** + * Wait for a message by the user that invoked this command, in the channel it was invoked in, using the given + * timeout (in milliseconds) and extra filter function. + * + * Will return `null` if no message is found before the timeout. + */ +public suspend fun CommandContext.waitForResponse( + timeout: Long, + filter: (suspend (MessageCreateEvent).() -> Boolean) = { true } +): Message? { + val kord = com.kotlindiscord.kord.extensions.utils.getKoin().get() + val event = kord.waitFor(timeout) { + message.author?.id == getUser()?.id && + message.channelId == getChannel()?.id && + filter() + } + + return event?.message +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Permissions.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Permissions.kt index d938b244d9..23028eb810 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Permissions.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Permissions.kt @@ -37,13 +37,15 @@ public fun Permission.toTranslationKey(): String = when (this) { Permission.Speak -> "permission.speak" Permission.Stream -> "permission.stream" Permission.UseExternalEmojis -> "permission.useExternalEmojis" - Permission.UsePrivateThreads -> "permission.usePrivateThreads" - Permission.UsePublicThreads -> "permission.usePublicThreads" Permission.UseSlashCommands -> "permission.useSlashCommands" Permission.UseVAD -> "permission.useVAD" Permission.ViewAuditLog -> "permission.viewAuditLog" Permission.ViewChannel -> "permission.viewChannel" Permission.ViewGuildInsights -> "permission.viewGuildInsights" + + Permission.CreatePublicThreads -> "permission.createPublicThreads" + Permission.CreatePrivateThreads -> "permission.createPrivateThreads" + Permission.SendMessagesInThreads -> "permission.sendMessagesInThreads" } /** Because "Stream" is a confusing name, people may look for "Video" instead. **/ diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Role.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Role.kt new file mode 100644 index 0000000000..00741ada4e --- /dev/null +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Role.kt @@ -0,0 +1,16 @@ +package com.kotlindiscord.kord.extensions.utils + +import dev.kord.core.entity.Role + +/** + * Checks whether a [Role] can interact with another [Role] by comparing their [rawPosition]s. + * + * Throws an [IllegalArgumentException] when the roles are not from the same guild. + */ +public fun Role.canInteract(role: Role): Boolean { + if (role.guildId != guildId) { + throw IllegalArgumentException("canInteract can only be called within the same guild!") + } + + return role.rawPosition < rawPosition +} diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Users.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Users.kt index 7b20ceab04..4ada0dbe54 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Users.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/_Users.kt @@ -23,7 +23,7 @@ public val User.profileLink: String * The user's creation timestamp. */ public val User.createdAt: Instant - get() = this.id.timeStamp + get() = this.id.timestamp /** * Send a private message to a user, if they have their DMs enabled. diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/deltas/MemberDelta.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/deltas/MemberDelta.kt index c498140220..61e78c1317 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/deltas/MemberDelta.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/deltas/MemberDelta.kt @@ -2,8 +2,8 @@ package com.kotlindiscord.kord.extensions.utils.deltas import dev.kord.common.entity.UserFlags import dev.kord.common.entity.optional.Optional +import dev.kord.core.entity.Icon import dev.kord.core.entity.Member -import dev.kord.core.entity.User import kotlinx.datetime.Instant import kotlin.contracts.contract @@ -18,7 +18,7 @@ import kotlin.contracts.contract */ @Suppress("UndocumentedPublicProperty") public class MemberDelta( - avatar: Optional, + avatar: Optional, username: Optional, discriminator: Optional, flags: Optional, diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/deltas/UserDelta.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/deltas/UserDelta.kt index 88694dd4cd..df7b1db7a3 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/deltas/UserDelta.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/deltas/UserDelta.kt @@ -2,6 +2,7 @@ package com.kotlindiscord.kord.extensions.utils.deltas import dev.kord.common.entity.UserFlags import dev.kord.common.entity.optional.Optional +import dev.kord.core.entity.Icon import dev.kord.core.entity.User import kotlin.contracts.contract @@ -16,7 +17,7 @@ import kotlin.contracts.contract */ @Suppress("UndocumentedPublicProperty") public open class UserDelta( - public val avatar: Optional, + public val avatar: Optional, public val username: Optional, public val discriminator: Optional, public val flags: Optional @@ -48,7 +49,7 @@ public open class UserDelta( old ?: return null return UserDelta( - if (old.avatar.url != new.avatar.url) Optional(new.avatar) else Optional.Missing(), + if (old.avatar?.url != new.avatar?.url) Optional(new.avatar) else Optional.Missing(), if (old.username != new.username) Optional(new.username) else Optional.Missing(), if (old.discriminator != new.discriminator) Optional(new.discriminator) else Optional.Missing(), if (old.publicFlags != new.publicFlags) Optional(new.publicFlags) else Optional.Missing() diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/scheduling/Scheduler.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/scheduling/Scheduler.kt index eec07a072f..dd06ec4a6b 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/scheduling/Scheduler.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/scheduling/Scheduler.kt @@ -24,11 +24,13 @@ public class Scheduler : CoroutineScope { /** Convenience function to schedule a [Task] using [seconds] instead of a [Duration]. **/ public fun schedule( seconds: Long, + startNow: Boolean = true, name: String? = null, pollingSeconds: Long = 1, callback: suspend () -> Unit ): Task = schedule( delay = Duration.seconds(seconds), + startNow = startNow, name = name, pollingSeconds = pollingSeconds, callback = callback, @@ -38,12 +40,14 @@ public class Scheduler : CoroutineScope { * Schedule a [Task] using the given [delay] and [callback]. A name will be generated if not provided. * * @param delay [Duration] object representing the time to wait for. + * @param startNow Whether to start the task now - `false` if you want to start it yourself. * @param name Optional task name, used in logging. * @param pollingSeconds How often to check whether enough time has passed - `1` by default. * @param callback Callback to run when the task has waited for long enough. */ public fun schedule( delay: Duration, + startNow: Boolean = true, name: String? = null, pollingSeconds: Long = 1, callback: suspend () -> Unit @@ -60,7 +64,10 @@ public class Scheduler : CoroutineScope { ) tasks.add(task) - task.start() + + if (startNow) { + task.start() + } return task } diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/scheduling/Task.kt b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/scheduling/Task.kt index 5640691a0f..6b2b138b9e 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/scheduling/Task.kt +++ b/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/utils/scheduling/Task.kt @@ -87,7 +87,7 @@ public class Task( removeFromParent() } - /** Like [cancel], but blocks until the cancellation has been applied.. **/ + /** Like [cancel], but blocks .. **/ public suspend fun cancelAndJoin() { job?.cancelAndJoin() job = null @@ -95,6 +95,22 @@ public class Task( removeFromParent() } + /** If the task is running, cancel it and restart it. **/ + public fun restart() { + job?.cancel() + job = null + + start() + } + + /** Like [restart], but blocks until the cancellation has been applied. **/ + public suspend fun restartJoining() { + job?.cancelAndJoin() + job = null + + start() + } + /** Join the running [job], if any. **/ public suspend fun join(): Unit? = job?.join() diff --git a/kord-extensions/src/main/resources/translations/kordex/strings.properties b/kord-extensions/src/main/resources/translations/kordex/strings.properties index b54b79e094..5b8c1e3a3c 100644 --- a/kord-extensions/src/main/resources/translations/kordex/strings.properties +++ b/kord-extensions/src/main/resources/translations/kordex/strings.properties @@ -5,48 +5,39 @@ argumentParser.error.notAllValid=Argument `{0}` was provided with {1} {1, plural argumentParser.error.unknownConverterType=Unknown converter type provided\: `{0}` argumentParser.error.noFilledArguments=This command has {0} required {0, plural, \=1 {argument} other {arguments}}. argumentParser.error.someFilledArguments=This command has {0} required {0, plural, \=1 {argument} other {arguments}}, but only {1} could be filled. - -channelType.dm="DM" -channelType.groupDm="Group DM" -channelType.guildCategory="Category" -channelType.guildNews="News" -channelType.guildStageVoice="Stage" -channelType.guildStore="Store" -channelType.guildText="Text" -channelType.guildVoice="Voice" -channelType.publicNewsThread="Public News Thread" -channelType.publicGuildThread="Public Guild Thread" -channelType.privateThread="Private Thread" -channelType.unknown="Unknown" - +channelType.dm=DM +channelType.groupDm=Group DM +channelType.guildCategory=Category +channelType.guildNews=Announcement +channelType.guildStageVoice=Stage +channelType.guildStore=Store +channelType.guildText=Text +channelType.guildVoice=Voice +channelType.publicNewsThread=News Thread +channelType.publicGuildThread=Public Thread +channelType.privateThread=Private Thread +channelType.unknown=Unknown checks.responseTemplate=**Error:** {0} - checks.inChannel.failed=Must be in **{0}** checks.notInChannel.failed=Must not be in **{0}** checks.inCategory.failed=Must be in category: **{0}** checks.notInCategory.failed=Must not be in category: **{0}** checks.channelHigher.failed=Must be in a channel higher than **{0}** -checks.channelLower.failed=Must not be in a channel lower than **{0}** +checks.channelLower.failed=Must be in a channel lower than **{0}** checks.channelHigherOrEqual.failed=Must be in **{0}**, or a higher channel checks.channelLowerOrEqual.failed=Must be in **{0}**, or a lower channel - checks.anyGuild.failed=Must be in a server checks.noGuild.failed=Must not be in a server checks.inGuild.failed=Must be in server: **{0}** checks.notInGuild.failed=Must not be in server: **{0}** - checks.channelType.failed=Must be in a channel of type: **{0}** checks.notChannelType.failed=Must not be in a channel of type: **{0}** - checks.hasPermission.failed=Must have permission: **{0}** checks.notHasPermission.failed=Must not have permission: **{0}** - checks.isBot.failed=Must be a bot checks.isNotBot.failed=Must not be a bot - checks.isInThread.failed=Must be in a thread checks.isNotInThread.failed=Must not be in a thread - checks.hasRole.failed=Must have role: **{0}** checks.notHasRole.failed=Must not have role: **{0}** checks.topRoleEqual.failed=Must have top role: **{0}** @@ -55,7 +46,6 @@ checks.topRoleHigher.failed=Must have a top role higher than: **{0}** checks.topRoleLower.failed=Must have a top role lower than: **{0}** checks.topRoleHigherOrEqual.failed=Must have a top role of **{0}**, or a higher top role checks.topRoleLowerOrEqual.failed=Must have a top role of **{0}**, or a lower top role - commands.defaultDescription=No description provided. commands.error.missingBotPermissions=I don't have the permissions I need to run that command\!\n\n**Missing permissions\:** {0} commands.error.user=Unfortunately, **an error occurred** during command processing. Please let a staff member know. @@ -110,6 +100,8 @@ converters.union.error.unknownConverterType=Unknown converter type provided\: `{ converters.user.signatureType=user converters.user.error.missing=Unable to find user\: `{0}` converters.user.error.invalid=Value `{0}` is not a valid user ID. +converters.timestamp.signatureType=timestamp +converters.timestamp.error.invalid=Value `{0}` is not a valid timestamp. extensions.help.commandName=help extensions.help.commandAliases=h extensions.help.commandDescription=Get command help.\n\nSpecify the name of a command to get help for that specific command. Subcommands may also be specified, using the same form you'd use to run them. @@ -143,7 +135,6 @@ paginator.button.group.switch=Next Group paginator.button.less=Less paginator.footer.page=Page {0}/{1} paginator.footer.group=Group {0}/{1} - permission.addReactions=Add Reactions permission.administrator=Administrator permission.all=All Permissions @@ -152,6 +143,8 @@ permission.banMembers=Ban Members permission.changeNickname=Change Nickname permission.connect=Connect (Voice) permission.createInstantInvite=Create Invite +permission.createPrivateThreads=Create Private Threads +permission.createPublicThreads=Create Public Threads permission.deafenMembers=Deafen Members permission.embedLinks=Embed Links permission.kickMembers=Kick Members @@ -170,18 +163,16 @@ permission.prioritySpeaker=Priority Speaker permission.readMessageHistory=Read Message History permission.requestToSpeak=Request to Speak permission.sendMessages=Send Messages +permission.sendMessagesInThreads=Send Messages In Threads permission.sendTTSMessages=Send TTS Messages permission.speak=Speak (Voice) permission.stream=Video permission.useExternalEmojis=Use External Emojis -permission.usePrivateThreads=View Server Insights -permission.usePublicThreads=View Server Insights permission.useSlashCommands=Use Slash Commands permission.useVAD=Use Voice Activity permission.viewAuditLog=View Audit Log permission.viewChannel=View Channel permission.viewGuildInsights=View Server Insights - utils.message.useThisChannel=Please use {0} for this command. utils.message.commandNotAvailableInDm=This command is not available via private message. utils.colors.black=black,blck,blk diff --git a/kord-extensions/src/main/resources/translations/kordex/strings_de_DE.properties b/kord-extensions/src/main/resources/translations/kordex/strings_de_DE.properties index 597c60fef2..3589013bfc 100644 --- a/kord-extensions/src/main/resources/translations/kordex/strings_de_DE.properties +++ b/kord-extensions/src/main/resources/translations/kordex/strings_de_DE.properties @@ -1,10 +1,51 @@ argumentParser.error.errorInArgument=**Fehler in Argument** `{0}`**\:** {1} argumentParser.error.requiresOneValue=Das Argument `{0}` benötigt genau einen Wert, aber {1} wurden angegeben. -argumentParser.error.invalidValue=Ungültiger Wert für das Argument `{0}` (akzeptiert {1}) +argumentParser.error.invalidValue=Ungültiger Wert für das Argument `{0}` (akzeptiert\: {1}) argumentParser.error.notAllValid=Das Argument `{0}` wurde mit {1} {1, plural, \=1 {Wert} other {Werten}} angegeben, aber {2, plural, \=0 {keine davon waren gültige {3}-Werte} \=1{nur {2} davon war ein gültiger {3}-Wert} other {nur {2} davon waren gültige {3}-Werte}}. argumentParser.error.unknownConverterType=Unbekannter Konvertertyp\: `{0}` argumentParser.error.noFilledArguments=Dieser Befehl hat {0} {0, plural, \=1 {erforderliches Argument} other {erforderliche Argumente}}. argumentParser.error.someFilledArguments=Dieser Befehl hat {0} {0, plural, \=1 {erforderliches Argument} other {erforderliche Argumente}}, aber nur {1} {1, plural, \=1 {konnte} other {konnten}} ausgefüllt werden. +channelType.dm=DM +channelType.groupDm=Gruppenchat +channelType.guildCategory=Kategorie +channelType.guildNews=Ankündigungen +channelType.guildStageVoice=Stage +channelType.guildStore=Store +channelType.guildText=Text +channelType.guildVoice=Sprache +channelType.publicNewsThread=Ankündigungsthread +channelType.publicGuildThread=Public Thread +channelType.privateThread=Private Thread +channelType.unknown=Unbekannt +checks.responseTemplate=**Fehler\:** {0} +checks.inChannel.failed=Muss in **{0}** sein +checks.notInChannel.failed=Darf nicht in **{0}** sein +checks.inCategory.failed=Muss in der Kategorie **{0}** sein +checks.notInCategory.failed=Darf nicht in der Kategorie **{0}** sein +checks.channelHigher.failed=Muss in einem Kanal über **{0}** sein +checks.channelLower.failed=Muss in einem Kanal über **{0}** sein +checks.channelHigherOrEqual.failed=Muss in **{0}** oder einem höheren Kanal sein +checks.channelLowerOrEqual.failed=Muss in **{0}** oder einem niedrigeren Kanal sein +checks.anyGuild.failed=Muss in einem Server sein +checks.noGuild.failed=Darf nicht in einem Server sein +checks.inGuild.failed=Muss im Server **{0}** sein +checks.notInGuild.failed=Darf nicht im Server **{0}** sein +checks.channelType.failed=Muss in einem Kanel des Typs **{0}** sein +checks.notChannelType.failed=Darf nicht in einem Kanal des Typs **{0}** sein +checks.hasPermission.failed=Muss Berechtigung **{0}** haben +checks.notHasPermission.failed=Darf nicht Berechtigung **{0}** haben +checks.isBot.failed=Muss ein Bot sein +checks.isNotBot.failed=Darf kein Bot sein +checks.isInThread.failed=Muss in einem Thread sein +checks.isNotInThread.failed=Darf nicht in einem Thread sein +checks.hasRole.failed=Muss Rolle **{0}** haben +checks.notHasRole.failed=Darf nicht Rolle **{0}**haben +checks.topRoleEqual.failed=Muss als höchste Rolle **{0}** haben +checks.topRoleNotEqual.failed=Darf nicht als höchste Rolle **{0}** haben +checks.topRoleHigher.failed=Muss als höchste Rolle eine Höhere als **{0}** haben +checks.topRoleLower.failed=Muss als höchste Rolle eine Niedrigere als **{0}** haben +checks.topRoleHigherOrEqual.failed=Muss als höchste Rolle **{0}** oder eine Höhere haben +checks.topRoleLowerOrEqual.failed=Muss als höchste Rolle **{0}** oder eine Niedrigere haben commands.defaultDescription=Keine Beschreibung verfügbar. commands.error.missingBotPermissions=Ich habe nicht die Berechtigungen, die ich brauche, um diesen Befehl auszuführen\!\n\n**Fehlende Berechtigungen\:** {0} commands.error.user=Leider **ist ein Fehler passiert** während der Befehl verarbeitet wurde. Bitte informiere deine Server-Administration. @@ -100,6 +141,8 @@ permission.banMembers=Mitglieder bannen permission.changeNickname=Nickname ändern permission.connect=Verbinden (Sprachkanal) permission.createInstantInvite=Einladung erstellen +permission.createPrivateThreads=Private Threads erstellen +permission.createPublicThreads=Öffentliche Threads erstellen permission.deafenMembers=Ausgabe von Mitgliedern deaktivieren permission.embedLinks=Links einbetten permission.kickMembers=Mitglieder kicken @@ -109,6 +152,7 @@ permission.manageGuild=Server verwalten permission.manageMessages=Nachrichten verwalten permission.manageNicknames=Nicknames verwalten permission.manageRoles=Rollen verwalten +permission.manageThreads=Server-Einblicke anzeigen permission.manageWebhooks=WebHooks verwalten permission.mentionEveryone=Erwähne @everyone permission.moveMembers=Mitglieder verschieben @@ -117,6 +161,7 @@ permission.prioritySpeaker=Very Important Speaker permission.readMessageHistory=Nachrichtenverlauf anzeigen permission.requestToSpeak=Redeanfrage permission.sendMessages=Nachrichten senden +permission.sendMessagesInThreads=Send Messages In Threads permission.sendTTSMessages=Text-zu-Sprache-Nachrichten senden permission.speak=Sprechen (Sprachkanal) permission.stream=Video diff --git a/kord-extensions/src/main/resources/translations/kordex/strings_en_GB.properties b/kord-extensions/src/main/resources/translations/kordex/strings_en_GB.properties index 10f6c82296..a4b194fe1b 100644 --- a/kord-extensions/src/main/resources/translations/kordex/strings_en_GB.properties +++ b/kord-extensions/src/main/resources/translations/kordex/strings_en_GB.properties @@ -1,23 +1,64 @@ -argumentParser.error.errorInArgument=**Error in argument** `{0}`**\:** {1} +argumentParser.error.errorInArgument=**Error in argument** `{0}`**:** {1} argumentParser.error.requiresOneValue=Argument `{0}` requires exactly 1 value, but {1} were provided. -argumentParser.error.invalidValue=Invalid value for argument `{0}` (which accepts\: {1}) +argumentParser.error.invalidValue=Invalid value for argument `{0}` (which accepts: {1}) argumentParser.error.notAllValid=Argument `{0}` was provided with {1} {1, plural, \=1 {value} other {values}}, but {2, plural, \=0 {none were valid} other {only {2} of them were valid }} {3} values. argumentParser.error.unknownConverterType=Unknown converter type provided\: `{0}` argumentParser.error.noFilledArguments=This command has {0} required {0, plural, \=1 {argument} other {arguments}}. argumentParser.error.someFilledArguments=This command has {0} required {0, plural, \=1 {argument} other {arguments}}, but only {1} could be filled. +channelType.dm=DM +channelType.groupDm=Group DM +channelType.guildCategory=Category +channelType.guildNews=Announcement +channelType.guildStageVoice=Stage +channelType.guildStore=Store +channelType.guildText=Text +channelType.guildVoice=Voice +channelType.publicNewsThread=News Thread +channelType.publicGuildThread=Public Thread +channelType.privateThread=Private Thread +channelType.unknown=Unknown +checks.responseTemplate=**Error:** {0} +checks.inChannel.failed=Must be in **{0}** +checks.notInChannel.failed=Must not be in **{0}** +checks.inCategory.failed=Must be in category: **{0}** +checks.notInCategory.failed=Must not be in category: **{0}** +checks.channelHigher.failed=Must be in a channel higher than **{0}** +checks.channelLower.failed=Must be in a channel lower than **{0}** +checks.channelHigherOrEqual.failed=Must be in **{0}**, or a higher channel +checks.channelLowerOrEqual.failed=Must be in **{0}**, or a lower channel +checks.anyGuild.failed=Must be in a server +checks.noGuild.failed=Must not be in a server +checks.inGuild.failed=Must be in server: **{0}** +checks.notInGuild.failed=Must not be in server: **{0}** +checks.channelType.failed=Must be in a channel of type: **{0}** +checks.notChannelType.failed=Must not be in a channel of type: **{0}** +checks.hasPermission.failed=Must have permission: **{0}** +checks.notHasPermission.failed=Must not have permission: **{0}** +checks.isBot.failed=Must be a bot +checks.isNotBot.failed=Must not be a bot +checks.isInThread.failed=Must be in a thread +checks.isNotInThread.failed=Must not be in a thread +checks.hasRole.failed=Must have role: **{0}** +checks.notHasRole.failed=Must not have role: **{0}** +checks.topRoleEqual.failed=Must have top role: **{0}** +checks.topRoleNotEqual.failed=Must not have top role: **{0}** +checks.topRoleHigher.failed=Must have a top role higher than: **{0}** +checks.topRoleLower.failed=Must have a top role lower than: **{0}** +checks.topRoleHigherOrEqual.failed=Must have a top role of **{0}**, or a higher top role +checks.topRoleLowerOrEqual.failed=Must have a top role of **{0}**, or a lower top role commands.defaultDescription=No description provided. -commands.error.missingBotPermissions=I don''t have the permissions I need to run that command\!\n\n**Missing permissions\:** {0} +commands.error.missingBotPermissions=I don''t have the permissions I need to run that command!\n\n**Missing permissions:** {0} commands.error.user=Unfortunately, **an error occurred** during command processing. Please let a staff member know. -commands.error.user.sentry.message=Unfortunately, **an error occurred** during command processing. If you''d like to submit information on what you were doing when this error happened, please use the following command\: ```{0}feedback {1} ``` -commands.error.user.sentry.slash=Unfortunately, **an error occurred** during command processing. If you''d like to submit information on what you were doing when this error happened, please use the following command\: ```/feedback {0} ``` +commands.error.user.sentry.message=Unfortunately, **an error occurred** during command processing. If you''d like to submit information on what you were doing when this error happened, please use the following command: ```{0}feedback {1} ``` +commands.error.user.sentry.slash=Unfortunately, **an error occurred** during command processing. If you''d like to submit information on what you were doing when this error happened, please use the following command: ```/feedback {0} ``` converters.boolean.signatureType=yes/no converters.boolean.errorType=`yes` or `no` converters.channel.signatureType=channel converters.channel.error.missing=Unable to find channel\: {0} converters.channel.error.invalid=Value `{0}` is not a valid channel ID. -converters.color.error.unknown=Unknown colour\: `{0}` -converters.color.error.unknownOrFailed=Failed to parse colour\: `{0}` -converters.color.signatureType=colour +converters.color.error.unknown=Unknown color\: `{0}` +converters.color.error.unknownOrFailed=Failed to parse color\: `{0}` +converters.color.signatureType=color converters.decimal.signatureType=decimal converters.decimal.error.invalid=Value `{0}` is not a valid decimal number. converters.duration.error.signatureType=duration @@ -35,7 +76,7 @@ converters.guild.signatureType=server converters.guild.error.missing=Unable to find server\: `{0}` converters.number.signatureType=number converters.number.error.invalid.defaultBase=Value `{0}` is not a valid whole number. -converters.number.error.invalid.otherBase=Value `{0}` is not a valid whole number in {1, plural, \=2 {binary (base-2)} \=8 {octal (base-8)} \=10 {decimal (base-10)} \=16 {hexadecimal (base-16)} other {base-{1}} }. +converters.number.error.invalid.otherBase=Value `{0}` is not a valid whole number in {1, plural, =2 {binary (base-2)} =8 {octal (base-8)} =10 {decimal (base-10)} =16 {hexadecimal (base-16)} other {base-{1}} }. converters.member.signatureType=member converters.member.error.missing=Unable to find member\: {0} converters.member.error.invalid=Value `{0}` is not a valid member ID. @@ -54,7 +95,7 @@ converters.snowflake.signatureType=ID converters.snowflake.error.invalid=Value `{0}` is not a valid Discord ID. converters.string.signatureType=text converters.supportedLocale.signatureType=locale name/code -converters.supportedLocale.error.unknown=Unknown (or unsupported) locale\: `{0}` +converters.supportedLocale.error.unknown=Unknown (or unsupported) locale: `{0}` converters.union.error.unknownConverterType=Unknown converter type provided\: `{0}` converters.user.signatureType=user converters.user.error.missing=Unable to find user\: `{0}` @@ -71,7 +112,7 @@ extensions.help.commandDescription.error.argumentList=Failed to retrieve argumen extensions.help.paginator.title.command=Command\: {0} extensions.help.paginator.title.commands=Commands extensions.help.paginator.title.arguments=Command Arguments -extensions.help.paginator.footer={0} {0, plural, \=1 {command} other {commands}} available +extensions.help.paginator.footer={0} {0, plural, =1 {command} other {commands}} available extensions.help.paginator.noCommands=No commands found. extensions.help.error.missingCommandTitle=Command not found extensions.help.error.missingCommandDescription=Unable to find that command. This may be for one of several possible reasons\: \n\n**»** The command doesn't exist or failed to load\n**»** The command isn't available in this context\n**»** You don't have access to the command\n\nIf you feel that this is incorrect, please contact a member of staff. @@ -100,15 +141,18 @@ permission.banMembers=Ban Members permission.changeNickname=Change Nickname permission.connect=Connect (Voice) permission.createInstantInvite=Create Invite +permission.createPrivateThreads=Create Private Threads +permission.createPublicThreads=Create Public Threads permission.deafenMembers=Deafen Members permission.embedLinks=Embed Links permission.kickMembers=Kick Members permission.manageChannels=Manage Channels permission.manageEmojis=Manage Emojis -permission.manageGuild=Manage +permission.manageGuild=Manage Server permission.manageMessages=Manage Messages permission.manageNicknames=Manage Nicknames permission.manageRoles=Manage Roles +permission.manageThreads=View Server Insights permission.manageWebhooks=Manage Webhooks permission.mentionEveryone=Mention Everyone permission.moveMembers=Move Members @@ -117,6 +161,7 @@ permission.prioritySpeaker=Priority Speaker permission.readMessageHistory=Read Message History permission.requestToSpeak=Request to Speak permission.sendMessages=Send Messages +permission.sendMessagesInThreads=Send Messages In Threads permission.sendTTSMessages=Send TTS Messages permission.speak=Speak (Voice) permission.stream=Video diff --git a/kord-extensions/src/main/resources/translations/kordex/strings_fi_FI.properties b/kord-extensions/src/main/resources/translations/kordex/strings_fi_FI.properties index f2e74f56db..2dc47a4d46 100644 --- a/kord-extensions/src/main/resources/translations/kordex/strings_fi_FI.properties +++ b/kord-extensions/src/main/resources/translations/kordex/strings_fi_FI.properties @@ -5,6 +5,57 @@ argumentParser.error.notAllValid=Argumentille `{0}` annettiin {1} {1, plural, \= argumentParser.error.unknownConverterType=Tuntematon muunnintyyppi annettu\: `{0}` argumentParser.error.noFilledArguments=Tällä komennolla on {0} {0, plural, \=1 {vaadittu argumentti} other {vaadittua argumenttia}}. argumentParser.error.someFilledArguments=Tällä komennolla on {0} {0, plural, \=1 {vaadittu argumentti} other {vaadittua argumenttia}}, mutta vain {1} voitiin täyttää. + +channelType.dm=DM +channelType.groupDm=Group DM +channelType.guildCategory=Kategoria +channelType.guildNews=Uutiset +channelType.guildStageVoice=Esitys +channelType.guildStore=Kauppa +channelType.guildText=Teksti +channelType.guildVoice=Ääni +channelType.publicNewsThread=News Thread +channelType.publicGuildThread=Public Thread +channelType.privateThread=Private Thread +channelType.unknown=Tuntematon + +checks.responseTemplate=**Virhe\:** {0} + +checks.inChannel.failed=Pitää sisältyä kanavaan **{0}** +checks.notInChannel.failed=Ei pidä sisältyä kanavaan **{0}** +checks.inCategory.failed=Pitää olla kategoriassa\: **{0}** +checks.notInCategory.failed=Ei pidä olla kategoriassa\: **{0}** +checks.channelHigher.failed=Pitää olla korkeammalla kanavalla kuin **{0}** +checks.channelLower.failed=Ei pidä olla matalammala kanavalla kuin **{0}** +checks.channelHigherOrEqual.failed=Pitää olla kanavalla **{0}** tai korkeampi +checks.channelLowerOrEqual.failed=Pitää olla kanavalla **{0}**, tai matalampi + +checks.anyGuild.failed=Pitää olla palvelimella +checks.noGuild.failed=Ei pidä olla palvelimella +checks.inGuild.failed=Pitää olla palvelimella **{0}** +checks.notInGuild.failed=Ei pidä olla palvelimella **{0}** + +checks.channelType.failed=Pitää olla kanavalla tyypin **{0}** +checks.notChannelType.failed=Ei pidä olla kanavalla tyypin **{0}** + +checks.hasPermission.failed=Pitää olla lupa **{0}** +checks.notHasPermission.failed=Ei pidä olla lupa **{0}** + +checks.isBot.failed=Pitää olla botti +checks.isNotBot.failed=Ei pidä olla botti + +checks.isInThread.failed=Must be in a thread +checks.isNotInThread.failed=Must not be in a thread + +checks.hasRole.failed=Pitää olla rooli **{0}** +checks.notHasRole.failed=Ei pidä olla roolia **{0}** +checks.topRoleEqual.failed=Pitää olla päärooli **{0}** +checks.topRoleNotEqual.failed=Ei pidä olla päärooli **{0}** +checks.topRoleHigher.failed=Pitää olla päärooli korkeampi kuin **{0}** +checks.topRoleLower.failed=Ei pidä olla päärooli korkeampi kuin **{0}** +checks.topRoleHigherOrEqual.failed=Pitää olla päärooli **{0}** tai korkeampi +checks.topRoleLowerOrEqual.failed=Pitää olla päärooli **{0}** tai matalampi + commands.defaultDescription=Kuvausta ei annettu. commands.error.missingBotPermissions=Minulla ei ole oikeuksia suorittaa tuota komentoa\!\n\n**Puuttuvat oikeudet\:** {0} commands.error.user=Valitettavasti **virhe tapahtui** komennon suorittamisen aikana. Raportoi tämä henkilökunnalle. @@ -92,6 +143,7 @@ paginator.button.group.switch=Seuraava Ryhmä paginator.button.less=Vähemmän paginator.footer.page=Sivu {0}/{1} paginator.footer.group=Ryhmä {0}/{1} + permission.addReactions=Lisää reaktioita permission.administrator=Järjestelmänvalvoja permission.all=Kaikki oikeudet @@ -100,6 +152,8 @@ permission.banMembers=Anna porttikieltoja jäsenille permission.changeNickname=Muuta nimimerkkiä permission.connect=Yhdistä permission.createInstantInvite=Kutsun luonti +permission.createPrivateThreads=Create Private Threads +permission.createPublicThreads=Create Public Threads permission.deafenMembers=Poista ääniä jäseniltä permission.embedLinks=Upota linkkejä permission.kickMembers=Erota jäseniä @@ -109,6 +163,7 @@ permission.manageGuild=Hallinnoi palvelinta permission.manageMessages=Hallinnoi viestejä permission.manageNicknames=Hallinnoi nimimerkkejä permission.manageRoles=Hallinnoi rooleja +permission.manageThreads=Näytä palvelinanalyysit permission.manageWebhooks=Hallinnoi webhookeja permission.mentionEveryone=@everyone ja @here sekä kaikki roolit mainittavissa permission.moveMembers=Siirrä jäseniä @@ -117,6 +172,7 @@ permission.prioritySpeaker=Ensisijainen puhuja permission.readMessageHistory=Lue viestihistoriaa permission.requestToSpeak=Pyydä puhevuoro permission.sendMessages=Lähetä viestejä +permission.sendMessagesInThreads=Send Messages In Threads permission.sendTTSMessages=Lähetä tekstistä puheeksi -viestejä permission.speak=Puhu permission.stream=Video @@ -126,6 +182,7 @@ permission.useVAD=Käytä puheentunnistusta permission.viewAuditLog=Katso valvontalokia permission.viewChannel=Näe kanava permission.viewGuildInsights=Näytä palvelinanalyysit + utils.message.useThisChannel=Käytä tätä komentoa kanavassa {0}. utils.message.commandNotAvailableInDm=Tätä komentoa ei voi käyttää yksityisviestillä. utils.colors.black=musta diff --git a/kord-extensions/src/main/resources/translations/kordex/strings_fr_FR.properties b/kord-extensions/src/main/resources/translations/kordex/strings_fr_FR.properties index 1feeb8696c..e78b9f5ee4 100644 --- a/kord-extensions/src/main/resources/translations/kordex/strings_fr_FR.properties +++ b/kord-extensions/src/main/resources/translations/kordex/strings_fr_FR.properties @@ -5,6 +5,47 @@ argumentParser.error.notAllValid=Le paramètre `{0}` a été saisi avec {1} {1, argumentParser.error.unknownConverterType=Type de convertisseur inconnu \: {0}` argumentParser.error.noFilledArguments=Cette commande a {0} {0, plural, \=1 {paramètre} other {paramètres}} requis. argumentParser.error.someFilledArguments=Cette commande a {0} {0, plural, one {} \=1 {paramètre} other {paramètres}} requis, mais seulement {1} ont été rempli. +channelType.dm=MP +channelType.groupDm=Groupe MP +channelType.guildCategory=Catégorie +channelType.guildNews=Annonce +channelType.guildStageVoice=Stage +channelType.guildStore=Magasin +channelType.guildText=Texte +channelType.guildVoice=Voix +channelType.publicNewsThread=Fil d'actualité +channelType.publicGuildThread=Fil de discussion public +channelType.privateThread=Fil de discussion privé +channelType.unknown=Inconnu +checks.responseTemplate=**Erreur :** {0} +checks.inChannel.failed=Doit être dans **{0}** +checks.notInChannel.failed=Ne doit pas être dans **{0}** +checks.inCategory.failed=Must be in category\: **{0}** +checks.notInCategory.failed=Must not be in category\: **{0}** +checks.channelHigher.failed=Must be in a channel higher than **{0}** +checks.channelLower.failed=Must be in a channel lower than **{0}** +checks.channelHigherOrEqual.failed=Must be in **{0}**, or a higher channel +checks.channelLowerOrEqual.failed=Must be in **{0}**, or a lower channel +checks.anyGuild.failed=Must be in a server +checks.noGuild.failed=Must not be in a server +checks.inGuild.failed=Must be in server\: **{0}** +checks.notInGuild.failed=Must not be in server\: **{0}** +checks.channelType.failed=Must be in a channel of type\: **{0}** +checks.notChannelType.failed=Must not be in a channel of type\: **{0}** +checks.hasPermission.failed=Must have permission\: **{0}** +checks.notHasPermission.failed=Must not have permission\: **{0}** +checks.isBot.failed=Must be a bot +checks.isNotBot.failed=Must not be a bot +checks.isInThread.failed=Must be in a thread +checks.isNotInThread.failed=Must not be in a thread +checks.hasRole.failed=Must have role\: **{0}** +checks.notHasRole.failed=Must not have role\: **{0}** +checks.topRoleEqual.failed=Must have top role\: **{0}** +checks.topRoleNotEqual.failed=Must not have top role\: **{0}** +checks.topRoleHigher.failed=Must have a top role higher than\: **{0}** +checks.topRoleLower.failed=Must have a top role lower than\: **{0}** +checks.topRoleHigherOrEqual.failed=Must have a top role of **{0}**, or a higher top role +checks.topRoleLowerOrEqual.failed=Must have a top role of **{0}**, or a lower top role commands.defaultDescription=Aucune description fournie. commands.error.missingBotPermissions=Je n’ai pas la permission d’exécuter cette commande \!\n\n**Permissions manquantes \:** {0} commands.error.user=Malheureusement, **une erreur s’est produite** lors du traitement de la commande. Veuillez en faire part à un membre de l'équipe. @@ -100,6 +141,8 @@ permission.banMembers=Bannir des membres permission.changeNickname=Changer le surnom permission.connect=Connecter (Voix) permission.createInstantInvite=Créer une invitation +permission.createPrivateThreads=Create Private Threads +permission.createPublicThreads=Create Public Threads permission.deafenMembers=Mettre en sourdine des membres permission.embedLinks=Intégrer des liens permission.kickMembers=Exclure des membres @@ -109,6 +152,7 @@ permission.manageGuild=Gérer le serveur permission.manageMessages=Gérer les messages permission.manageNicknames=Gérer les surnoms permission.manageRoles=Gérer les rôles +permission.manageThreads=Voir les analyses de serveur permission.manageWebhooks=Gérer les Webhooks permission.mentionEveryone=Mentionner tout le monde permission.moveMembers=Déplacer les membres @@ -117,6 +161,7 @@ permission.prioritySpeaker=Voix prioritaire permission.readMessageHistory=Lire l'historique des messages permission.requestToSpeak=Demander pour parler permission.sendMessages=Envoyer des messages +permission.sendMessagesInThreads=Send Messages In Threads permission.sendTTSMessages=Envoyer des messages TTS permission.speak=Parler (Voix) permission.stream=Vidéo @@ -137,7 +182,7 @@ utils.colors.white=blancs,blanc utils.colors.yellow=jaunes,jaune utils.durations.ignoredWords=et utils.units.year=a, an, ans -utils.units.month=mo, mois, mois +utils.units.month=mo, mois utils.units.week=s, semaine, semaines utils.units.day=j, jour, jours utils.units.hour=h, heure, heures diff --git a/kord-extensions/src/main/resources/translations/kordex/strings_pl_PL.properties b/kord-extensions/src/main/resources/translations/kordex/strings_pl_PL.properties index b0eb958824..89aad38dd2 100644 --- a/kord-extensions/src/main/resources/translations/kordex/strings_pl_PL.properties +++ b/kord-extensions/src/main/resources/translations/kordex/strings_pl_PL.properties @@ -5,6 +5,57 @@ argumentParser.error.notAllValid=Argument `{0}` został podany z {1} {1, plural, argumentParser.error.unknownConverterType=Nieznany typ konwertera\: `{0}` argumentParser.error.noFilledArguments=To polecenie ma {0} {0, plural, \=1 {wymagany argument} \=2 {wymagane argumenty} \=3 {wymagane argumenty} \=4 {wymagane argumenty} other {wymaganych argumentów}}. argumentParser.error.someFilledArguments=To polecenie ma {0} {0, plural, one {wymagany argument} few {wymagane argumenty} other {wymaganych argumentów}}, ale tylko {1} {1, plural, one {mógł zostać wypełniony} few {mogły zostać wypełnione} other {mogło zostać wypełnionych}}. + +channelType.dm=PW +channelType.groupDm=Grupa +channelType.guildCategory=Kategoria +channelType.guildNews=Ogłoszenia +channelType.guildStageVoice=Podium +channelType.guildStore=Sklep +channelType.guildText=Tekstowy +channelType.guildVoice=Głosowy +channelType.publicNewsThread=News Thread +channelType.publicGuildThread=Public Thread +channelType.privateThread=Private Thread +channelType.unknown=Nieznany + +checks.responseTemplate=**Błąd\:** {0} + +checks.inChannel.failed=Musi być w **{0}** +checks.notInChannel.failed=Nie może być w **{0}** +checks.inCategory.failed=Musi być w kategorii\: **{0}** +checks.notInCategory.failed=Nie może być w kategorii\: **{0}** +checks.channelHigher.failed=Musi być na kanale wyższym niż **{0}** +checks.channelLower.failed=Nie może być na kanale niższym niż **{0}** +checks.channelHigherOrEqual.failed=Musi być na **{0}**, lub wyższym kanale +checks.channelLowerOrEqual.failed=Musi być na **{0}**, lub niższym kanale + +checks.anyGuild.failed=Musi być w serwerze +checks.noGuild.failed=Nie może być w serwerze +checks.inGuild.failed=Musi być na serwerze\: **{0}** +checks.notInGuild.failed=Nie może być w serwerze\: **{0}** + +checks.channelType.failed=Musi być na kanale typu\: **{0}** +checks.notChannelType.failed=Nie może być na kanale typu\: **{0}** + +checks.hasPermission.failed=Musi mieć uprawnienia\: **{0}** +checks.notHasPermission.failed=Nie może mieć uprawnień\: **{0}** + +checks.isBot.failed=Musi być botem +checks.isNotBot.failed=Nie może być botem + +checks.isInThread.failed=Musi być w wątku +checks.isNotInThread.failed=Nie może być w wątku + +checks.hasRole.failed=Musi mieć rolę\: **{0}** +checks.notHasRole.failed=Nie wolno mieć roli\: **{0}** +checks.topRoleEqual.failed=Najwyższa wymagana rola\: **{0}** +checks.topRoleNotEqual.failed=Najwyższa możliwa rola\: **{0}** +checks.topRoleHigher.failed=Musi mieć najwyższą rolę większą niż\: **{0}** +checks.topRoleLower.failed=Musi mieć najniższą rolę mniejszą niż\: **{0}** +checks.topRoleHigherOrEqual.failed=Musi mieć najwyższą rolę **{0}**, lub wyższą +checks.topRoleLowerOrEqual.failed=Musi mieć najwyższą rolę **{0}**, lub niższą górną rolę + commands.defaultDescription=Nie podano opisu. commands.error.missingBotPermissions=Nie mam uprawnień, których potrzebuję do wykonania tego polecenia\!\n\n**Brakujące uprawnienia\:** {0} commands.error.user=Niestety, **wystąpił błąd** podczas przetwarzania polecenia. Proszę poinformować członka zespołu. @@ -92,6 +143,7 @@ paginator.button.group.switch=Następna Grupa paginator.button.less=Mniej paginator.footer.page=Strona {0}/{1} paginator.footer.group=Grupa {0}/{1} + permission.addReactions=Dodawanie reakcji permission.administrator=Administrator permission.all=Wszystkie uprawnienia @@ -100,6 +152,8 @@ permission.banMembers=Banowanie członków permission.changeNickname=Zmiana pseudonimu permission.connect=Połącz permission.createInstantInvite=Tworzenie zaproszenia +permission.createPrivateThreads=Create Private Threads +permission.createPublicThreads=Create Public Threads permission.deafenMembers=Wyciszanie członków permission.embedLinks=Wyświetlanie podglądu linku permission.kickMembers=Wyrzucanie członków @@ -109,6 +163,7 @@ permission.manageGuild=Zarządzanie serwerem permission.manageMessages=Zarządzanie wiadomościami permission.manageNicknames=Zarządzanie pseudonimami permission.manageRoles=Zarządzanie rolami +permission.manageThreads=Pokaż przegląd serwera permission.manageWebhooks=Zarządzanie webhookami permission.mentionEveryone=Zamieść wzmiankę @everyone, @here oraz wszystkie role permission.moveMembers=Przenoszenie członków @@ -117,6 +172,7 @@ permission.prioritySpeaker=Priorytetowy rozmówca permission.readMessageHistory=Czytanie historii czatu permission.requestToSpeak=Poproś o głos permission.sendMessages=Wysyłanie wiadomości +permission.sendMessagesInThreads=Send Messages In Threads permission.sendTTSMessages=Wysyłanie wiadomości TTS permission.speak=Mówienie permission.stream=Wideo @@ -126,6 +182,7 @@ permission.useVAD=Używanie Aktywności Głosowej permission.viewAuditLog=Wyświetlanie dziennika zdarzeń permission.viewChannel=Wyświetlanie kanału permission.viewGuildInsights=Pokaż przegląd serwera + utils.message.useThisChannel=Proszę użyć {0} dla tego polecenia. utils.message.commandNotAvailableInDm=To polecenie nie jest dostępne w prywatnych wiadomościach. utils.colors.black=czarny,blck,blk diff --git a/kord-extensions/src/main/resources/translations/kordex/strings_pt_PT.properties b/kord-extensions/src/main/resources/translations/kordex/strings_pt_PT.properties new file mode 100644 index 0000000000..9f84147fff --- /dev/null +++ b/kord-extensions/src/main/resources/translations/kordex/strings_pt_PT.properties @@ -0,0 +1,204 @@ +argumentParser.error.errorInArgument=**Erro no argumento** `{0}`**\:** {1} +argumentParser.error.requiresOneValue=O argumento `{0}` necessita de exatamente 1 valor, mas {1} foram fornecidos. +argumentParser.error.invalidValue=Invalid value for argument `{0}` (which accepts\: {1}) +argumentParser.error.notAllValid=O argumento `{0}` recebeu {1} {1, plural, \=1 {valor} other {valores}}, porém {2, plural, \=0 {nenhum deles foram válores válidos.} other { somente {2} deles foram válores válidos.}} {3} valores. +argumentParser.error.unknownConverterType=Um conversor de tipo desconhecido foi fornecido\: `{0}` +argumentParser.error.noFilledArguments=Esse comando necessita de {0} {0, plural, one {} \=1 {argumento} other {argumentos}}. +argumentParser.error.someFilledArguments=Esse comando necessita de {0} {0, plural, one {} \=1 {argumento} other {argumentos}}, porém somente {1} foi completado(s). + +channelType.dm=DM +channelType.groupDm=Group DM +channelType.guildCategory=Category +channelType.guildNews=News +channelType.guildStageVoice=Stage +channelType.guildStore=Store +channelType.guildText=Text +channelType.guildVoice=Voice +channelType.publicNewsThread=News Thread +channelType.publicGuildThread=Public Thread +channelType.privateThread=Private Thread +channelType.unknown=Unknown + +checks.responseTemplate=**Error\:** {0} + +checks.inChannel.failed=Must be in **{0}** +checks.notInChannel.failed=Must not be in **{0}** +checks.inCategory.failed=Must be in category\: **{0}** +checks.notInCategory.failed=Must not be in category\: **{0}** +checks.channelHigher.failed=Must be in a channel higher than **{0}** +checks.channelLower.failed=Must be in a channel lower than **{0}** +checks.channelHigherOrEqual.failed=Must be in **{0}**, or a higher channel +checks.channelLowerOrEqual.failed=Must be in **{0}**, or a lower channel + +checks.anyGuild.failed=Must be in a server +checks.noGuild.failed=Must not be in a server +checks.inGuild.failed=Must be in server\: **{0}** +checks.notInGuild.failed=Must not be in server\: **{0}** + +checks.channelType.failed=Must be in a channel of type\: **{0}** +checks.notChannelType.failed=Must not be in a channel of type\: **{0}** + +checks.hasPermission.failed=Must have permission\: **{0}** +checks.notHasPermission.failed=Must not have permission\: **{0}** + +checks.isBot.failed=Must be a bot +checks.isNotBot.failed=Must not be a bot + +checks.isInThread.failed=Must be in a thread +checks.isNotInThread.failed=Must not be in a thread + +checks.hasRole.failed=Must have role\: **{0}** +checks.notHasRole.failed=Must not have role\: **{0}** +checks.topRoleEqual.failed=Must have top role\: **{0}** +checks.topRoleNotEqual.failed=Must not have top role\: **{0}** +checks.topRoleHigher.failed=Must have a top role higher than\: **{0}** +checks.topRoleLower.failed=Must have a top role lower than\: **{0}** +checks.topRoleHigherOrEqual.failed=Must have a top role of **{0}**, or a higher top role +checks.topRoleLowerOrEqual.failed=Must have a top role of **{0}**, or a lower top role + +commands.defaultDescription=Nenhuma descrição fornecida. +commands.error.missingBotPermissions=Eu não tenho as permissões necessárias para executar esse comando\!\n\n**Permissões necessárias\:** {0} +commands.error.user=Infelizmente, **um erro ocorreu** durante o processamento do comando. Por favor, avise um moderador. +commands.error.user.sentry.message=Infelizmente, **ocorreu um erro** durante o processamento do comando. Se você gostaria de enviar informações sobre o que estava fazendo quando este erro aconteceu, por favor use o seguinte comando\: ```{0}feedback {1} ``` +commands.error.user.sentry.slash=Infelizmente, **ocorreu um erro** durante o processamento do comando. Se você gostaria de enviar informações sobre o que estava fazendo quando este erro aconteceu, por favor use o seguinte comando\: ```{0}feedback {1} ``` +converters.boolean.signatureType=sim/não +converters.boolean.errorType=`sim` ou `não` +converters.channel.signatureType=canal +converters.channel.error.missing=Não foi possível encontrar o canal\: {0} +converters.channel.error.invalid=`{0}` não é uma ID de canal válida. +converters.color.error.unknown=Unknown color\: `{0}` +converters.color.error.unknownOrFailed=Failed to parse color\: `{0}` +converters.color.signatureType=cor +converters.decimal.signatureType=decimal +converters.decimal.error.invalid=`{0}` não é um número decimal válido. +converters.duration.error.signatureType=duração +converters.duration.error.badUnitPairs=Você deve fornecer o mesmo número de unidades e valores. Números planos não são suportados. +converters.duration.error.invalidUnit=Uma unidade de tempo inválida foi especificada\: `{0}` +converters.duration.error.negativeUnsupported=Valores negativos não são suportados. +converters.duration.error.positiveOnly=É necessária uma unidade de tempo positiva. +converters.duration.help=__Como usar as unidades de tempo__\n\nUnidades de tempo são especificadas em pares de quantidades e unidades - por exemplo, `12d` são 12 dias.\nUnidades de tempo podem ser compostas (seguindo abreviações de unidades anglo-saxônicas) - por exemplo, `2w 3d` seria 2 semanas (weeks, em inglês) e 3 dias.\n\nAs seguintes unidades são suportadas\:\n\n**Segundos\:** `s`, `seg`, `segundo`, `segundos`\n**Minutos\:** `m`, `mi`, `min`, `minuto`, `minutos`\n**Horas\:** `h`, `hora`, `horas`\n**Dias\:** `d`, `dia`, `dias`\n**Semanas\:** `s`, `semana`, `semanas`\n**Meses\:** `me`, `mês`, `meses`\n**Anos\:** `a`, `ano`, `anos` +converters.email.signatureType=email +converters.email.error.invalid=Endereço de e-mail inválido especificado\: `{0}` +converters.emoji.signatureType=emoji do servidor +converters.emoji.error.missing=Não foi possível encontrar o emoji\: `{0}` +converters.emoji.error.invalid=`{0}` não é um ID de emoji válido. +converters.guild.signatureType=servidor +converters.guild.error.missing=Não foi possível encontrar o servidor\: `{0}` +converters.number.signatureType=número +converters.number.error.invalid.defaultBase=`{0}` não é um número inteiro válido. +converters.number.error.invalid.otherBase=O valor `{0}` não é um número inteiro válido em {1, plural, one {} \=2 {binário (base 2)} \=8 {octal (base 8)} \=10 {decimal (base 10)} \=16 {hexadecimal (base 16)} other {base {1}} }. +converters.member.signatureType=membro +converters.member.error.missing=Não foi possível encontrar o membro\: {0} +converters.member.error.invalid=`{0}` não é uma ID de membro válida. +converters.message.signatureType=mensagem +converters.message.error.invalidUrl=URL de mensagem inválida fornecida\: <{0}> +converters.message.error.invalidGuildId=`{0}` não é uma ID de servidor válida. +converters.message.error.invalidChannelId=`{0}` não é uma ID de canal válida. +converters.message.error.invalidMessageId=`{0}` não é uma ID de mensagem válida. +converters.message.error.missing=Não foi possível encontrar a mensagem\: `{0}` +converters.regex.signatureType.singular=regex +converters.regex.signatureType.plural=regexes +converters.role.signatureType=cargo +converters.role.error.missing=Não foi possível encontrar o cargo\: `{0}` +converters.role.error.invalid=`{0}` não é uma ID de cargo válida. +converters.snowflake.signatureType=ID +converters.snowflake.error.invalid=Valor `{0}` não é um ID do Discord válido. +converters.string.signatureType=texto +converters.supportedLocale.signatureType=nome/código da localidade +converters.supportedLocale.error.unknown=Local desconhecido (ou não suportado)\: `{0}` +converters.union.error.unknownConverterType=Um conversor de tipo desconhecido foi fornecido\: `{0}` +converters.user.signatureType=usuário +converters.user.error.missing=Não foi possível encontrar o usuário\: `{0}` +converters.user.error.invalid=`{0}` não é uma ID de usuário válida. +extensions.help.commandName=ajuda +extensions.help.commandAliases=a +extensions.help.commandDescription=Obtenha ajuda.\n\nEspecifique o nome de um comando para obter ajuda para esse comando específico. Os subcomandos também podem ser especificados da mesma forma que você os executa. +extensions.help.commandArguments.command=Comando para obter ajuda +extensions.help.commandDescription.aliases=**Aliases\:** +extensions.help.commandDescription.subCommands=**Subcomandos\:** +extensions.help.commandDescription.requiredBotPermissions=**Permissões requeridas pelo bot\:** +extensions.help.commandDescription.noArguments=Sem argumentos. +extensions.help.commandDescription.error.argumentList=Falha ao recuperar a lista de argumentos devido a um erro. +extensions.help.paginator.title.command=Comando\: {0} +extensions.help.paginator.title.commands=Comandos +extensions.help.paginator.title.arguments=Argumentos de Comando +extensions.help.paginator.footer={0} {0, plural, one {} \=1 {comando} other {comandos}} em disposição +extensions.help.paginator.noCommands=Nenhum comando encontrado. +extensions.help.error.missingCommandTitle=Comando não encontrado +extensions.help.error.missingCommandDescription=Não foi possível encontrar esse comando. Isto pode ser por uma das várias razões possíveis\: \n\n**»** O comando não existe ou não carregou corretamente\n**»** O comando não está disponível neste contexto\n**»** Você não tem acesso ao comando\n\nSe você acha que isso está incorreto, por favor, entre em contato com um moderador. +extensions.sentry.arguments.id=ID de evento sentinela +extensions.sentry.arguments.feedback=Feedback para enviar aos desenvolvedores +extensions.sentry.converter.sentryId.signatureType=UUID +extensions.sentry.converter.error.invalid=ID inválido de evento de sentinela especificado\: `{0}` +extensions.sentry.commandName=feedback +extensions.sentry.commandAliases=feedback-sentinela +extensions.sentry.commandDescription.short=Forneça feedback sobre o que você estava fazendo quando ocorreu um erro. +extensions.sentry.commandDescription.long=Se você recebeu um ID de sentinela pelo bot, você pode enviar comentários sobre para que estava usando este comando.\n\nSeu feedback idealmente deve incluir uma descrição do que você estava fazendo quando o erro ocorreu e o que você esperava que acontecesse. Mas o texto do seu feedback depende de você.\n\n**Nota\:** O feedback é inteiramente opcional, e você não deve se sentir obrigado a enviar feedback se você não quiser - se você recebeu um ID de evento, o erro já foi enviado\! +extensions.sentry.error.invalidId=O ID sentinela que você forneceu não existe ou não está aguardando feedback. +extensions.sentry.thanks=Obrigado pelo seu feedback - vamos usá-lo para melhorar o nosso bot e corrigir o erro que você encontrou\! +paginator.button.delete=Apagar +paginator.button.done=OK +paginator.button.more=Mais +paginator.button.group.switch=Próximo grupo +paginator.button.less=Menos +paginator.footer.page=Página {0}/{1} +paginator.footer.group=Grupo {0}/{1} + +permission.addReactions=Adicionar reações +permission.administrator=Administrador +permission.all=Todas as permissões +permission.attachFiles=Anexar arquivos +permission.banMembers=Banir Membros +permission.changeNickname=Alterar apelido +permission.connect=Conectar (voz) +permission.createInstantInvite=Criar convites +permission.createPrivateThreads=Create Private Threads +permission.createPublicThreads=Create Public Threads +permission.deafenMembers=Ensurdecer Membros +permission.embedLinks=Incorporar Links +permission.kickMembers=Expulsar membros +permission.manageChannels=Gerenciar canais +permission.manageEmojis=Gerenciar emojis +permission.manageGuild=Gerenciar servidor +permission.manageMessages=Gerenciar Mensagens +permission.manageNicknames=Gerenciar apelidos +permission.manageRoles=Gerenciar Cargos +permission.manageThreads=Ver estatísticas do servidor +permission.manageWebhooks=Gerenciar webhooks +permission.mentionEveryone=Mencionar Todos +permission.moveMembers=Mover membros +permission.muteMembers=Silenciar membros +permission.prioritySpeaker=Falante Prioritário +permission.readMessageHistory=Ver Histórico de Mensagens +permission.requestToSpeak=Pedido de palavra +permission.sendMessages=Enviar mensagens +permission.sendMessagesInThreads=Send Messages In Threads +permission.sendTTSMessages=Enviar mensagens em TTS +permission.speak=Falar (voz) +permission.stream=Vídeo +permission.useExternalEmojis=Usar emojis externos +permission.useSlashCommands=Usar comandos barra (\\) +permission.useVAD=Usar detecção de voz +permission.viewAuditLog=Visualizar registro de auditoria +permission.viewChannel=Ver Canal +permission.viewGuildInsights=Ver estatísticas do servidor + +utils.message.useThisChannel=Por favor, use {0} para este comando. +utils.message.commandNotAvailableInDm=Este comando não está disponível através de mensagens privadas. +utils.colors.black=preto,pt +utils.colors.blurple=desfoque,roxo,rx +utils.colors.fuchsia=fuchsia,rosa,rs +utils.colors.green=verde,vd +utils.colors.red=vermelho,vm +utils.colors.white=branco,br +utils.colors.yellow=amarelo,am +utils.durations.ignoredWords=e +utils.units.year=a, ano, anos +utils.units.month=me, mes, meses +utils.units.week=s, semana, semanas +utils.units.day=d, dia, dias +utils.units.hour=h, hora, horas +utils.units.minute=m, mi, min, mins, minuto, minutos +utils.units.second=s, sec, secs, second, seconds +utils.string.false=0, n, não, f, falso +utils.string.true=1, s, sim, t, verdadeiro diff --git a/kord-extensions/src/main/resources/translations/kordex/strings_ru_RU.properties b/kord-extensions/src/main/resources/translations/kordex/strings_ru_RU.properties index a859e689b7..54e8dd6278 100644 --- a/kord-extensions/src/main/resources/translations/kordex/strings_ru_RU.properties +++ b/kord-extensions/src/main/resources/translations/kordex/strings_ru_RU.properties @@ -5,6 +5,47 @@ argumentParser.error.notAllValid=Для аргумента `{0}` дано {1} {1 argumentParser.error.unknownConverterType=Указан неизвестный тип конвертера\: `{0}` argumentParser.error.noFilledArguments=У команды {0} {0, plural, one {обязательный аргумент} few {обязательных аргумента} other {обязательных аргументов}}. argumentParser.error.someFilledArguments=У команды {0} {0, plural, one {обязательный аргумент} few {обязательных аргумента} other {обязательных аргументов}}, но удалось заполнить только {1}. +channelType.dm=ЛС +channelType.groupDm=Групповые ЛС +channelType.guildCategory=Категория +channelType.guildNews=Новости +channelType.guildStageVoice=Трибуна +channelType.guildStore=Store +channelType.guildText=Text +channelType.guildVoice=Voice +channelType.publicNewsThread=News Thread +channelType.publicGuildThread=Public Thread +channelType.privateThread=Private Thread +channelType.unknown=Неизвестный +checks.responseTemplate=**Ошибка\:** {0} +checks.inChannel.failed=Must be in **{0}** +checks.notInChannel.failed=Must not be in **{0}** +checks.inCategory.failed=Разрешено только в категории\: **{0}** +checks.notInCategory.failed=Не разрешено в категории\: **{0}** +checks.channelHigher.failed=Must be in a channel higher than **{0}** +checks.channelLower.failed=Must be in a channel lower than **{0}** +checks.channelHigherOrEqual.failed=Must be in **{0}**, or a higher channel +checks.channelLowerOrEqual.failed=Must be in **{0}**, or a lower channel +checks.anyGuild.failed=Разрешено только на сервере +checks.noGuild.failed=Не разрешено на сервере +checks.inGuild.failed=Разрешено только на сервере\: **{0}** +checks.notInGuild.failed=Не разрешено на сервере\: **{0}** +checks.channelType.failed=Тип канала должен быть **{0}** +checks.notChannelType.failed=Тип канала не должен быть **{0}** +checks.hasPermission.failed=Должно быть разрешение\: **{0}** +checks.notHasPermission.failed=Не должно быть разрешения\: **{0}** +checks.isBot.failed=Разрешено только для бота +checks.isNotBot.failed=Не разрешено для бота +checks.isInThread.failed=Разрешено только в ветке +checks.isNotInThread.failed=Не разрешено в ветке +checks.hasRole.failed=Должна быть роль\: **{0}** +checks.notHasRole.failed=Не дложно быть роли\: **{0}** +checks.topRoleEqual.failed=Верхняя роль должна быть\: **{0}** +checks.topRoleNotEqual.failed=Верхняя роль не должна быть\: **{0}** +checks.topRoleHigher.failed=Верхняя роль должна быть выше, чем **{0}** +checks.topRoleLower.failed=Верхняя роль должна быть ниже, чем **{0}** +checks.topRoleHigherOrEqual.failed=Верхняя роль должна быть **{0}** или выше +checks.topRoleLowerOrEqual.failed=Верхняя роль должна быть **{0}** или ниже commands.defaultDescription=Описание отсутствует. commands.error.missingBotPermissions=У меня не хватает разрешений для выполнения этой команды\!\n\n**Требуются разрешения\:** {0} commands.error.user=К сожалению, во время исполнения команды **произошла ошибка**. Сообщите об этом персоналу сервера. @@ -35,7 +76,7 @@ converters.guild.signatureType=сервер converters.guild.error.missing=Не могу найти сервер\: `{0}` converters.number.signatureType=число converters.number.error.invalid.defaultBase=Не могу распознать `{0}` как число. -converters.number.error.invalid.otherBase=Не могу распознать `{0}` как {1, plural, \=2 {двоичное число} \=8 {восьмеричное число} \=10 {десятичное число} \=16 {шестнадцатеричное число} other {число в системе счисления с основанием {1}}}. +converters.number.error.invalid.otherBase=Не могу распознать `{0}` как {1, plural, =2 {двоичное число} =8 {восьмеричное число} =10 {десятичное число} =16 {шестнадцатеричное число} other {число в системе счисления с основанием {1}}}. converters.member.signatureType=член converters.member.error.missing=Не могу найти члена\: {0} converters.member.error.invalid=Не могу распознать `{0}` как ID пользователя. @@ -100,6 +141,8 @@ permission.banMembers=Банить участников permission.changeNickname=Изменить никнейм permission.connect=Подключаться (в голосовой канал) permission.createInstantInvite=Создание приглашения +permission.createPrivateThreads=Создавать публичные ветки +permission.createPublicThreads=Создавать приватные ветки permission.deafenMembers=Отключать участникам звук permission.embedLinks=Встраивать ссылки permission.kickMembers=Выгонять участников @@ -109,6 +152,7 @@ permission.manageGuild=Управлять сервером permission.manageMessages=Управлять сообщениями permission.manageNicknames=Управлять никнеймами permission.manageRoles=Управлять ролями +permission.manageThreads=Просматривать аналитику сервера permission.manageWebhooks=Управлять вебхуками (webhooks) permission.mentionEveryone=Упоминание `@everyone`, `@here` и всех ролей permission.moveMembers=Перемещать участников (голосовой канал) @@ -117,6 +161,7 @@ permission.prioritySpeaker=Приоритетный режим (голосово permission.readMessageHistory=Читать историю сообщений permission.requestToSpeak=Попросить выступить permission.sendMessages=Отправлять сообщения +permission.sendMessagesInThreads=Отправлять сообщения в ветках permission.sendTTSMessages=Отправка сообщения text-to-speech permission.speak=Говорить (голосовой канал) permission.stream=Видео diff --git a/kord-extensions/src/main/resources/translations/kordex/strings_zh_CN.properties b/kord-extensions/src/main/resources/translations/kordex/strings_zh_CN.properties index 5218c712a0..57285adebb 100644 --- a/kord-extensions/src/main/resources/translations/kordex/strings_zh_CN.properties +++ b/kord-extensions/src/main/resources/translations/kordex/strings_zh_CN.properties @@ -5,6 +5,47 @@ argumentParser.error.notAllValid=给参数`{0}`提供了{1}个值,但其中{2, argumentParser.error.unknownConverterType=指定了未知的转换器类型:`{0}` argumentParser.error.noFilledArguments=此指令需要{0}个参数。 argumentParser.error.someFilledArguments=此命令需要{0}个参数,但只填入了{1}个参数。 +channelType.dm=私信 +channelType.groupDm=群组私信 +channelType.guildCategory=类别 +channelType.guildNews=新闻 +channelType.guildStageVoice=讲堂 +channelType.guildStore=Store +channelType.guildText=文字 +channelType.guildVoice=语音 +channelType.publicNewsThread=新闻子区 +channelType.publicGuildThread=公共子区 +channelType.privateThread=私密子区 +channelType.unknown=未知 +checks.responseTemplate=**错误**:{0} +checks.inChannel.failed=必须在**{0}**中 +checks.notInChannel.failed=必须不在**{0}**中 +checks.inCategory.failed=必须在类别**{0}**中 +checks.notInCategory.failed=必须不在类别**{0}**中 +checks.channelHigher.failed=必须在一个高于**{0}**的频道中 +checks.channelLower.failed=必须不在一个低于**{0}**的频道中 +checks.channelHigherOrEqual.failed=必须在**{0}**或更高的频道 +checks.channelLowerOrEqual.failed=必须在**{0}**或更低的频道 +checks.anyGuild.failed=必须在服务器中 +checks.noGuild.failed=必须不在服务器中 +checks.inGuild.failed=必须在**{0}**服务器中 +checks.notInGuild.failed=必须不在**{0}**服务器中 +checks.channelType.failed=必须在**{0}**型频道内 +checks.notChannelType.failed=必须不在**{0}**型频道内 +checks.hasPermission.failed=必须有权限:**{0}** +checks.notHasPermission.failed=必须没有权限:**{0}** +checks.isBot.failed=必须是机器人 +checks.isNotBot.failed=必须不是机器人 +checks.isInThread.failed=必须在分区内 +checks.isNotInThread.failed=必须不在分区内 +checks.hasRole.failed=必须属于身份组**{0}** +checks.notHasRole.failed=必须不属于身份组**{0}** +checks.topRoleEqual.failed=最高身份组必须为**{0}** +checks.topRoleNotEqual.failed=最高身份组必须不为**{0}** +checks.topRoleHigher.failed=最高身份组必须高于**{0}** +checks.topRoleLower.failed=最高身份组必须低于**{0}** +checks.topRoleHigherOrEqual.failed=最高身份组必须为**{0}**或更高 +checks.topRoleLowerOrEqual.failed=最高身份组必须为**{0}**或更低 commands.defaultDescription=未提供说明。 commands.error.missingBotPermissions=我没有运行该指令的权限!\n\n**缺少权限:**{0} commands.error.user=不好意思,在处理指令时**发生了错误**。请告知服务器的管理人员。 @@ -100,6 +141,8 @@ permission.banMembers=封禁成员 permission.changeNickname=修改昵称 permission.connect=连接至语音 permission.createInstantInvite=创建邀请 +permission.createPrivateThreads=Create Private Threads +permission.createPublicThreads=Create Public Threads permission.deafenMembers=屏蔽成员语音接收 permission.embedLinks=嵌入链接 permission.kickMembers=踢除成员 @@ -109,6 +152,7 @@ permission.manageGuild=管理服务器 permission.manageMessages=管理消息 permission.manageNicknames=管理昵称 permission.manageRoles=管理身份组 +permission.manageThreads=查看服务器分析 permission.manageWebhooks=管理 webhooks permission.mentionEveryone=@全体成员 permission.moveMembers=移动成员 @@ -117,6 +161,7 @@ permission.prioritySpeaker=主要发言者 permission.readMessageHistory=阅读消息历史记录 permission.requestToSpeak=请求发言 permission.sendMessages=发送消息 +permission.sendMessagesInThreads=Send Messages In Threads permission.sendTTSMessages=发送文字转语音消息 permission.speak=语音讲话 permission.stream=视频 diff --git a/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/TimestampConverterTest.kt b/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/TimestampConverterTest.kt new file mode 100644 index 0000000000..2a6249e720 --- /dev/null +++ b/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/TimestampConverterTest.kt @@ -0,0 +1,39 @@ +package com.kotlindiscord.kord.extensions.commands.converters.impl + +import com.kotlindiscord.kord.extensions.time.TimestampType +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +internal class TimestampConverterTest { + + @Test + fun `timestamp without format`() { + val timestamp = "" // 1st second of 2015 + val parsed = TimestampConverter.parseFromString(timestamp)!! + assertEquals(Instant.fromEpochSeconds(1_420_070_400), parsed.instant) + assertEquals(TimestampType.Default, parsed.format) + } + + @Test + fun `timestamp with format`() { + val timestamp = "" + val parsed = TimestampConverter.parseFromString(timestamp)!! + assertEquals(Instant.fromEpochSeconds(1_420_070_400), parsed.instant) + assertEquals(TimestampType.RelativeTime, parsed.format) + } + + @Test + fun `empty timestamp`() { + val timestamp = "" + val parsed = TimestampConverter.parseFromString(timestamp) + assertNull(parsed) + } + + @Test + fun `timestamp with empty format`() { + val timestamp = "" + val parsed = TimestampConverter.parseFromString(timestamp) + assertNull(parsed) + } +} diff --git a/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt b/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt index d7be02be70..157e71f20c 100644 --- a/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt +++ b/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt @@ -1,18 +1,19 @@ package com.kotlindiscord.kord.extensions.test.bot import com.kotlindiscord.kord.extensions.ExtensibleBot -import com.kotlindiscord.kord.extensions.checks.isNotbot +import com.kotlindiscord.kord.extensions.checks.isNotBot import com.kotlindiscord.kord.extensions.utils.env import org.koin.core.logger.Level suspend fun main() { - val bot = ExtensibleBot(env("TOKEN")!!) { + val bot = ExtensibleBot(env("TOKEN")) { koinLogLevel = Level.DEBUG - messageCommands { + chatCommands { defaultPrefix = "?" + enabled = true - check(isNotbot) + check { isNotBot() } prefix { default -> if (guildId?.asString == "787452339908116521") { @@ -23,8 +24,16 @@ suspend fun main() { } } - slashCommands { - enabled = true + applicationCommands { + defaultGuild("787452339908116521") + } + + intents { +// +Intent.GuildMessages + } + + members { + none() } extensions { diff --git a/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/TestChoiceEnum.kt b/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/TestChoiceEnum.kt index 138f8d9f5f..5091e39b44 100644 --- a/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/TestChoiceEnum.kt +++ b/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/TestChoiceEnum.kt @@ -1,6 +1,6 @@ package com.kotlindiscord.kord.extensions.test.bot -import com.kotlindiscord.kord.extensions.commands.slash.converters.ChoiceEnum +import com.kotlindiscord.kord.extensions.commands.application.slash.converters.ChoiceEnum enum class TestChoiceEnum(override val readableName: String) : ChoiceEnum { ONE("first"), diff --git a/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/TestExtension.kt b/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/TestExtension.kt index 4f243dd1cd..6e0c35c548 100644 --- a/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/TestExtension.kt +++ b/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/TestExtension.kt @@ -1,30 +1,39 @@ -@file:OptIn(KordPreview::class, TranslationNotSupported::class) +@file:OptIn(KordPreview::class, ExperimentalTime::class) package com.kotlindiscord.kord.extensions.test.bot +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.application.slash.converters.impl.enumChoice +import com.kotlindiscord.kord.extensions.commands.application.slash.ephemeralSubCommand +import com.kotlindiscord.kord.extensions.commands.application.slash.group +import com.kotlindiscord.kord.extensions.commands.application.slash.publicSubCommand import com.kotlindiscord.kord.extensions.commands.converters.impl.* -import com.kotlindiscord.kord.extensions.commands.parser.Arguments -import com.kotlindiscord.kord.extensions.commands.slash.AutoAckType -import com.kotlindiscord.kord.extensions.commands.slash.TranslationNotSupported -import com.kotlindiscord.kord.extensions.commands.slash.converters.impl.enumChoice -import com.kotlindiscord.kord.extensions.extensions.Extension -import com.kotlindiscord.kord.extensions.pagination.InteractionButtonPaginator +import com.kotlindiscord.kord.extensions.components.* +import com.kotlindiscord.kord.extensions.components.types.emoji +import com.kotlindiscord.kord.extensions.extensions.* import com.kotlindiscord.kord.extensions.pagination.MessageButtonPaginator import com.kotlindiscord.kord.extensions.pagination.pages.Page import com.kotlindiscord.kord.extensions.pagination.pages.Pages +import com.kotlindiscord.kord.extensions.types.editingPaginator +import com.kotlindiscord.kord.extensions.types.respond import com.kotlindiscord.kord.extensions.utils.respond import dev.kord.common.annotation.KordPreview import dev.kord.common.entity.ButtonStyle import dev.kord.common.entity.Permission import dev.kord.core.behavior.channel.createEmbed import dev.kord.core.behavior.reply +import dev.kord.core.event.guild.GuildCreateEvent import dev.kord.rest.builder.message.create.embed +import mu.KotlinLogging +import kotlin.time.ExperimentalTime // They're IDs @Suppress("UnderscoresInNumericLiterals") class TestExtension : Extension() { override val name = "test" + val logger = KotlinLogging.logger {} + class ColorArgs : Arguments() { val color by colour("color", "Color to use for the embed") } @@ -69,8 +78,67 @@ class TestExtension : Extension() { val message by message("target", "Target message") } + class UserArgs : Arguments() { + val user by user("target", "Target user") + } + override suspend fun setup() { - command(::ColorArgs) { + event { + action { + logger.info { "Guild created: ${event.guild.name} (${event.guild.id.asString})" } + } + } + + publicSlashCommand(::UserArgs) { + name = "slap" + description = "Slap someone!" + + action { + respond { content = "*slaps ${arguments.user.mention}*" } + } + } + + publicMessageCommand { + name = "Raw Info" + + check { + failIf("This message command only supports non-webhook, non-interaction messages.") { + event.interaction.messages.values.firstOrNull()?.author == null + } + } + + action { + val message = targetMessages.firstOrNull() ?: return@action + + respond { + content = "**Message command:** Raw content for message sent by ${message.author!!.mention}" + + embed { + description = "```markdown\n${message.content}```" + } + } + } + } + + publicUserCommand { + name = "ping" + + check { + failIf("That's me, you can't make me ping myself!") { + event.interaction.users.values.firstOrNull()?.id == kord.selfId + } + } + + action { + val user = targetUsers.firstOrNull() ?: return@action + + respond { + content = "Let's ping ${user.mention} for no reason. <3" + } + } + } + + chatCommand(::ColorArgs) { name = "color" aliases = arrayOf("colour") description = "Get an embed with a set color" @@ -86,7 +154,7 @@ class TestExtension : Extension() { } } - command(::MessageArgs) { + chatCommand(::MessageArgs) { name = "msg" description = "Message argument test" @@ -97,7 +165,7 @@ class TestExtension : Extension() { } } - command(::CoalescedArgs) { + chatCommand(::CoalescedArgs) { name = "coalesce" description = "Coalesce me, baby" @@ -115,7 +183,7 @@ class TestExtension : Extension() { } } - command { + chatCommand { name = "dropdown" description = "Dropdown test!" @@ -123,9 +191,8 @@ class TestExtension : Extension() { message.respond { content = "Here's a dropdown." - components(60) { - menu { - autoAck = AutoAckType.PUBLIC + components { + publicSelectMenu { maximumChoices = null option("Option 1", "one") @@ -133,7 +200,7 @@ class TestExtension : Extension() { option("Option 3", "three") action { - publicFollowUp { + respond { content = "You picked the following options: " + selected.joinToString { "`$it`" } @@ -145,31 +212,28 @@ class TestExtension : Extension() { } } - slashCommand { + ephemeralSlashCommand { name = "pages" description = "Pages!" - autoAck = AutoAckType.PUBLIC guild(787452339908116521) action { - val pages = Pages() + editingPaginator("short") { + owner = event.interaction.user.asUser() + timeoutSeconds = 60 + keepEmbed = false - (0..2).forEach { - pages.addPage( - Page { + (0..2).forEach { + page { description = "Short page $it." footer { text = "Footer text ($it)" } } - ) - - pages.addPage( - "Expanded", - Page { + page("Expanded") { description = "Expanded page $it, expanded page $it\n" + "Expanded page $it, expanded page $it" @@ -177,12 +241,8 @@ class TestExtension : Extension() { text = "Footer text ($it)" } } - ) - pages.addPage( - "MASSIVE GROUP", - - Page { + page("MASSIVE GROUP") { description = "MASSIVE PAGE $it, MASSIVE PAGE $it\n" + "MASSIVE PAGE $it, MASSIVE PAGE $it\n" + "MASSIVE PAGE $it, MASSIVE PAGE $it\n" + @@ -193,47 +253,36 @@ class TestExtension : Extension() { text = "Footer text ($it)" } } - ) - } - - val paginator = InteractionButtonPaginator( - extension = this@TestExtension, - pages = pages, - owner = event.interaction.user.asUser(), - timeoutSeconds = 60, - parentContext = this, - keepEmbed = false - ) - - paginator.send() + } + }.send() } } - slashCommand { + ephemeralSlashCommand { name = "buttons" description = "Buttons!" guild(787452339908116521) // Our test server action { - ephemeralFollowUp { + respond { content = "Buttons!" - components(60) { - interactiveButton { + components { + ephemeralButton { label = "Button one!" action { - respond("Button one pressed!") + respond { content = "Button one pressed!" } } } - interactiveButton { + ephemeralButton { label = "Button two!" style = ButtonStyle.Secondary action { - respond("Button two pressed!") + respond { content = "Button two pressed!" } } } @@ -252,40 +301,37 @@ class TestExtension : Extension() { } } - slashCommand { + publicSlashCommand { name = "test-noack" description = "Don't auto-ack this one" - autoAck = AutoAckType.NONE guild(787452339908116521) // Our test server - action { - ack(false) // Public ack - - publicFollowUp { - embed { - title = "An embed!" - description = "With a description, and without a content string!" - } + initialResponse { + embed { + title = "An embed!" + description = "With a description, and without a content string!" } } + + action { + } } - slashCommand(::SlashChoiceArgs) { + publicSlashCommand(::SlashChoiceArgs) { name = "choice" description = "Choice-based" - autoAck = AutoAckType.PUBLIC guild(787452339908116521) // Our test server action { - publicFollowUp { + respond { content = "Your choice: ${arguments.arg.readableName} -> ${arguments.arg.name}" } } } - slashCommand { + ephemeralSlashCommand { name = "group" description = "Test command, please ignore" @@ -294,14 +340,12 @@ class TestExtension : Extension() { group("one") { description = "Group one" - subCommand(::SlashArgs) { + publicSubCommand(::SlashArgs) { name = "test" description = "Test command, please ignore" - autoAck = AutoAckType.PUBLIC - action { - publicFollowUp { + respond { content = "Some content" embed { @@ -333,12 +377,12 @@ class TestExtension : Extension() { } } - subCommand { + ephemeralSubCommand { name = "test-two" description = "Test command, please ignore" action { - ephemeralFollowUp { + respond { content = "Some content" } } @@ -346,7 +390,7 @@ class TestExtension : Extension() { } } - slashCommand { + ephemeralSlashCommand { name = "guild-embed" description = "Test command, please ignore" @@ -355,14 +399,12 @@ class TestExtension : Extension() { group("first") { description = "First group." - subCommand(::SlashArgs) { + publicSubCommand(::SlashArgs) { name = "inner-test" description = "Test command, please ignore" - autoAck = AutoAckType.PUBLIC - action { - publicFollowUp { + respond { embed { title = "Guild response" description = "Guild description" @@ -394,17 +436,16 @@ class TestExtension : Extension() { } } - slashCommand(::SlashArgs) { + publicSlashCommand(::SlashArgs) { name = "test-embed" description = "Test command, please ignore\n\n" + "Now with some newlines in the description!" - autoAck = AutoAckType.PUBLIC guild(787452339908116521) // Our test server action { - publicFollowUp { + respond { embed { title = "Test response" description = "Test description" @@ -434,7 +475,7 @@ class TestExtension : Extension() { } } - command { + chatCommand { name = "translation-test" description = "Let's test translations." @@ -444,18 +485,18 @@ class TestExtension : Extension() { } } - command { + chatCommand { name = "requires-perms" description = "A command that requires some permissions" - requirePermissions(Permission.Administrator) + requireBotPermissions(Permission.Administrator) action { message.respond("Looks like I'm an admin. Nice!") } } - command(::TestArgs) { + chatCommand(::TestArgs) { name = "test" description = "Test command, please ignore\n\n" + @@ -496,7 +537,7 @@ class TestExtension : Extension() { } } - command(::TestArgs) { + chatCommand(::TestArgs) { name = "test-help" description = "Sends help for this command.\n\n" + @@ -507,68 +548,63 @@ class TestExtension : Extension() { } } - command { + chatCommand { name = "page" description = "Paginator test" action { - val pages = Pages(defaultGroup = "short") + paginator("short", targetMessage = event.message) { + keepEmbed = true + owner = user + locale = getLocale() - (0..2).forEach { - pages.addPage( - "short", + (0..2).forEach { + page( + "short", - Page { - description = "Short page $it." + Page { + description = "Short page $it." - footer { - text = "Footer text ($it)" + footer { + text = "Footer text ($it)" + } } - } - ) + ) - pages.addPage( - "expanded", + page( + "expanded", - Page { - description = "Expanded page $it, expanded page $it\n" + - "Expanded page $it, expanded page $it" + Page { + description = "Expanded page $it, expanded page $it\n" + + "Expanded page $it, expanded page $it" - footer { - text = "Footer text ($it)" + footer { + text = "Footer text ($it)" + } } - } - ) + ) - pages.addPage( - "massive", + page( + "massive", - Page { - description = "MASSIVE PAGE $it, MASSIVE PAGE $it\n" + - "MASSIVE PAGE $it, MASSIVE PAGE $it\n" + - "MASSIVE PAGE $it, MASSIVE PAGE $it\n" + - "MASSIVE PAGE $it, MASSIVE PAGE $it\n" + - "MASSIVE PAGE $it, MASSIVE PAGE $it" + Page { + description = "MASSIVE PAGE $it, MASSIVE PAGE $it\n" + + "MASSIVE PAGE $it, MASSIVE PAGE $it\n" + + "MASSIVE PAGE $it, MASSIVE PAGE $it\n" + + "MASSIVE PAGE $it, MASSIVE PAGE $it\n" + + "MASSIVE PAGE $it, MASSIVE PAGE $it" - footer { - text = "Footer text ($it)" + footer { + text = "Footer text ($it)" + } } - } - ) - } - - MessageButtonPaginator( - extension = this@TestExtension, - targetMessage = event.message, - pages = pages, - keepEmbed = true, - owner = user, - locale = getLocale() - ).send() + ) + } + }.send() } } - command { + chatCommand { name = "page2" description = "Paginator test 2" @@ -601,7 +637,6 @@ class TestExtension : Extension() { } MessageButtonPaginator( - extension = this@TestExtension, targetMessage = event.message, pages = pages, keepEmbed = false, @@ -611,11 +646,11 @@ class TestExtension : Extension() { } } - group { + chatGroupCommand { name = "group" description = "Command group" - command { + chatCommand { name = "one" description = "one" @@ -624,7 +659,7 @@ class TestExtension : Extension() { } } - command { + chatCommand { name = "two" description = "two" @@ -633,7 +668,7 @@ class TestExtension : Extension() { } } - command { + chatCommand { name = "three" description = "three" diff --git a/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/utils/RestRequestTest.kt b/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/utils/RestRequestTest.kt deleted file mode 100644 index b654dab240..0000000000 --- a/kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/utils/RestRequestTest.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.kotlindiscord.kord.extensions.utils - -import dev.kord.rest.request.HttpStatus -import dev.kord.rest.request.RestRequestException -import io.ktor.http.* -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.parallel.Execution -import org.junit.jupiter.api.parallel.ExecutionMode - -/** - * Tests for [RestRequestException] extension functions. - */ -class RestRequestTest { - - /** - * Mock class implementing [RestRequestException]. - */ - private inner class RequestMockException(status: HttpStatus) : RestRequestException(status) - - /** - * Test `hasStatus()` with zero parameters. - */ - @Test - @Execution(ExecutionMode.CONCURRENT) - fun `test hasStatus with zero parameters`() { - createMockAndHasStatus(HttpStatusCode.Forbidden, false) - } - - /** - * Test `hasStatus()` with one parameter. - */ - @Test - @Execution(ExecutionMode.CONCURRENT) - fun `test hasStatus with one parameter`() { - createMockAndHasStatus(HttpStatusCode.Forbidden, true, HttpStatusCode.Forbidden) - } - - /** - * Test `hasStatus()` with multiple parameters. - */ - @Test - @Execution(ExecutionMode.CONCURRENT) - fun `test hasStatus with multiple parameters`() { - createMockAndHasStatus( - HttpStatusCode.Forbidden, - true, - HttpStatusCode.BadRequest, - HttpStatusCode.Forbidden, - HttpStatusCode.Accepted - ) - } - - /** - * Test `hasNotStatus()` with zero parameters. - */ - @Test - @Execution(ExecutionMode.CONCURRENT) - fun `test hasNotStatus with zero parameters`() { - createMockAndHasNotStatus(HttpStatusCode.Forbidden, true) - } - - /** - * Test `hasNotStatus()` with one parameter. - */ - @Test - @Execution(ExecutionMode.CONCURRENT) - fun `test hasNotStatus with one parameter`() { - createMockAndHasNotStatus(HttpStatusCode.Forbidden, false, HttpStatusCode.Forbidden) - } - - /** - * Test `hasNotStatus()` with multiple parameters. - */ - @Test - @Execution(ExecutionMode.CONCURRENT) - fun `test hasNotStatus with multiple parameters`() { - createMockAndHasNotStatus( - HttpStatusCode.Forbidden, - false, - HttpStatusCode.BadRequest, - HttpStatusCode.Forbidden, - HttpStatusCode.Accepted - ) - } - - /** - * Create an instance of [RequestMockException] with the given [status] code, and check whether it has a matching - * status from [statuses] via `hasStatus()` and `hasStatusCode()`. - * - * @param status HTTP status code to be passed to the mock object. - * @param result The expected return value of `hasStatus()` and `hasStatusCode()`. - * @param statuses Status codes to check for. - */ - private fun createMockAndHasStatus(status: HttpStatusCode, result: Boolean, vararg statuses: HttpStatusCode) { - val code = status.value - val ex = RequestMockException(HttpStatus(code, "")) - - assertEquals(result, ex.hasStatus(*statuses)) - assertEquals(result, ex.hasStatusCode(*statuses.map { it.value }.toIntArray())) - } - - /** - * Create an instance of [RequestMockException] with the given [status] code, and check whether it - * **does not have** a matching status from [statuses] via `hasNotStatus()` and `hasNotStatusCode()`. - * - * @param status HTTP status code to be passed to the mock object. - * @param result The expected return value of `hasNotStatus()` and `hasNotStatusCode()`. - * @param statuses Status codes to check for. - */ - private fun createMockAndHasNotStatus(status: HttpStatusCode, result: Boolean, vararg statuses: HttpStatusCode) { - val code = status.value - val ex = RequestMockException(HttpStatus(code, "")) - - assertEquals(result, ex.hasNotStatus(*statuses)) - assertEquals(result, ex.hasNotStatusCode(*statuses.map { it.value }.toIntArray())) - } -} diff --git a/kord-extensions/src/test/resources/logback.groovy b/kord-extensions/src/test/resources/logback.groovy index 1bab8d7d2d..6a2d39fbfe 100644 --- a/kord-extensions/src/test/resources/logback.groovy +++ b/kord-extensions/src/test/resources/logback.groovy @@ -2,7 +2,7 @@ import ch.qos.logback.core.joran.spi.ConsoleTarget def environment = System.getenv().getOrDefault("ENVIRONMENT", "production") -def defaultLevel = DEBUG +def defaultLevel = TRACE if (environment == "spam") { logger("dev.kord.rest.DefaultGateway", TRACE) diff --git a/libs.versions.toml b/libs.versions.toml index 117bc52867..703c4321db 100644 --- a/libs.versions.toml +++ b/libs.versions.toml @@ -1,43 +1,46 @@ [versions] detekt = "1.17.1" # Note: Plugin versions must be updated in the settings.gradle.kts too dokka = "1.4.10.2" # Note: Plugin versions must be updated in the settings.gradle.kts too -kotlin = "1.5.10" # Note: Plugin versions must be updated in the settings.gradle.kts too +kotlin = "1.5.30" # Note: Plugin versions must be updated in the settings.gradle.kts too -commons-text = "1.9" commons-validator = "1.7" groovy = "3.0.8" icu4j = "69.1" junit = "5.6.2" koin = "3.0.2" konf = "0.23.0" -kord = "0.8.0-M4" #kord = "0.8.x-SNAPSHOT" +kord = "0.8.0-M7" kotlinpoet = "1.8.0" -ksp = "1.5.10-1.0.0-beta02" -kx-ser = "1.2.1" -linkie = "1.0.85" +ksp = "1.5.30-1.0.0-beta08" +ktor = "1.6.3" +kx-ser = "1.3.0" +linkie = "1.0.88" logback = "1.2.3" -logging = "2.0.6" +logging = "2.0.11" sentry = "5.0.0" time4j-base = "5.8" time4j-tzdata = "5.0-2021a" [libraries] -commons-text = { module = "org.apache.commons:commons-text", version.ref = "commons-text" } commons-validator = { module = "commons-validator:commons-validator", version.ref = "commons-validator" } detekt = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } groovy = { module = "org.codehaus.groovy:groovy", version.ref = "groovy" } icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" } junit = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } + koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-logger = { module = "io.insert-koin:koin-logger-slf4j", version.ref = "koin" } koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } + konf-core = { module = "com.uchuhimo:konf", version.ref = "konf" } konf-toml = { module = "com.uchuhimo:konf-toml", version.ref = "konf" } + kord = { module = "dev.kord:kord-core", version.ref = "kord" } kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8" } ksp = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } +ktor-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } kx-ser = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kx-ser" } linkie = { module = "me.shedaniel:linkie-core", version.ref = "linkie" } logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } @@ -47,4 +50,4 @@ time4j-base = { module = "net.time4j:time4j-base", version.ref = "time4j-base" } time4j-tzdata = { module = "net.time4j:time4j-tzdata", version.ref = "time4j-tzdata" } [bundles] -commons = [ "commons-text", "commons-validator" ] +commons = [ "commons-validator" ] diff --git a/modules/java-time/build.gradle.kts b/modules/java-time/build.gradle.kts index fa54c06faa..48d9059e32 100644 --- a/modules/java-time/build.gradle.kts +++ b/modules/java-time/build.gradle.kts @@ -1,14 +1,10 @@ -import java.io.ByteArrayOutputStream -import java.net.URL - plugins { - `maven-publish` + `kordex-module` + `published-module` + `dokka-module` + `ksp-module` - kotlin("jvm") kotlin("plugin.serialization") - - id("io.gitlab.arturbosch.detekt") - id("org.jetbrains.dokka") } dependencies { @@ -22,138 +18,12 @@ dependencies { testImplementation(libs.logback) } -val sourceJar = task("sourceJar", Jar::class) { - dependsOn(tasks["classes"]) - archiveClassifier.set("sources") - from(sourceSets.main.get().allSource) -} - -val javadocJar = task("javadocJar", Jar::class) { - dependsOn("dokkaJavadoc") - archiveClassifier.set("javadoc") - from(tasks.javadoc) -} - -java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 -} - -kotlin { - explicitApi() -} - -sourceSets { - main { - java { - srcDir(file("$buildDir/generated/ksp/main/kotlin/")) - } - } - - test { - java { - srcDir(file("$buildDir/generated/ksp/test/kotlin/")) - } - } -} - -detekt { - buildUponDefaultConfig = true - config = files("../../detekt.yml") - - autoCorrect = true -} - -publishing { - repositories { - maven { - name = "KotDis" - - url = if (project.version.toString().contains("SNAPSHOT")) { - uri("https://maven.kotlindiscord.com/repository/maven-snapshots/") - } else { - uri("https://maven.kotlindiscord.com/repository/maven-releases/") - } - - credentials { - username = project.findProperty("kotdis.user") as String? ?: System.getenv("KOTLIN_DISCORD_USER") - password = project.findProperty("kotdis.password") as String? - ?: System.getenv("KOTLIN_DISCORD_PASSWORD") - } - - version = project.version - } - } - - publications { - create("maven") { - from(components.getByName("java")) - - artifact(sourceJar) - artifact(javadocJar) - } - } -} - -fun runCommand(command: String): String { - val output = ByteArrayOutputStream() - - project.exec { - commandLine(command.split(" ")) - standardOutput = output - } - - return output.toString().trim() -} - -fun getCurrentGitBranch(): String { // https://gist.github.com/lordcodes/15b2a4aecbeff7c3238a70bfd20f0931 - var gitBranch = "Unknown branch" - - try { - gitBranch = runCommand("git rev-parse --abbrev-ref HEAD") - } catch (t: Throwable) { - println(t) - } - - return gitBranch -} - -tasks.dokkaHtml.configure { - moduleName.set("Kord Extensions: Java Time") - - dokkaSourceSets { - configureEach { - includeNonPublic.set(false) - skipDeprecated.set(false) - - displayName.set("Kord Extensions: Java Time") - includes.from("packages.md") - jdkVersion.set(8) - - sourceLink { - localDirectory.set(file("${project.projectDir}/src/main/kotlin")) - - remoteUrl.set( - URL( - "https://github.com/Kotlin-Discord/kord-extensions/" + - "tree/${getCurrentGitBranch()}/java-time/src/main/kotlin" - ) - ) - - remoteLineSuffix.set("#L") - } - - externalDocumentationLink { - url.set(URL("http://kordlib.github.io/kord/common/common/")) - } - externalDocumentationLink { - url.set(URL("http://kordlib.github.io/kord/core/core/")) - } - } - } +kordex { + jvmTarget.set("9") + javaVersion.set(JavaVersion.VERSION_1_9) } -tasks.build { - this.finalizedBy(sourceJar, javadocJar) +dokkaModule { + moduleName.set("Kord Extensions: Java Time") } diff --git a/modules/java-time/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/java/ChronoContainer.kt b/modules/java-time/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/java/ChronoContainer.kt index 398fe34919..7772ff6d8b 100644 --- a/modules/java-time/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/java/ChronoContainer.kt +++ b/modules/java-time/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/java/ChronoContainer.kt @@ -173,7 +173,7 @@ public class ChronoContainer { if (target.isSupported(unit)) { result = result.plus(value, unit) as T } else { - logger.debug { "Unit $unit is not supported by $target" } + logger.trace { "Unit $unit is not supported by $target" } } } diff --git a/modules/java-time/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/java/J8DurationCoalescingConverter.kt b/modules/java-time/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/java/J8DurationCoalescingConverter.kt index 4d5d89da6a..ac6a4cf0af 100644 --- a/modules/java-time/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/java/J8DurationCoalescingConverter.kt +++ b/modules/java-time/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/java/J8DurationCoalescingConverter.kt @@ -7,15 +7,16 @@ package com.kotlindiscord.kord.extensions.modules.time.java -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument -import com.kotlindiscord.kord.extensions.commands.parser.Arguments import com.kotlindiscord.kord.extensions.parser.StringParser import com.kotlindiscord.kord.extensions.parsers.DurationParserException import com.kotlindiscord.kord.extensions.parsers.InvalidTimeUnitException import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder import mu.KotlinLogging @@ -123,7 +124,7 @@ public class J8DurationCoalescingConverter( normalized.normalize(LocalDateTime.now()) if (!normalized.isPositive()) { - throw CommandException(context.translate("converters.duration.error.positiveOnly")) + throw DiscordRelayedException(context.translate("converters.duration.error.positiveOnly")) } } @@ -137,9 +138,6 @@ public class J8DurationCoalescingConverter( return durations.size } - override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = - StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } - private suspend fun throwIfNecessary( e: Exception, context: CommandContext, @@ -152,16 +150,50 @@ public class J8DurationCoalescingConverter( replacements = arrayOf(e.unit) ) + if (longHelp) "\n\n" + context.translate("converters.duration.help") else "" - throw CommandException(message) + throw DiscordRelayedException(message) } - is DurationParserException -> throw CommandException(e.error) + is DurationParserException -> throw DiscordRelayedException(e.error) else -> throw e } } else { logger.debug(e) { "Error thrown during duration parsing" } } + + override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = + StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val arg = (option as? OptionValue.StringOptionValue)?.value ?: return false + + try { + val result = J8DurationParser.parse(arg, context.getLocale()) + + if (positiveOnly) { + val normalized = result.clone() + + normalized.normalize(LocalDateTime.now()) + + if (!normalized.isPositive()) { + throw DiscordRelayedException(context.translate("converters.duration.error.positiveOnly")) + } + } + + parsed = result + } catch (e: InvalidTimeUnitException) { + val message = context.translate( + "converters.duration.error.invalidUnit", + replacements = arrayOf(e.unit) + ) + if (longHelp) "\n\n" + context.translate("converters.duration.help") else "" + + throw DiscordRelayedException(message) + } catch (e: DurationParserException) { + throw DiscordRelayedException(e.error) + } + + return true + } } /** diff --git a/modules/java-time/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/java/J8DurationConverter.kt b/modules/java-time/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/java/J8DurationConverter.kt index 86c413d854..7749bfeba8 100644 --- a/modules/java-time/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/java/J8DurationConverter.kt +++ b/modules/java-time/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/java/J8DurationConverter.kt @@ -7,15 +7,16 @@ package com.kotlindiscord.kord.extensions.modules.time.java -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument -import com.kotlindiscord.kord.extensions.commands.parser.Arguments import com.kotlindiscord.kord.extensions.parser.StringParser import com.kotlindiscord.kord.extensions.parsers.DurationParserException import com.kotlindiscord.kord.extensions.parsers.InvalidTimeUnitException import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder import java.time.Duration @@ -50,7 +51,7 @@ public class J8DurationConverter( normalized.normalize(LocalDateTime.now()) if (!normalized.isPositive()) { - throw CommandException(context.translate("converters.duration.error.positiveOnly")) + throw DiscordRelayedException(context.translate("converters.duration.error.positiveOnly")) } } @@ -61,9 +62,9 @@ public class J8DurationConverter( replacements = arrayOf(e.unit) ) + if (longHelp) "\n\n" + context.translate("converters.duration.help") else "" - throw CommandException(message) + throw DiscordRelayedException(message) } catch (e: DurationParserException) { - throw CommandException(e.error) + throw DiscordRelayedException(e.error) } return true @@ -71,6 +72,37 @@ public class J8DurationConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val arg = (option as? OptionValue.StringOptionValue)?.value ?: return false + + try { + val result = J8DurationParser.parse(arg, context.getLocale()) + + if (positiveOnly) { + val normalized = result.clone() + + normalized.normalize(LocalDateTime.now()) + + if (!normalized.isPositive()) { + throw DiscordRelayedException(context.translate("converters.duration.error.positiveOnly")) + } + } + + parsed = result + } catch (e: InvalidTimeUnitException) { + val message = context.translate( + "converters.duration.error.invalidUnit", + replacements = arrayOf(e.unit) + ) + if (longHelp) "\n\n" + context.translate("converters.duration.help") else "" + + throw DiscordRelayedException(message) + } catch (e: DurationParserException) { + throw DiscordRelayedException(e.error) + } + + return true + } } /** diff --git a/modules/java-time/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt b/modules/java-time/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt index d5b3f137a1..7373ff47a2 100644 --- a/modules/java-time/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt +++ b/modules/java-time/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt @@ -6,25 +6,25 @@ import com.kotlindiscord.kord.extensions.utils.env import org.koin.core.logger.Level suspend fun main() { - val bot = ExtensibleBot(env("TOKEN")!!) { + val bot = ExtensibleBot(env("TOKEN")) { koinLogLevel = Level.DEBUG i18n { - localeResolver { guild, channel, user -> + localeResolver { _, _, user -> @Suppress("UnderscoresInNumericLiterals") when (user?.id?.value) { - 560515299388948500 -> SupportedLocales.FINNISH - 242043299022635020 -> SupportedLocales.FRENCH - 407110650217627658 -> SupportedLocales.FRENCH - 667552017434017794 -> SupportedLocales.CHINESE_SIMPLIFIED - 185461862878543872 -> SupportedLocales.GERMAN + 560515299388948500UL -> SupportedLocales.FINNISH + 242043299022635020UL -> SupportedLocales.FRENCH + 407110650217627658UL -> SupportedLocales.FRENCH + 667552017434017794UL -> SupportedLocales.CHINESE_SIMPLIFIED + 185461862878543872UL -> SupportedLocales.GERMAN else -> defaultLocale } } } - messageCommands { + chatCommands { defaultPrefix = "?" prefix { default -> diff --git a/modules/java-time/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/TestExtension.kt b/modules/java-time/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/TestExtension.kt index 813b9b5a8e..07f05af42d 100644 --- a/modules/java-time/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/TestExtension.kt +++ b/modules/java-time/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/TestExtension.kt @@ -1,7 +1,8 @@ package com.kotlindiscord.kord.extensions.test.bot -import com.kotlindiscord.kord.extensions.commands.parser.Arguments +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.extensions.chatCommand import com.kotlindiscord.kord.extensions.modules.time.java.coalescedJ8Duration import com.kotlindiscord.kord.extensions.modules.time.java.toHuman import com.kotlindiscord.kord.extensions.utils.respond @@ -22,7 +23,7 @@ class TestExtension : Extension() { } override suspend fun setup() { - command(::TestArgs) { + chatCommand(::TestArgs) { name = "format" description = "Let's test formatting." diff --git a/modules/time4j/build.gradle.kts b/modules/time4j/build.gradle.kts index b4223eff59..b04c0e2736 100644 --- a/modules/time4j/build.gradle.kts +++ b/modules/time4j/build.gradle.kts @@ -1,13 +1,7 @@ -import java.io.ByteArrayOutputStream -import java.net.URL - plugins { - `maven-publish` - - kotlin("jvm") - - id("io.gitlab.arturbosch.detekt") - id("org.jetbrains.dokka") + `kordex-module` + `published-module` + `dokka-module` } dependencies { @@ -24,138 +18,11 @@ dependencies { testImplementation(libs.logback) } -val sourceJar = task("sourceJar", Jar::class) { - dependsOn(tasks["classes"]) - archiveClassifier.set("sources") - from(sourceSets.main.get().allSource) -} - -val javadocJar = task("javadocJar", Jar::class) { - dependsOn("dokkaJavadoc") - archiveClassifier.set("javadoc") - from(tasks.javadoc) -} - -java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 -} - -kotlin { - explicitApi() -} - -sourceSets { - main { - java { - srcDir(file("$buildDir/generated/ksp/main/kotlin/")) - } - } - - test { - java { - srcDir(file("$buildDir/generated/ksp/test/kotlin/")) - } - } -} - -detekt { - buildUponDefaultConfig = true - config = files("../../detekt.yml") - - autoCorrect = true +kordex { + jvmTarget.set("9") + javaVersion.set(JavaVersion.VERSION_1_9) } -publishing { - repositories { - maven { - name = "KotDis" - - url = if (project.version.toString().contains("SNAPSHOT")) { - uri("https://maven.kotlindiscord.com/repository/maven-snapshots/") - } else { - uri("https://maven.kotlindiscord.com/repository/maven-releases/") - } - - credentials { - username = project.findProperty("kotdis.user") as String? ?: System.getenv("KOTLIN_DISCORD_USER") - password = project.findProperty("kotdis.password") as String? - ?: System.getenv("KOTLIN_DISCORD_PASSWORD") - } - - version = project.version - } - } - - publications { - create("maven") { - from(components.getByName("java")) - - artifact(sourceJar) - artifact(javadocJar) - } - } -} - -fun runCommand(command: String): String { - val output = ByteArrayOutputStream() - - project.exec { - commandLine(command.split(" ")) - standardOutput = output - } - - return output.toString().trim() -} - -fun getCurrentGitBranch(): String { // https://gist.github.com/lordcodes/15b2a4aecbeff7c3238a70bfd20f0931 - var gitBranch = "Unknown branch" - - try { - gitBranch = runCommand("git rev-parse --abbrev-ref HEAD") - } catch (t: Throwable) { - println(t) - } - - return gitBranch -} - -tasks.dokkaHtml.configure { +dokkaModule { moduleName.set("Kord Extensions: Time4J") - - dokkaSourceSets { - configureEach { - includeNonPublic.set(false) - skipDeprecated.set(false) - - displayName.set("Kord Extensions: Time4J") - includes.from("packages.md") - jdkVersion.set(8) - - sourceLink { - localDirectory.set(file("${project.projectDir}/src/main/kotlin")) - - remoteUrl.set( - URL( - "https://github.com/Kotlin-Discord/kord-extensions/" + - "tree/${getCurrentGitBranch()}/time4j/src/main/kotlin" - ) - ) - - remoteLineSuffix.set("#L") - } - - externalDocumentationLink { - url.set(URL("http://kordlib.github.io/kord/common/common/")) - } - - externalDocumentationLink { - url.set(URL("http://kordlib.github.io/kord/core/core/")) - } - } - } -} - -tasks.build { - this.finalizedBy(sourceJar, javadocJar) } diff --git a/modules/time4j/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/time4j/T4JDurationCoalescingConverter.kt b/modules/time4j/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/time4j/T4JDurationCoalescingConverter.kt index f1639f6c29..82396130ff 100644 --- a/modules/time4j/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/time4j/T4JDurationCoalescingConverter.kt +++ b/modules/time4j/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/time4j/T4JDurationCoalescingConverter.kt @@ -7,16 +7,17 @@ package com.kotlindiscord.kord.extensions.modules.time.time4j -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* import com.kotlindiscord.kord.extensions.commands.converters.impl.RegexCoalescingConverter -import com.kotlindiscord.kord.extensions.commands.parser.Argument -import com.kotlindiscord.kord.extensions.commands.parser.Arguments import com.kotlindiscord.kord.extensions.parser.StringParser import com.kotlindiscord.kord.extensions.parsers.DurationParserException import com.kotlindiscord.kord.extensions.parsers.InvalidTimeUnitException import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder import mu.KotlinLogging @@ -124,9 +125,6 @@ public class T4JDurationCoalescingConverter( return durations.size } - override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = - StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } - private suspend fun throwIfNecessary( e: Exception, context: CommandContext, @@ -139,16 +137,38 @@ public class T4JDurationCoalescingConverter( replacements = arrayOf(e.unit) ) + if (longHelp) "\n\n" + context.translate("converters.duration.help") else "" - throw CommandException(message) + throw DiscordRelayedException(message) } - is DurationParserException -> throw CommandException(e.error) + is DurationParserException -> throw DiscordRelayedException(e.error) else -> throw e } } else { logger.debug(e) { "Error thrown during duration parsing" } } + + override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = + StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val arg = (option as? OptionValue.StringOptionValue)?.value ?: return false + + try { + this.parsed = T4JDurationParser.parse(arg, context.getLocale()) + } catch (e: InvalidTimeUnitException) { + val message = context.translate( + "converters.duration.error.invalidUnit", + replacements = arrayOf(e.unit) + ) + if (longHelp) "\n\n" + context.translate("converters.duration.help") else "" + + throw DiscordRelayedException(message) + } catch (e: DurationParserException) { + throw DiscordRelayedException(e.error) + } + + return true + } } /** diff --git a/modules/time4j/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/time4j/T4JDurationConverter.kt b/modules/time4j/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/time4j/T4JDurationConverter.kt index 5f1f9fbaed..21b6fcc604 100644 --- a/modules/time4j/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/time4j/T4JDurationConverter.kt +++ b/modules/time4j/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/time/time4j/T4JDurationConverter.kt @@ -7,15 +7,16 @@ package com.kotlindiscord.kord.extensions.modules.time.time4j -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument -import com.kotlindiscord.kord.extensions.commands.parser.Arguments import com.kotlindiscord.kord.extensions.parser.StringParser import com.kotlindiscord.kord.extensions.parsers.DurationParserException import com.kotlindiscord.kord.extensions.parsers.InvalidTimeUnitException import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder import net.time4j.Duration @@ -50,9 +51,9 @@ public class T4JDurationConverter( replacements = arrayOf(e.unit) ) + if (longHelp) "\n\n" + context.translate("converters.duration.help") else "" - throw CommandException(message) + throw DiscordRelayedException(message) } catch (e: DurationParserException) { - throw CommandException(e.error) + throw DiscordRelayedException(e.error) } return true @@ -60,6 +61,25 @@ public class T4JDurationConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + val arg = (option as? OptionValue.StringOptionValue)?.value ?: return false + + try { + this.parsed = T4JDurationParser.parse(arg, context.getLocale()) + } catch (e: InvalidTimeUnitException) { + val message = context.translate( + "converters.duration.error.invalidUnit", + replacements = arrayOf(e.unit) + ) + if (longHelp) "\n\n" + context.translate("converters.duration.help") else "" + + throw DiscordRelayedException(message) + } catch (e: DurationParserException) { + throw DiscordRelayedException(e.error) + } + + return true + } } /** diff --git a/modules/time4j/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt b/modules/time4j/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt index f5e9eec545..21a5601661 100644 --- a/modules/time4j/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt +++ b/modules/time4j/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/Bot.kt @@ -5,10 +5,10 @@ import com.kotlindiscord.kord.extensions.utils.env import org.koin.core.logger.Level suspend fun main() { - val bot = ExtensibleBot(env("TOKEN")!!) { + val bot = ExtensibleBot(env("TOKEN")) { koinLogLevel = Level.DEBUG - messageCommands { + chatCommands { defaultPrefix = "?" prefix { default -> diff --git a/modules/time4j/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/TestExtension.kt b/modules/time4j/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/TestExtension.kt index 5ee83e3253..08fe3504cf 100644 --- a/modules/time4j/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/TestExtension.kt +++ b/modules/time4j/src/test/kotlin/com/kotlindiscord/kord/extensions/test/bot/TestExtension.kt @@ -1,7 +1,8 @@ package com.kotlindiscord.kord.extensions.test.bot -import com.kotlindiscord.kord.extensions.commands.parser.Arguments +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.extensions.chatCommand import com.kotlindiscord.kord.extensions.modules.time.time4j.coalescedT4jDuration import com.kotlindiscord.kord.extensions.modules.time.time4j.toHuman import com.kotlindiscord.kord.extensions.utils.respond @@ -22,7 +23,7 @@ class TestExtension : Extension() { } override suspend fun setup() { - command(::TestArgs) { + chatCommand(::TestArgs) { name = "format" description = "Let's test formatting." diff --git a/modules/unsafe/build.gradle.kts b/modules/unsafe/build.gradle.kts new file mode 100644 index 0000000000..48196d696d --- /dev/null +++ b/modules/unsafe/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + `kordex-module` + `published-module` + `dokka-module` + `ksp-module` +} + +dependencies { + implementation(libs.kotlin.stdlib) + implementation(project(":kord-extensions")) + + detektPlugins(libs.detekt) + + testImplementation(libs.groovy) // For logback config + testImplementation(libs.junit) + testImplementation(libs.logback) + + ksp(project(":annotation-processor")) +} + +kordex { + jvmTarget.set("9") + javaVersion.set(JavaVersion.VERSION_1_9) +} + +dokkaModule { + moduleName.set("Kord Extensions: Unsafe") +} diff --git a/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/annotations/UnsafeAPI.kt b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/annotations/UnsafeAPI.kt new file mode 100644 index 0000000000..0d2be61ccf --- /dev/null +++ b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/annotations/UnsafeAPI.kt @@ -0,0 +1,10 @@ +package com.kotlindiscord.kord.extensions.modules.unsafe.annotations + +/** Annotation used to mark unsafe APIs. Should be applied to basically everything in this module. **/ +@RequiresOptIn( + message = "This API is unsafe, and is only intended for advanced use-cases. If you're not entirely sure that " + + "you need to use this, you should look for a safer API that's provided by a different Kord Extensions module." +) +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS, AnnotationTarget.FIELD, AnnotationTarget.TYPEALIAS) +public annotation class UnsafeAPI diff --git a/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/commands/UnsafeCommandEvents.kt b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/commands/UnsafeCommandEvents.kt new file mode 100644 index 0000000000..86537b6d41 --- /dev/null +++ b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/commands/UnsafeCommandEvents.kt @@ -0,0 +1,107 @@ +@file:OptIn(UnsafeAPI::class) + +package com.kotlindiscord.kord.extensions.modules.unsafe.commands + +import com.kotlindiscord.kord.extensions.ArgumentParsingException +import com.kotlindiscord.kord.extensions.commands.events.* +import com.kotlindiscord.kord.extensions.modules.unsafe.annotations.UnsafeAPI +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent +import dev.kord.core.event.interaction.MessageCommandInteractionCreateEvent +import dev.kord.core.event.interaction.UserCommandInteractionCreateEvent + +// region Message commands + +/** Event emitted when an unsafe message command is invoked. **/ +public data class UnsafeMessageCommandInvocationEvent( + override val command: UnsafeMessageCommand, + override val event: MessageCommandInteractionCreateEvent +) : MessageCommandInvocationEvent + +/** Event emitted when an unsafe message command invocation succeeds. **/ +public data class UnsafeMessageCommandSucceededEvent( + override val command: UnsafeMessageCommand, + override val event: MessageCommandInteractionCreateEvent +) : MessageCommandSucceededEvent + +/** Event emitted when an unsafe message command's checks fail. **/ +public data class UnsafeMessageCommandFailedChecksEvent( + override val command: UnsafeMessageCommand, + override val event: MessageCommandInteractionCreateEvent, + override val reason: String +) : MessageCommandFailedChecksEvent + +/** Event emitted when an unsafe message command invocation fails with an exception. **/ +public data class UnsafeMessageCommandFailedWithExceptionEvent( + override val command: UnsafeMessageCommand, + override val event: MessageCommandInteractionCreateEvent, + override val throwable: Throwable +) : MessageCommandFailedWithExceptionEvent + +// endregion + +// region Slash commands + +/** Event emitted when an unsafe slash command is invoked. **/ +public data class UnsafeSlashCommandInvocationEvent( + override val command: UnsafeSlashCommand<*>, + override val event: ChatInputCommandInteractionCreateEvent +) : SlashCommandInvocationEvent> + +/** Event emitted when an unsafe slash command invocation succeeds. **/ +public data class UnsafeSlashCommandSucceededEvent( + override val command: UnsafeSlashCommand<*>, + override val event: ChatInputCommandInteractionCreateEvent +) : SlashCommandSucceededEvent> + +/** Event emitted when an unsafe slash command's checks fail. **/ +public data class UnsafeSlashCommandFailedChecksEvent( + override val command: UnsafeSlashCommand<*>, + override val event: ChatInputCommandInteractionCreateEvent, + override val reason: String +) : SlashCommandFailedChecksEvent> + +/** Event emitted when an unsafe slash command's argument parsing fails. **/ +public data class UnsafeSlashCommandFailedParsingEvent( + override val command: UnsafeSlashCommand<*>, + override val event: ChatInputCommandInteractionCreateEvent, + override val exception: ArgumentParsingException, +) : SlashCommandFailedParsingEvent> + +/** Event emitted when an unsafe slash command invocation fails with an exception. **/ +public data class UnsafeSlashCommandFailedWithExceptionEvent( + override val command: UnsafeSlashCommand<*>, + override val event: ChatInputCommandInteractionCreateEvent, + override val throwable: Throwable +) : SlashCommandFailedWithExceptionEvent> + +// endregion + +// region User commands + +/** Event emitted when an unsafe user command is invoked. **/ +public data class UnsafeUserCommandInvocationEvent( + override val command: UnsafeUserCommand, + override val event: UserCommandInteractionCreateEvent +) : UserCommandInvocationEvent + +/** Event emitted when an unsafe user command invocation succeeds. **/ +public data class UnsafeUserCommandSucceededEvent( + override val command: UnsafeUserCommand, + override val event: UserCommandInteractionCreateEvent +) : UserCommandSucceededEvent + +/** Event emitted when an unsafe user command's checks fail. **/ +public data class UnsafeUserCommandFailedChecksEvent( + override val command: UnsafeUserCommand, + override val event: UserCommandInteractionCreateEvent, + override val reason: String +) : UserCommandFailedChecksEvent + +/** Event emitted when an unsafe user command invocation fails with an exception. **/ +public data class UnsafeUserCommandFailedWithExceptionEvent( + override val command: UnsafeUserCommand, + override val event: UserCommandInteractionCreateEvent, + override val throwable: Throwable +) : UserCommandFailedWithExceptionEvent + +// endregion diff --git a/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/commands/UnsafeMessageCommand.kt b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/commands/UnsafeMessageCommand.kt new file mode 100644 index 0000000000..95c89d5d83 --- /dev/null +++ b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/commands/UnsafeMessageCommand.kt @@ -0,0 +1,113 @@ +@file:Suppress("TooGenericExceptionCaught") + +package com.kotlindiscord.kord.extensions.modules.unsafe.commands + +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.application.message.MessageCommand +import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.modules.unsafe.annotations.UnsafeAPI +import com.kotlindiscord.kord.extensions.modules.unsafe.contexts.UnsafeMessageCommandContext +import com.kotlindiscord.kord.extensions.modules.unsafe.types.InitialMessageCommandResponse +import com.kotlindiscord.kord.extensions.modules.unsafe.types.respondEphemeral +import com.kotlindiscord.kord.extensions.modules.unsafe.types.respondPublic +import com.kotlindiscord.kord.extensions.types.FailureReason +import dev.kord.core.behavior.interaction.EphemeralInteractionResponseBehavior +import dev.kord.core.behavior.interaction.PublicInteractionResponseBehavior +import dev.kord.core.behavior.interaction.respondEphemeral +import dev.kord.core.behavior.interaction.respondPublic +import dev.kord.core.event.interaction.MessageCommandInteractionCreateEvent + +/** Like a standard message command, but with less safety features. **/ +@UnsafeAPI +public class UnsafeMessageCommand( + extension: Extension +) : MessageCommand(extension) { + /** Initial response type. Change this to decide what happens when this message command action is executed. **/ + public var initialResponse: InitialMessageCommandResponse = InitialMessageCommandResponse.EphemeralAck + + override suspend fun call(event: MessageCommandInteractionCreateEvent) { + emitEventAsync(UnsafeMessageCommandInvocationEvent(this, event)) + + try { + if (!runChecks(event)) { + emitEventAsync( + UnsafeMessageCommandFailedChecksEvent( + this, + event, + "Checks failed without a message." + ) + ) + return + } + } catch (e: DiscordRelayedException) { + event.interaction.respondEphemeral { + settings.failureResponseBuilder(this, e.reason, FailureReason.ProvidedCheckFailure(e)) + } + + emitEventAsync(UnsafeMessageCommandFailedChecksEvent(this, event, e.reason)) + + return + } + + val response = when (val r = initialResponse) { + is InitialMessageCommandResponse.EphemeralAck -> event.interaction.acknowledgeEphemeral() + is InitialMessageCommandResponse.PublicAck -> event.interaction.acknowledgePublic() + + is InitialMessageCommandResponse.EphemeralResponse -> event.interaction.respondEphemeral { + r.builder!!(event) + } + + is InitialMessageCommandResponse.PublicResponse -> event.interaction.respondPublic { + r.builder!!(event) + } + + is InitialMessageCommandResponse.None -> null + } + + val context = UnsafeMessageCommandContext(event, this, response) + + context.populate() + + firstSentryBreadcrumb(context) + + try { + checkBotPerms(context) + } catch (t: DiscordRelayedException) { + emitEventAsync(UnsafeMessageCommandFailedChecksEvent(this, event, t.reason)) + respondText(context, t.reason, FailureReason.OwnPermissionsCheckFailure(t)) + + return + } + + try { + body(context) + } catch (t: Throwable) { + if (t is DiscordRelayedException) { + respondText(context, t.reason, FailureReason.RelayedFailure(t)) + } + + emitEventAsync(UnsafeMessageCommandFailedWithExceptionEvent(this, event, t)) + handleError(context, t) + + return + } + + emitEventAsync(UnsafeMessageCommandSucceededEvent(this, event)) + } + + override suspend fun respondText( + context: UnsafeMessageCommandContext, + message: String, + failureType: FailureReason<*> + ) { + when (context.interactionResponse) { + is PublicInteractionResponseBehavior -> context.respondPublic { + settings.failureResponseBuilder(this, message, failureType) + } + + is EphemeralInteractionResponseBehavior -> context.respondEphemeral { + settings.failureResponseBuilder(this, message, failureType) + } + } + } +} diff --git a/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/commands/UnsafeSlashCommand.kt b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/commands/UnsafeSlashCommand.kt new file mode 100644 index 0000000000..6ec66a4fb7 --- /dev/null +++ b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/commands/UnsafeSlashCommand.kt @@ -0,0 +1,162 @@ +@file:Suppress("TooGenericExceptionCaught") + +package com.kotlindiscord.kord.extensions.modules.unsafe.commands + +import com.kotlindiscord.kord.extensions.ArgumentParsingException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.application.slash.SlashCommand +import com.kotlindiscord.kord.extensions.commands.application.slash.SlashGroup +import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.modules.unsafe.annotations.UnsafeAPI +import com.kotlindiscord.kord.extensions.modules.unsafe.contexts.UnsafeSlashCommandContext +import com.kotlindiscord.kord.extensions.modules.unsafe.types.InitialSlashCommandResponse +import com.kotlindiscord.kord.extensions.modules.unsafe.types.respondEphemeral +import com.kotlindiscord.kord.extensions.modules.unsafe.types.respondPublic +import com.kotlindiscord.kord.extensions.types.FailureReason +import dev.kord.core.behavior.interaction.EphemeralInteractionResponseBehavior +import dev.kord.core.behavior.interaction.PublicInteractionResponseBehavior +import dev.kord.core.behavior.interaction.respondEphemeral +import dev.kord.core.behavior.interaction.respondPublic +import dev.kord.core.entity.interaction.GroupCommand +import dev.kord.core.entity.interaction.SubCommand +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent + +/** Like a standard slash command, but with less safety features. **/ +@UnsafeAPI +public class UnsafeSlashCommand( + extension: Extension, + + public override val arguments: (() -> A)? = null, + public override val parentCommand: SlashCommand<*, *>? = null, + public override val parentGroup: SlashGroup? = null +) : SlashCommand, A>(extension) { + /** Initial response type. Change this to decide what happens when this slash command is executed. **/ + public var initialResponse: InitialSlashCommandResponse = InitialSlashCommandResponse.EphemeralAck + + override suspend fun call(event: ChatInputCommandInteractionCreateEvent) { + val eventCommand = event.interaction.command + + val commandObj: SlashCommand<*, *> = when (eventCommand) { + is SubCommand -> { + val firstSubCommandKey = eventCommand.name + + this.subCommands.firstOrNull { it.name == firstSubCommandKey } + ?: error("Unknown subcommand: $firstSubCommandKey") + } + + is GroupCommand -> { + val firstEventGroupKey = eventCommand.groupName + val group = this.groups[firstEventGroupKey] ?: error("Unknown command group: $firstEventGroupKey") + val firstSubCommandKey = eventCommand.name + + group.subCommands.firstOrNull { it.name == firstSubCommandKey } + ?: error("Unknown subcommand: $firstSubCommandKey") + } + + else -> this + } + + commandObj.run(event) + } + + override suspend fun run(event: ChatInputCommandInteractionCreateEvent) { + emitEventAsync(UnsafeSlashCommandInvocationEvent(this, event)) + + try { + if (!runChecks(event)) { + emitEventAsync( + UnsafeSlashCommandFailedChecksEvent( + this, + event, + "Checks failed without a message." + ) + ) + + return + } + } catch (e: DiscordRelayedException) { + event.interaction.respondEphemeral { + settings.failureResponseBuilder(this, e.reason, FailureReason.ProvidedCheckFailure(e)) + } + + emitEventAsync(UnsafeSlashCommandFailedChecksEvent(this, event, e.reason)) + + return + } + + val response = when (val r = initialResponse) { + is InitialSlashCommandResponse.EphemeralAck -> event.interaction.acknowledgeEphemeral() + is InitialSlashCommandResponse.PublicAck -> event.interaction.acknowledgePublic() + + is InitialSlashCommandResponse.EphemeralResponse -> event.interaction.respondEphemeral { + r.builder!!(event) + } + + is InitialSlashCommandResponse.PublicResponse -> event.interaction.respondPublic { + r.builder!!(event) + } + + is InitialSlashCommandResponse.None -> null + } + + val context = UnsafeSlashCommandContext(event, this, response) + + context.populate() + + firstSentryBreadcrumb(context, this) + + try { + checkBotPerms(context) + } catch (e: DiscordRelayedException) { + respondText(context, e.reason, FailureReason.OwnPermissionsCheckFailure(e)) + emitEventAsync(UnsafeSlashCommandFailedChecksEvent(this, event, e.reason)) + + return + } + + try { + if (arguments != null) { + val args = registry.argumentParser.parse(arguments, context) + + context.populateArgs(args) + } + } catch (e: ArgumentParsingException) { + respondText(context, e.reason, FailureReason.ArgumentParsingFailure(e)) + emitEventAsync(UnsafeSlashCommandFailedParsingEvent(this, event, e)) + + return + } + + try { + body(context) + } catch (t: Throwable) { + if (t is DiscordRelayedException) { + respondText(context, t.reason, FailureReason.RelayedFailure(t)) + } + + emitEventAsync(UnsafeSlashCommandFailedWithExceptionEvent(this, event, t)) + handleError(context, t, this) + + return + } + + emitEventAsync(UnsafeSlashCommandSucceededEvent(this, event)) + } + + override suspend fun respondText( + context: UnsafeSlashCommandContext, + message: String, + failureType: FailureReason<*> + ) { + when (context.interactionResponse) { + is PublicInteractionResponseBehavior -> context.respondPublic { + settings.failureResponseBuilder(this, message, failureType) + } + + is EphemeralInteractionResponseBehavior -> context.respondEphemeral { + settings.failureResponseBuilder(this, message, failureType) + } + } + } +} diff --git a/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/commands/UnsafeUserCommand.kt b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/commands/UnsafeUserCommand.kt new file mode 100644 index 0000000000..218739502e --- /dev/null +++ b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/commands/UnsafeUserCommand.kt @@ -0,0 +1,114 @@ +@file:Suppress("TooGenericExceptionCaught") + +package com.kotlindiscord.kord.extensions.modules.unsafe.commands + +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.application.user.UserCommand +import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.modules.unsafe.annotations.UnsafeAPI +import com.kotlindiscord.kord.extensions.modules.unsafe.contexts.UnsafeUserCommandContext +import com.kotlindiscord.kord.extensions.modules.unsafe.types.InitialUserCommandResponse +import com.kotlindiscord.kord.extensions.modules.unsafe.types.respondEphemeral +import com.kotlindiscord.kord.extensions.modules.unsafe.types.respondPublic +import com.kotlindiscord.kord.extensions.types.FailureReason +import dev.kord.core.behavior.interaction.EphemeralInteractionResponseBehavior +import dev.kord.core.behavior.interaction.PublicInteractionResponseBehavior +import dev.kord.core.behavior.interaction.respondEphemeral +import dev.kord.core.behavior.interaction.respondPublic +import dev.kord.core.event.interaction.UserCommandInteractionCreateEvent + +/** Like a standard user command, but with less safety features. **/ +@UnsafeAPI +public class UnsafeUserCommand( + extension: Extension +) : UserCommand(extension) { + /** Initial response type. Change this to decide what happens when this user command action is executed. **/ + public var initialResponse: InitialUserCommandResponse = InitialUserCommandResponse.EphemeralAck + + override suspend fun call(event: UserCommandInteractionCreateEvent) { + emitEventAsync(UnsafeUserCommandInvocationEvent(this, event)) + + try { + if (!runChecks(event)) { + emitEventAsync( + UnsafeUserCommandFailedChecksEvent( + this, + event, + "Checks failed without a message." + ) + ) + + return + } + } catch (e: DiscordRelayedException) { + event.interaction.respondEphemeral { + settings.failureResponseBuilder(this, e.reason, FailureReason.ProvidedCheckFailure(e)) + } + + emitEventAsync(UnsafeUserCommandFailedChecksEvent(this, event, e.reason)) + + return + } + + val response = when (val r = initialResponse) { + is InitialUserCommandResponse.EphemeralAck -> event.interaction.acknowledgeEphemeral() + is InitialUserCommandResponse.PublicAck -> event.interaction.acknowledgePublic() + + is InitialUserCommandResponse.EphemeralResponse -> event.interaction.respondEphemeral { + r.builder!!(event) + } + + is InitialUserCommandResponse.PublicResponse -> event.interaction.respondPublic { + r.builder!!(event) + } + + is InitialUserCommandResponse.None -> null + } + + val context = UnsafeUserCommandContext(event, this, response) + + context.populate() + + firstSentryBreadcrumb(context) + + try { + checkBotPerms(context) + } catch (t: DiscordRelayedException) { + emitEventAsync(UnsafeUserCommandFailedChecksEvent(this, event, t.reason)) + respondText(context, t.reason, FailureReason.OwnPermissionsCheckFailure(t)) + + return + } + + try { + body(context) + } catch (t: Throwable) { + if (t is DiscordRelayedException) { + respondText(context, t.reason, FailureReason.RelayedFailure(t)) + } + + emitEventAsync(UnsafeUserCommandFailedWithExceptionEvent(this, event, t)) + handleError(context, t) + + return + } + + emitEventAsync(UnsafeUserCommandSucceededEvent(this, event)) + } + + override suspend fun respondText( + context: UnsafeUserCommandContext, + message: String, + failureType: FailureReason<*> + ) { + when (context.interactionResponse) { + is PublicInteractionResponseBehavior -> context.respondPublic { + settings.failureResponseBuilder(this, message, failureType) + } + + is EphemeralInteractionResponseBehavior -> context.respondEphemeral { + settings.failureResponseBuilder(this, message, failureType) + } + } + } +} diff --git a/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/contexts/UnsafeMessageCommandContext.kt b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/contexts/UnsafeMessageCommandContext.kt new file mode 100644 index 0000000000..a05530c7bc --- /dev/null +++ b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/contexts/UnsafeMessageCommandContext.kt @@ -0,0 +1,16 @@ +package com.kotlindiscord.kord.extensions.modules.unsafe.contexts + +import com.kotlindiscord.kord.extensions.commands.application.message.MessageCommand +import com.kotlindiscord.kord.extensions.commands.application.message.MessageCommandContext +import com.kotlindiscord.kord.extensions.modules.unsafe.annotations.UnsafeAPI +import com.kotlindiscord.kord.extensions.modules.unsafe.types.UnsafeInteractionContext +import dev.kord.core.behavior.interaction.InteractionResponseBehavior +import dev.kord.core.event.interaction.MessageCommandInteractionCreateEvent + +/** Command context for an unsafe message command. **/ +@UnsafeAPI +public class UnsafeMessageCommandContext( + override val event: MessageCommandInteractionCreateEvent, + override val command: MessageCommand, + override var interactionResponse: InteractionResponseBehavior?, +) : MessageCommandContext(event, command), UnsafeInteractionContext diff --git a/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/contexts/UnsafeSlashCommandContext.kt b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/contexts/UnsafeSlashCommandContext.kt new file mode 100644 index 0000000000..c34e2321e8 --- /dev/null +++ b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/contexts/UnsafeSlashCommandContext.kt @@ -0,0 +1,17 @@ +package com.kotlindiscord.kord.extensions.modules.unsafe.contexts + +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.application.slash.SlashCommandContext +import com.kotlindiscord.kord.extensions.modules.unsafe.annotations.UnsafeAPI +import com.kotlindiscord.kord.extensions.modules.unsafe.commands.UnsafeSlashCommand +import com.kotlindiscord.kord.extensions.modules.unsafe.types.UnsafeInteractionContext +import dev.kord.core.behavior.interaction.InteractionResponseBehavior +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent + +/** Command context for an unsafe slash command. **/ +@UnsafeAPI +public class UnsafeSlashCommandContext( + override val event: ChatInputCommandInteractionCreateEvent, + override val command: UnsafeSlashCommand, + override var interactionResponse: InteractionResponseBehavior? +) : SlashCommandContext, A>(event, command), UnsafeInteractionContext diff --git a/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/contexts/UnsafeUserCommandContext.kt b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/contexts/UnsafeUserCommandContext.kt new file mode 100644 index 0000000000..02707a9740 --- /dev/null +++ b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/contexts/UnsafeUserCommandContext.kt @@ -0,0 +1,16 @@ +package com.kotlindiscord.kord.extensions.modules.unsafe.contexts + +import com.kotlindiscord.kord.extensions.commands.application.user.UserCommand +import com.kotlindiscord.kord.extensions.commands.application.user.UserCommandContext +import com.kotlindiscord.kord.extensions.modules.unsafe.annotations.UnsafeAPI +import com.kotlindiscord.kord.extensions.modules.unsafe.types.UnsafeInteractionContext +import dev.kord.core.behavior.interaction.InteractionResponseBehavior +import dev.kord.core.event.interaction.UserCommandInteractionCreateEvent + +/** Command context for an unsafe user command. **/ +@UnsafeAPI +public class UnsafeUserCommandContext( + override val event: UserCommandInteractionCreateEvent, + override val command: UserCommand, + override var interactionResponse: InteractionResponseBehavior? +) : UserCommandContext(event, command), UnsafeInteractionContext diff --git a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/UnionConverter.kt b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/converters/UnionConverter.kt similarity index 66% rename from kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/UnionConverter.kt rename to modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/converters/UnionConverter.kt index 333de39257..a867bbba90 100644 --- a/kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/UnionConverter.kt +++ b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/converters/UnionConverter.kt @@ -4,21 +4,25 @@ ConverterToMulti::class, ConverterToOptional::class ) +@file:Suppress("StringLiteralDuplication") -package com.kotlindiscord.kord.extensions.commands.converters.impl +package com.kotlindiscord.kord.extensions.modules.unsafe.converters -import com.kotlindiscord.kord.extensions.CommandException +import com.kotlindiscord.kord.extensions.DiscordRelayedException +import com.kotlindiscord.kord.extensions.commands.Argument +import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.CommandContext import com.kotlindiscord.kord.extensions.commands.converters.* -import com.kotlindiscord.kord.extensions.commands.parser.Argument -import com.kotlindiscord.kord.extensions.commands.parser.Arguments import com.kotlindiscord.kord.extensions.i18n.TranslationsProvider +import com.kotlindiscord.kord.extensions.modules.unsafe.annotations.UnsafeAPI import com.kotlindiscord.kord.extensions.parser.StringParser import dev.kord.common.annotation.KordPreview +import dev.kord.core.entity.interaction.OptionValue import dev.kord.rest.builder.interaction.OptionsBuilder import dev.kord.rest.builder.interaction.StringChoiceBuilder import org.koin.core.component.inject +@UnsafeAPI private typealias GenericConverter = Converter<*, *, *, *> /** @@ -27,6 +31,7 @@ private typealias GenericConverter = Converter<*, *, *, *> * This converter does not support optional or defaulting converters. */ @OptIn(KordPreview::class) +@UnsafeAPI public class UnionConverter( private val converters: Collection, @@ -160,7 +165,7 @@ public class UnionConverter( if (shouldThrow) throw t } - else -> throw CommandException( + else -> throw DiscordRelayedException( context.translate( "converters.union.error.unknownConverterType", replacements = arrayOf(converter) @@ -174,6 +179,107 @@ public class UnionConverter( override suspend fun toSlashOption(arg: Argument<*>): OptionsBuilder = StringChoiceBuilder(arg.displayName, arg.description).apply { required = true } + + override suspend fun parseOption(context: CommandContext, option: OptionValue<*>): Boolean { + for (converter in converters) { + @Suppress("TooGenericExceptionCaught") + when (converter) { + is SingleConverter<*> -> try { + val result: Boolean = converter.parseOption(context, option) + + if (result) { + converter.parseSuccess = true + this.parsed = converter.parsed + + return true + } + } catch (t: Throwable) { + if (shouldThrow) throw t + } + + is DefaultingConverter<*> -> try { + val result: Boolean = converter.parseOption(context, option) + + if (result) { + converter.parseSuccess = true + this.parsed = converter.parsed + + return true + } + } catch (t: Throwable) { + if (shouldThrow) throw t + } + + is OptionalConverter<*> -> try { + val result: Boolean = converter.parseOption(context, option) + + if (result && converter.parsed != null) { + converter.parseSuccess = true + this.parsed = converter.parsed!! + + return true + } + } catch (t: Throwable) { + if (shouldThrow) throw t + } + + is MultiConverter<*> -> throw DiscordRelayedException( + context.translate( + "converters.union.error.unknownConverterType", + replacements = arrayOf(converter) + ) + ) + + is CoalescingConverter<*> -> try { + val result: Boolean = converter.parseOption(context, option) + + if (result) { + converter.parseSuccess = true + this.parsed = converter.parsed + + return true + } + } catch (t: Throwable) { + if (shouldThrow) throw t + } + + is DefaultingCoalescingConverter<*> -> try { + val result: Boolean = converter.parseOption(context, option) + + if (result) { + converter.parseSuccess = true + this.parsed = converter.parsed + + return true + } + } catch (t: Throwable) { + if (shouldThrow) throw t + } + + is OptionalCoalescingConverter<*> -> try { + val result: Boolean = converter.parseOption(context, option) + + if (result && converter.parsed != null) { + converter.parseSuccess = true + this.parsed = converter.parsed!! + + return result + } + } catch (t: Throwable) { + if (shouldThrow) throw t + } + + else -> throw DiscordRelayedException( + context.translate( + "converters.union.error.unknownConverterType", + replacements = arrayOf(converter) + ) + ) + } + } + + return false + } } /** @@ -184,6 +290,7 @@ public class UnionConverter( * * @see UnionConverter */ +@UnsafeAPI public fun Arguments.union( displayName: String, description: String, @@ -217,6 +324,7 @@ public fun Arguments.union( * * @see UnionConverter */ +@UnsafeAPI public fun Arguments.optionalUnion( displayName: String, description: String, diff --git a/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/extensions/_Commands.kt b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/extensions/_Commands.kt new file mode 100644 index 0000000000..4aaa4625b6 --- /dev/null +++ b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/extensions/_Commands.kt @@ -0,0 +1,161 @@ +@file:Suppress("StringLiteralDuplication") + +package com.kotlindiscord.kord.extensions.modules.unsafe.extensions + +import com.kotlindiscord.kord.extensions.CommandRegistrationException +import com.kotlindiscord.kord.extensions.InvalidCommandException +import com.kotlindiscord.kord.extensions.annotations.ExtensionDSL +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.extensions.Extension +import com.kotlindiscord.kord.extensions.modules.unsafe.annotations.UnsafeAPI +import com.kotlindiscord.kord.extensions.modules.unsafe.commands.UnsafeMessageCommand +import com.kotlindiscord.kord.extensions.modules.unsafe.commands.UnsafeSlashCommand +import com.kotlindiscord.kord.extensions.modules.unsafe.commands.UnsafeUserCommand +import mu.KotlinLogging + +private val logger = KotlinLogging.logger {} + +// region: Message commands + +/** Register an unsafe message command, DSL-style. **/ +@ExtensionDSL +@UnsafeAPI +public suspend fun Extension.unsafeMessageCommand( + body: suspend UnsafeMessageCommand.() -> Unit +): UnsafeMessageCommand { + val commandObj = UnsafeMessageCommand(this) + body(commandObj) + + return unsafeMessageCommand(commandObj) +} + +/** Register a custom instance of an unsafe message command. **/ +@ExtensionDSL +@UnsafeAPI +public suspend fun Extension.unsafeMessageCommand( + commandObj: UnsafeMessageCommand +): UnsafeMessageCommand { + try { + commandObj.validate() + messageCommands.add(commandObj) + } catch (e: CommandRegistrationException) { + logger.error(e) { "Failed to register message command ${commandObj.name} - $e" } + } catch (e: InvalidCommandException) { + logger.error(e) { "Failed to register message command ${commandObj.name} - $e" } + } + + if (applicationCommandRegistry.initialised) { + applicationCommandRegistry.register(commandObj) + } + + return commandObj +} + +// endregion + +// region: Slash commands (Unsafe) + +/** + * DSL function for easily registering an unsafe slash command, with arguments. + * + * Use this in your setup function to register a slash command that may be executed on Discord. + * + * @param arguments Arguments builder (probably a reference to the class constructor). + * @param body Builder lambda used for setting up the slash command object. + */ +@ExtensionDSL +@UnsafeAPI +public suspend fun Extension.unsafeSlashCommand( + arguments: () -> T, + body: suspend UnsafeSlashCommand.() -> Unit +): UnsafeSlashCommand { + val commandObj = UnsafeSlashCommand(this, arguments, null, null) + body(commandObj) + + return unsafeSlashCommand(commandObj) +} + +/** + * Function for registering a custom unsafe slash command object. + * + * You can use this if you have a custom unsafe slash command subclass you need to register. + * + * @param commandObj UnsafeSlashCommand object to register. + */ +@ExtensionDSL +@UnsafeAPI +public suspend fun Extension.unsafeSlashCommand( + commandObj: UnsafeSlashCommand +): UnsafeSlashCommand { + try { + commandObj.validate() + slashCommands.add(commandObj) + } catch (e: CommandRegistrationException) { + logger.error(e) { "Failed to register subcommand - $e" } + } catch (e: InvalidCommandException) { + logger.error(e) { "Failed to register subcommand - $e" } + } + + if (applicationCommandRegistry.initialised) { + applicationCommandRegistry.register(commandObj) + } + + return commandObj +} + +/** + * DSL function for easily registering an unsafe slash command, without arguments. + * + * Use this in your setup function to register a slash command that may be executed on Discord. + * + * @param body Builder lambda used for setting up the slash command object. + */ +@ExtensionDSL +@UnsafeAPI +public suspend fun Extension.unsafeSlashCommand( + body: suspend UnsafeSlashCommand.() -> Unit +): UnsafeSlashCommand { + val commandObj = UnsafeSlashCommand(this, null, null, null) + body(commandObj) + + return unsafeSlashCommand(commandObj) +} + +// endregion + +// region: User commands + +/** Register an unsafe user command, DSL-style. **/ +@ExtensionDSL +@UnsafeAPI +public suspend fun Extension.unsafeUserCommand( + body: suspend UnsafeUserCommand.() -> Unit +): UnsafeUserCommand { + val commandObj = UnsafeUserCommand(this) + body(commandObj) + + return unsafeUserCommand(commandObj) +} + +/** Register a custom instance of an unsafe user command. **/ +@ExtensionDSL +@UnsafeAPI +public suspend fun Extension.unsafeUserCommand( + commandObj: UnsafeUserCommand +): UnsafeUserCommand { + try { + commandObj.validate() + userCommands.add(commandObj) + } catch (e: CommandRegistrationException) { + logger.error(e) { "Failed to register message command ${commandObj.name} - $e" } + } catch (e: InvalidCommandException) { + logger.error(e) { "Failed to register message command ${commandObj.name} - $e" } + } + + if (applicationCommandRegistry.initialised) { + applicationCommandRegistry.register(commandObj) + } + + return commandObj +} +// endregion diff --git a/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/extensions/_SlashCommands.kt b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/extensions/_SlashCommands.kt new file mode 100644 index 0000000000..bcd5c09816 --- /dev/null +++ b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/extensions/_SlashCommands.kt @@ -0,0 +1,153 @@ +@file:Suppress("StringLiteralDuplication") + +package com.kotlindiscord.kord.extensions.modules.unsafe.extensions + +import com.kotlindiscord.kord.extensions.CommandRegistrationException +import com.kotlindiscord.kord.extensions.InvalidCommandException +import com.kotlindiscord.kord.extensions.commands.Arguments +import com.kotlindiscord.kord.extensions.commands.application.slash.SlashCommand +import com.kotlindiscord.kord.extensions.commands.application.slash.SlashGroup +import com.kotlindiscord.kord.extensions.modules.unsafe.annotations.UnsafeAPI +import com.kotlindiscord.kord.extensions.modules.unsafe.commands.UnsafeSlashCommand + +private const val SUBCOMMAND_AND_GROUP_LIMIT: Int = 25 + +// region: Slash commands (Unsafe) + +/** + * DSL function for easily registering an unsafe subcommand, with arguments. + * + * Use this in your setup function to register a subcommand that may be executed on Discord. + * + * @param arguments Arguments builder (probably a reference to the class constructor). + * @param body Builder lambda used for setting up the slash command object. + */ +@UnsafeAPI +public suspend fun SlashCommand<*, *>.unsafeSubCommand( + arguments: () -> T, + body: suspend UnsafeSlashCommand.() -> Unit +): UnsafeSlashCommand { + val commandObj = UnsafeSlashCommand(extension, arguments, parentCommand, parentGroup) + body(commandObj) + + return unsafeSubCommand(commandObj) +} + +/** + * Function for registering a custom unsafe slash command object, for subcommands. + * + * You can use this if you have a custom unsafe slash command subclass you need to register. + * + * @param commandObj UnsafeSlashCommand object to register as a subcommand. + */ +@UnsafeAPI +public fun SlashCommand<*, *>.unsafeSubCommand( + commandObj: UnsafeSlashCommand +): UnsafeSlashCommand { + if (subCommands.size >= SUBCOMMAND_AND_GROUP_LIMIT) { + throw InvalidCommandException( + commandObj.name, + "Groups may only contain up to $SUBCOMMAND_AND_GROUP_LIMIT commands." + ) + } + + try { + commandObj.validate() + subCommands.add(commandObj) + } catch (e: CommandRegistrationException) { + logger.error(e) { "Failed to register subcommand - $e" } + } catch (e: InvalidCommandException) { + logger.error(e) { "Failed to register subcommand - $e" } + } + + return commandObj +} + +/** + * DSL function for easily registering an unsafe subcommand, without arguments. + * + * Use this in your slash command function to register a subcommand that may be executed on Discord. + * + * @param body Builder lambda used for setting up the subcommand object. + */ +@UnsafeAPI +public suspend fun SlashCommand<*, *>.unsafeSubCommand( + body: suspend UnsafeSlashCommand.() -> Unit +): UnsafeSlashCommand { + val commandObj = UnsafeSlashCommand(extension, null, parentCommand, parentGroup) + body(commandObj) + + return unsafeSubCommand(commandObj) +} + +// endregion + +// region: Slash groups (Unsafe) + +/** + * DSL function for easily registering an unsafe subcommand, with arguments. + * + * Use this in your setup function to register a subcommand that may be executed on Discord. + * + * @param arguments Arguments builder (probably a reference to the class constructor). + * @param body Builder lambda used for setting up the slash command object. + */ +@UnsafeAPI +public suspend fun SlashGroup.unsafeSubCommand( + arguments: () -> T, + body: suspend UnsafeSlashCommand.() -> Unit +): UnsafeSlashCommand { + val commandObj = UnsafeSlashCommand(parent.extension, arguments, parent, this) + body(commandObj) + + return unsafeSubCommand(commandObj) +} + +/** + * Function for registering a custom unsafe slash command object, for subcommands. + * + * You can use this if you have a custom unsafe slash command subclass you need to register. + * + * @param commandObj UnsafeSlashCommand object to register as a subcommand. + */ +@UnsafeAPI +public fun SlashGroup.unsafeSubCommand( + commandObj: UnsafeSlashCommand +): UnsafeSlashCommand { + if (subCommands.size >= SUBCOMMAND_AND_GROUP_LIMIT) { + throw InvalidCommandException( + commandObj.name, + "Groups may only contain up to $SUBCOMMAND_AND_GROUP_LIMIT commands." + ) + } + + try { + commandObj.validate() + subCommands.add(commandObj) + } catch (e: CommandRegistrationException) { + logger.error(e) { "Failed to register subcommand - $e" } + } catch (e: InvalidCommandException) { + logger.error(e) { "Failed to register subcommand - $e" } + } + + return commandObj +} + +/** + * DSL function for easily registering an unsafe subcommand, without arguments. + * + * Use this in your slash command function to register a subcommand that may be executed on Discord. + * + * @param body Builder lambda used for setting up the subcommand object. + */ +@UnsafeAPI +public suspend fun SlashGroup.unsafeSubCommand( + body: suspend UnsafeSlashCommand.() -> Unit +): UnsafeSlashCommand { + val commandObj = UnsafeSlashCommand(parent.extension, null, parent, this) + body(commandObj) + + return unsafeSubCommand(commandObj) +} + +// endregion diff --git a/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/types/InitialMessageCommandResponse.kt b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/types/InitialMessageCommandResponse.kt new file mode 100644 index 0000000000..9b03559863 --- /dev/null +++ b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/types/InitialMessageCommandResponse.kt @@ -0,0 +1,34 @@ +package com.kotlindiscord.kord.extensions.modules.unsafe.types + +import com.kotlindiscord.kord.extensions.commands.application.message.InitialEphemeralMessageResponseBuilder +import com.kotlindiscord.kord.extensions.commands.application.message.InitialPublicMessageResponseBuilder +import com.kotlindiscord.kord.extensions.modules.unsafe.annotations.UnsafeAPI + +/** Sealed class representing the initial response types for an unsafe message command. **/ +@UnsafeAPI +public sealed class InitialMessageCommandResponse { + /** Respond with an ephemeral ack, without any content. **/ + public object EphemeralAck : InitialMessageCommandResponse() + + /** Respond with a public ack, without any content. **/ + public object PublicAck : InitialMessageCommandResponse() + + /** Don't respond. Warning: You may not be able to respond in time! **/ + public object None : InitialMessageCommandResponse() + + /** + * Respond with an ephemeral ack, including message content. + * + * @param builder Response builder, containing the message content. + */ + public data class EphemeralResponse(val builder: InitialEphemeralMessageResponseBuilder) : + InitialMessageCommandResponse() + + /** + * Respond with a public ack, including message content. + * + * @param builder Response builder, containing the message content. + **/ + public data class PublicResponse(val builder: InitialPublicMessageResponseBuilder) : + InitialMessageCommandResponse() +} diff --git a/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/types/InitialSlashCommandResponse.kt b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/types/InitialSlashCommandResponse.kt new file mode 100644 index 0000000000..21ae7243a3 --- /dev/null +++ b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/types/InitialSlashCommandResponse.kt @@ -0,0 +1,34 @@ +package com.kotlindiscord.kord.extensions.modules.unsafe.types + +import com.kotlindiscord.kord.extensions.commands.application.slash.InitialEphemeralSlashResponseBuilder +import com.kotlindiscord.kord.extensions.commands.application.slash.InitialPublicSlashResponseBehavior +import com.kotlindiscord.kord.extensions.modules.unsafe.annotations.UnsafeAPI + +/** Sealed class representing the initial response types for an unsafe slash command. **/ +@UnsafeAPI +public sealed class InitialSlashCommandResponse { + /** Respond with an ephemeral ack, without any content. **/ + public object EphemeralAck : InitialSlashCommandResponse() + + /** Respond with a public ack, without any content. **/ + public object PublicAck : InitialSlashCommandResponse() + + /** Don't respond. Warning: You may not be able to respond in time! **/ + public object None : InitialSlashCommandResponse() + + /** + * Respond with an ephemeral ack, including message content. + * + * @param builder Response builder, containing the message content. + */ + public data class EphemeralResponse(val builder: InitialEphemeralSlashResponseBuilder) : + InitialSlashCommandResponse() + + /** + * Respond with a public ack, including message content. + * + * @param builder Response builder, containing the message content. + **/ + public data class PublicResponse(val builder: InitialPublicSlashResponseBehavior) : + InitialSlashCommandResponse() +} diff --git a/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/types/InitialUserCommandResponse.kt b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/types/InitialUserCommandResponse.kt new file mode 100644 index 0000000000..bef7c39269 --- /dev/null +++ b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/types/InitialUserCommandResponse.kt @@ -0,0 +1,34 @@ +package com.kotlindiscord.kord.extensions.modules.unsafe.types + +import com.kotlindiscord.kord.extensions.commands.application.user.InitialEphemeralUserResponseBuilder +import com.kotlindiscord.kord.extensions.commands.application.user.InitialPublicUserResponseBuilder +import com.kotlindiscord.kord.extensions.modules.unsafe.annotations.UnsafeAPI + +/** Sealed class representing the initial response types for an unsafe user command. **/ +@UnsafeAPI +public sealed class InitialUserCommandResponse { + /** Respond with an ephemeral ack, without any content. **/ + public object EphemeralAck : InitialUserCommandResponse() + + /** Respond with a public ack, without any content. **/ + public object PublicAck : InitialUserCommandResponse() + + /** Don't respond. Warning: You may not be able to respond in time! **/ + public object None : InitialUserCommandResponse() + + /** + * Respond with an ephemeral ack, including message content. + * + * @param builder Response builder, containing the message content. + */ + public data class EphemeralResponse(val builder: InitialEphemeralUserResponseBuilder) : + InitialUserCommandResponse() + + /** + * Respond with a public ack, including message content. + * + * @param builder Response builder, containing the message content. + **/ + public data class PublicResponse(val builder: InitialPublicUserResponseBuilder) : + InitialUserCommandResponse() +} diff --git a/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/types/UnsafeInteractionContext.kt b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/types/UnsafeInteractionContext.kt new file mode 100644 index 0000000000..d32b5be24c --- /dev/null +++ b/modules/unsafe/src/main/kotlin/com/kotlindiscord/kord/extensions/modules/unsafe/types/UnsafeInteractionContext.kt @@ -0,0 +1,146 @@ +@file:Suppress("StringLiteralDuplication") + +package com.kotlindiscord.kord.extensions.modules.unsafe.types + +import com.kotlindiscord.kord.extensions.modules.unsafe.annotations.UnsafeAPI +import com.kotlindiscord.kord.extensions.pagination.BaseButtonPaginator +import com.kotlindiscord.kord.extensions.pagination.EphemeralResponsePaginator +import com.kotlindiscord.kord.extensions.pagination.PublicFollowUpPaginator +import com.kotlindiscord.kord.extensions.pagination.PublicResponsePaginator +import com.kotlindiscord.kord.extensions.pagination.builders.PaginatorBuilder +import dev.kord.core.behavior.interaction.* +import dev.kord.core.entity.interaction.EphemeralFollowupMessage +import dev.kord.core.entity.interaction.PublicFollowupMessage +import dev.kord.core.event.interaction.ApplicationInteractionCreateEvent +import dev.kord.rest.builder.message.create.FollowupMessageCreateBuilder +import dev.kord.rest.builder.message.create.InteractionResponseCreateBuilder +import dev.kord.rest.builder.message.modify.InteractionResponseModifyBuilder +import java.util.* + +/** Interface representing a generic, unsafe interaction action context. **/ +@UnsafeAPI +public interface UnsafeInteractionContext { + /** Response created by acknowledging the interaction. Generic. **/ + public var interactionResponse: InteractionResponseBehavior? + + /** Original interaction event object, for manual acks. **/ + public val event: ApplicationInteractionCreateEvent +} + +/** Send an ephemeral ack, if the interaction hasn't been acknowledged yet. **/ +@UnsafeAPI +public suspend fun UnsafeInteractionContext.ackEphemeral( + builder: (suspend InteractionResponseCreateBuilder.() -> Unit)? = null +): EphemeralInteractionResponseBehavior { + if (interactionResponse != null) { + error("The interaction has already been acknowledged.") + } + + interactionResponse = if (builder == null) { + event.interaction.acknowledgeEphemeral() + } else { + event.interaction.respondEphemeral { builder() } + } + + return interactionResponse as EphemeralInteractionResponseBehavior +} + +/** Send a public ack, if the interaction hasn't been acknowledged yet. **/ +@UnsafeAPI +public suspend fun UnsafeInteractionContext.ackPublic( + builder: (suspend InteractionResponseCreateBuilder.() -> Unit)? = null +): PublicInteractionResponseBehavior { + if (interactionResponse != null) { + error("The interaction has already been acknowledged.") + } + + interactionResponse = if (builder == null) { + event.interaction.acknowledgePublic() + } else { + event.interaction.respondPublic { builder() } + } + + return interactionResponse as PublicInteractionResponseBehavior +} + +/** Respond to the current interaction with an ephemeral followup, or throw if it isn't ephemeral. **/ +@UnsafeAPI +public suspend inline fun UnsafeInteractionContext.respondEphemeral( + builder: FollowupMessageCreateBuilder.() -> Unit +): EphemeralFollowupMessage { + return when (val interaction = interactionResponse) { + is InteractionResponseBehavior -> interaction.followUpEphemeral(builder) + + null -> error("Acknowledge the interaction before trying to follow-up.") + else -> error("Unsupported initial interaction response type $interaction - please report this.") + } +} + +/** Respond to the current interaction with a public followup. **/ +@UnsafeAPI +public suspend inline fun UnsafeInteractionContext.respondPublic( + builder: FollowupMessageCreateBuilder.() -> Unit +): PublicFollowupMessage { + return when (val interaction = interactionResponse) { + is InteractionResponseBehavior -> interaction.followUp { builder() } + + null -> error("Acknowledge the interaction before trying to follow-up.") + else -> error("Unsupported initial interaction response type $interaction - please report this.") + } +} + +/** + * Edit the current interaction's response, or throw if it isn't public. + */ +@Suppress("UseIfInsteadOfWhen") +@UnsafeAPI +public suspend inline fun UnsafeInteractionContext.edit( + builder: InteractionResponseModifyBuilder.() -> Unit +) { + return when (val interaction = interactionResponse) { + is InteractionResponseBehavior -> interaction.edit(builder) + + null -> error("Acknowledge the interaction before trying to edit it.") + else -> error("Unsupported initial interaction response type $interaction - please report this.") + } +} + +/** Create a paginator that edits the original interaction. **/ +@UnsafeAPI +public suspend inline fun UnsafeInteractionContext.editingPaginator( + defaultGroup: String = "", + locale: Locale? = null, + builder: (PaginatorBuilder).() -> Unit +): BaseButtonPaginator { + val pages = PaginatorBuilder(locale = locale, defaultGroup = defaultGroup) + + builder(pages) + + return when (val interaction = interactionResponse) { + is PublicInteractionResponseBehavior -> PublicResponsePaginator(pages, interaction) + is EphemeralInteractionResponseBehavior -> EphemeralResponsePaginator(pages, interaction) + + null -> error("Acknowledge the interaction before trying to edit it.") + else -> error("Unsupported initial interaction response type - please report this.") + } +} + +/** Create a paginator that creates a follow-up message, and edits that. **/ +@Suppress("UseIfInsteadOfWhen") +@UnsafeAPI +public suspend inline fun UnsafeInteractionContext.respondingPaginator( + defaultGroup: String = "", + locale: Locale? = null, + builder: (PaginatorBuilder).() -> Unit +): BaseButtonPaginator { + val pages = PaginatorBuilder(locale = locale, defaultGroup = defaultGroup) + + builder(pages) + + return when (val interaction = interactionResponse) { + is PublicInteractionResponseBehavior -> PublicFollowUpPaginator(pages, interaction) + + null -> error("Acknowledge the interaction before trying to follow-up.") + else -> error("Initial interaction response was not public.") + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 502eafdffd..0e793fb0e5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,20 +1,5 @@ -pluginManagement { - repositories { - google() - gradlePluginPortal() - } - - plugins { - // NOTE: UPDATE THIS IF YOU UPDATE THE LIBS.VERSIONS.TOML - - kotlin("jvm") version "1.5.10" - kotlin("plugin.serialization") version "1.5.10" - - id("com.google.devtools.ksp") version "1.5.10-1.0.0-beta02" - id("io.gitlab.arturbosch.detekt") version "1.17.1" - id("org.jetbrains.dokka") version "1.4.10.2" - } -} +// NOTE: UPDATE THIS IF YOU UPDATE THE LIBS.VERSIONS.TOML +// NOTE: All the plugins and plugin repositories moved to buildSrc/build.gradle.kts rootProject.name = "kord-extensions" @@ -35,8 +20,10 @@ include("kord-extensions") include("extra-modules:extra-common") include("extra-modules:extra-mappings") +include("extra-modules:extra-phishing") include("modules:java-time") include("modules:time4j") +include("modules:unsafe") include("token-parser") diff --git a/token-parser/build.gradle.kts b/token-parser/build.gradle.kts index 2e9c01e352..783bfa3612 100644 --- a/token-parser/build.gradle.kts +++ b/token-parser/build.gradle.kts @@ -1,13 +1,8 @@ -import java.io.ByteArrayOutputStream -import java.net.URL - plugins { - `maven-publish` - - kotlin("jvm") - - id("io.gitlab.arturbosch.detekt") - id("org.jetbrains.dokka") + `kordex-module` + `published-module` + `dokka-module` + `tested-module` } dependencies { @@ -23,136 +18,6 @@ dependencies { testImplementation(libs.logback) } -val sourceJar = task("sourceJar", Jar::class) { - dependsOn(tasks["classes"]) - archiveClassifier.set("sources") - from(sourceSets.main.get().allSource) -} - -val javadocJar = task("javadocJar", Jar::class) { - dependsOn("dokkaJavadoc") - archiveClassifier.set("javadoc") - from(tasks.javadoc) -} - -java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 -} - -kotlin { - explicitApi() -} - -detekt { - buildUponDefaultConfig = true - config = files("$rootDir/detekt.yml") - - autoCorrect = true -} - -publishing { - repositories { - maven { - name = "KotDis" - - url = if (project.version.toString().contains("SNAPSHOT")) { - uri("https://maven.kotlindiscord.com/repository/maven-snapshots/") - } else { - uri("https://maven.kotlindiscord.com/repository/maven-releases/") - } - - credentials { - username = project.findProperty("kotdis.user") as String? ?: System.getenv("KOTLIN_DISCORD_USER") - password = project.findProperty("kotdis.password") as String? - ?: System.getenv("KOTLIN_DISCORD_PASSWORD") - } - - version = project.version - } - } - - publications { - create("maven") { - from(components.getByName("java")) - - artifact(sourceJar) - artifact(javadocJar) - } - } -} - -fun runCommand(command: String): String { - val output = ByteArrayOutputStream() - - project.exec { - commandLine(command.split(" ")) - standardOutput = output - } - - return output.toString().trim() -} - -fun getCurrentGitBranch(): String { // https://gist.github.com/lordcodes/15b2a4aecbeff7c3238a70bfd20f0931 - var gitBranch = "Unknown branch" - - try { - gitBranch = runCommand("git rev-parse --abbrev-ref HEAD") - } catch (t: Throwable) { - println(t) - } - - return gitBranch -} - -tasks.dokkaHtml.configure { - moduleName.set("Kord Extensions: Annotation Processor") - - dokkaSourceSets { - configureEach { - includeNonPublic.set(false) - skipDeprecated.set(false) - - displayName.set("Kord Extensions: Java Time") - includes.from("packages.md") - jdkVersion.set(8) - - sourceLink { - localDirectory.set(file("${project.projectDir}/src/main/kotlin")) - - remoteUrl.set( - URL( - "https://github.com/Kotlin-Discord/kord-extensions/" + - "tree/${getCurrentGitBranch()}/annotation-processor/src/main/kotlin" - ) - ) - - remoteLineSuffix.set("#L") - } - - externalDocumentationLink { - url.set(URL("http://kordlib.github.io/kord/common/common/")) - } - - externalDocumentationLink { - url.set(URL("http://kordlib.github.io/kord/core/core/")) - } - } - } -} - -tasks.test { - useJUnitPlatform() - - testLogging.showStandardStreams = true - - testLogging { - events("PASSED", "FAILED", "SKIPPED", "STANDARD_OUT", "STANDARD_ERROR") - } - - systemProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug") -} - -tasks.build { - this.finalizedBy(sourceJar, javadocJar) +dokkaModule { + moduleName.set("Kord Extensions: Token Parser") }