Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add converter for discord-formatted timestamps #102

Merged
merged 4 commits into from
Oct 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}
}