From ff578b4f1b0843513a0428cc43166c057278bd04 Mon Sep 17 00:00:00 2001 From: AppleTheGolden Date: Tue, 12 Oct 2021 12:36:07 +0200 Subject: [PATCH] Add converter for discord-formatted timestamps (#102) --- .../impl/DurationCoalescingConverter.kt | 40 +++++--- .../converters/impl/DurationConverter.kt | 12 ++- .../converters/impl/TimestampConverter.kt | 92 +++++++++++++++++++ .../kord/extensions/time/TimestampType.kt | 19 ++++ .../translations/kordex/strings.properties | 14 +-- .../converters/impl/TimestampConverterTest.kt | 39 ++++++++ 6 files changed, 191 insertions(+), 25 deletions(-) create mode 100644 kord-extensions/src/main/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/TimestampConverter.kt create mode 100644 kord-extensions/src/test/kotlin/com/kotlindiscord/kord/extensions/commands/converters/impl/TimestampConverterTest.kt 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 cc0d884830..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 @@ -18,6 +18,7 @@ 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 @@ -37,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, @@ -48,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() @@ -121,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 DiscordRelayedException(context.translate("converters.duration.error.positiveOnly")) - } - } + checkPositive(context, result, positiveOnly) parsed = result } catch (e: InvalidTimeUnitException) { @@ -163,6 +170,17 @@ public class DurationCoalescingConverter( 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 } 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 259683f261..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 @@ -16,10 +16,12 @@ 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. @@ -34,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, @@ -46,7 +48,13 @@ 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() 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/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/resources/translations/kordex/strings.properties b/kord-extensions/src/main/resources/translations/kordex/strings.properties index d714898225..5b8c1e3a3c 100644 --- a/kord-extensions/src/main/resources/translations/kordex/strings.properties +++ b/kord-extensions/src/main/resources/translations/kordex/strings.properties @@ -5,7 +5,6 @@ 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 @@ -18,9 +17,7 @@ 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}** @@ -29,24 +26,18 @@ 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}** @@ -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 @@ -182,7 +173,6 @@ 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/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) + } +}