Skip to content

Commit

Permalink
Add converter for discord-formatted timestamps (#102)
Browse files Browse the repository at this point in the history
  • Loading branch information
Scotsguy committed Oct 12, 2021
1 parent 099874f commit ff578b4
Show file tree
Hide file tree
Showing 6 changed files with 191 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -48,10 +49,23 @@ public class DurationCoalescingConverter(
private val logger = KotlinLogging.logger {}

override suspend fun parse(parser: StringParser?, context: CommandContext, named: List<String>?): Int {
val durations: MutableList<String> = mutableListOf<String>()
// 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<String>()
val ignoredWords: List<String> = context.translate("utils.durations.ignoredWords").split(",")

var skipNext: Boolean = false
var skipNext = false

val args: List<String> = named ?: parser?.run {
val tokens: MutableList<String> = mutableListOf()
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = "<t:"
private const val TIMESTAMP_SUFFIX = ">"

/**
* Argument converter for discord-formatted timestamp arguments.
*/
@Converter(
"timestamp",

types = [ConverterType.DEFAULTING, ConverterType.LIST, ConverterType.OPTIONAL, ConverterType.SINGLE]
)
@OptIn(KordPreview::class)
public class TimestampConverter(
override var validator: Validator<FormattedTimestamp> = null
) : SingleConverter<FormattedTimestamp>() {
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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<t:$value${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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}**
Expand All @@ -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}**
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = "<t:1420070400>" // 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 = "<t:1420070400:R>"
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 = "<t::>"
val parsed = TimestampConverter.parseFromString(timestamp)
assertNull(parsed)
}

@Test
fun `timestamp with empty format`() {
val timestamp = "<t:1420070400:>"
val parsed = TimestampConverter.parseFromString(timestamp)
assertNull(parsed)
}
}

0 comments on commit ff578b4

Please sign in to comment.