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

Restrict Snowflake range, type changes, consistent naming #384

Merged
merged 14 commits into from
Sep 5, 2021
Merged
Show file tree
Hide file tree
Changes from 13 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
12 changes: 9 additions & 3 deletions common/src/main/kotlin/entity/DiscordActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ data class DiscordActivity(
val url: Optional<String?> = Optional.Missing(),
@SerialName("created_at")
val createdAt: Long,
val timestamps: Optional<DiscordActivityTimeStamps> = Optional.Missing(),
val timestamps: Optional<DiscordActivityTimestamps> = Optional.Missing(),
@SerialName("application_id")
val applicationId: OptionalSnowflake = OptionalSnowflake.Missing,
val details: Optional<String?> = Optional.Missing(),
Expand Down Expand Up @@ -67,9 +67,15 @@ class ActivityFlags(val value: Int) {
}
}

@Deprecated(
"DiscordActivityTimeStamps was renamed to DiscordActivityTimestamps.",
ReplaceWith("DiscordActivityTimestamps"),
DeprecationLevel.ERROR,
)
typealias DiscordActivityTimeStamps = DiscordActivityTimestamps

@Serializable
data class DiscordActivityTimeStamps(
data class DiscordActivityTimestamps(
val start: OptionalLong = OptionalLong.Missing,
val end: OptionalLong = OptionalLong.Missing
)
Expand Down Expand Up @@ -154,4 +160,4 @@ enum class ActivityType(val code: Int) {
}
}

}
}
7 changes: 4 additions & 3 deletions common/src/main/kotlin/entity/DiscordGuild.kt
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ data class DiscordGuild(
val welcomeScreen: Optional<DiscordWelcomeScreen> = Optional.Missing(),
@SerialName("nsfw_level")
val nsfwLevel: NsfwLevel
)
)

/**
* A partial representation of a [Discord Guild structure](https://discord.com/developers/docs/resources/guild#guild-object
Expand Down Expand Up @@ -242,7 +242,7 @@ sealed class GuildFeature(val value: String) {

/** Guild has access to the seven day archive time for threads */
object SevenDayThreadArchive : GuildFeature("SEVEN_DAY_THREAD_ARCHIVE")

/** Guild has access to create private threads */
object PrivateThreads : GuildFeature("PRIVATE_THREADS")

Expand Down Expand Up @@ -320,7 +320,7 @@ enum class SystemChannelFlag(val code: Int) {
@Serializable
data class DiscordGuildBan(
@SerialName("guild_id")
val guildId: String,
val guildId: Snowflake,
val user: DiscordUser,
)

Expand Down Expand Up @@ -526,6 +526,7 @@ sealed class MFALevel(val value: Int) {
}
}
}

/**
* A representation of a [Discord Guild NSFW Level](https://discord.com/developers/docs/resources/guild#guild-object-guild-nsfw-level).
*/
Expand Down
89 changes: 67 additions & 22 deletions common/src/main/kotlin/entity/Snowflake.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package dev.kord.common.entity

import dev.kord.common.entity.Snowflake.Companion.validValues
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.ExperimentalSerializationApi
Expand All @@ -14,36 +15,52 @@ import kotlin.time.TimeMark

/**
* A unique identifier for entities [used by discord](https://discord.com/developers/docs/reference#snowflakes).
* Snowflakes are IDs with a [timestamp], which makes them [comparable][Comparable] based on their timestamp.
*
* Note: this class has a natural ordering that is inconsistent with [equals],
* since [compareTo] only compares the first 42 bits of the ULong [value] (comparing the timestamp),
* whereas [equals] uses all bits of the ULong [value].
* [compareTo] can return `0` even if [equals] returns `false`,
* but [equals] only returns `true` if [compareTo] returns `0`.
*
* @constructor Creates a Snowflake from a given ULong [value].
*/
@Serializable(with = Snowflake.Serializer::class)
class Snowflake(val value: ULong) : Comparable<Snowflake> {
class Snowflake : Comparable<Snowflake> {

/**
* The raw value of this Snowflake as specified
* [here](https://discord.com/developers/docs/reference#snowflakes).
*/
val value: ULong

/**
* Creates a Snowflake from a given ULong [value].
*
* Values are [coerced in][coerceIn] [validValues].
*/
constructor(value: ULong) {
this.value = value.coerceIn(validValues)
}

/**
* Creates a Snowflake from a given String [value], parsing it as a [ULong] value.
*
* Values are [coerced in][coerceIn] [validValues].
*/
constructor(value: String) : this(value.toULong())

/**
* Creates a Snowflake from a given [instant].
* Creates a Snowflake from a given [timestamp].
*
* If the given [instant] is too far in the past / future, this constructor will create
* an instance with a [timeStamp] equal to [Snowflake.min] / [Snowflake.max].
* If the given timestamp is too far in the past / future, this constructor will create an instance with a
* [timestamp][Snowflake.timestamp] equal to the timestamp of [Snowflake.min] / [Snowflake.max].
*/
constructor(instant: Instant) : this(
instant.toEpochMilliseconds()
constructor(timestamp: Instant) : this(
timestamp.toEpochMilliseconds()
.coerceAtLeast(discordEpochLong) // time before is unknown to Snowflakes
.minus(discordEpochLong)
.coerceAtMost(maxMillisecondsSinceDiscordEpoch) // time after is unknown to Snowflakes
.toULong()
.shl(22)
.shl(nonTimestampBitCount)
)

/**
Expand All @@ -54,14 +71,23 @@ class Snowflake(val value: ULong) : Comparable<Snowflake> {
/**
* The point in time this Snowflake represents.
*/
val timeStamp: Instant get() = Instant.fromEpochMilliseconds(discordEpochLong + (value shr 22).toLong())
@Deprecated("timeStamp was renamed to timestamp.", ReplaceWith("timestamp"), DeprecationLevel.ERROR)
val timeStamp: Instant
get() = timestamp

/**
* The point in time this Snowflake represents.
*/
val timestamp: Instant
get() = Instant.fromEpochMilliseconds(value.shr(nonTimestampBitCount).toLong().plus(discordEpochLong))

/**
* A [TimeMark] for the point in time this Snowflake represents.
*/
val timeMark: TimeMark get() = SnowflakeTimeMark(timeStamp)
val timeMark: TimeMark get() = SnowflakeTimeMark(timestamp)

override fun compareTo(other: Snowflake): Int = value.shr(22).compareTo(other.value.shr(22))
override fun compareTo(other: Snowflake): Int =
value.shr(nonTimestampBitCount).compareTo(other.value.shr(nonTimestampBitCount))

override fun toString(): String = "Snowflake(value=$value)"

Expand All @@ -72,13 +98,33 @@ class Snowflake(val value: ULong) : Comparable<Snowflake> {
}

companion object {
private const val discordEpochLong = 1420070400000L // 42 one bits
private const val maxMillisecondsSinceDiscordEpoch = 0b111111111111111111111111111111111111111111L
private const val nonTimestampBitCount = 22
private const val discordEpochLong = 1420070400000L

/**
* A range that contains all valid raw Snowflake [value]s.
*
* Note that this range might change in the future.
*/
val validValues: ULongRange = ULong.MIN_VALUE..Long.MAX_VALUE.toULong() // 0..9223372036854775807
lukellmann marked this conversation as resolved.
Show resolved Hide resolved

private val maxMillisecondsSinceDiscordEpoch = validValues.last.shr(nonTimestampBitCount).toLong()

/**
* The point in time that marks the Discord Epoch (the first second of 2015).
*/
@Deprecated(
"Snowflake.discordEpochStart was renamed to Snowflake.discordEpoch.",
ReplaceWith("Snowflake.discordEpoch"),
DeprecationLevel.ERROR,
)
val discordEpochStart: Instant
get() = discordEpoch

/**
* The point in time that marks the Discord Epoch (the first second of 2015).
*/
val discordEpochStart: Instant = Instant.fromEpochMilliseconds(discordEpochLong)
val discordEpoch: Instant = Instant.fromEpochMilliseconds(discordEpochLong)

/**
* The last point in time a Snowflake can represent.
Expand All @@ -89,13 +135,13 @@ class Snowflake(val value: ULong) : Comparable<Snowflake> {
* The maximum value a Snowflake can hold.
* Useful when requesting paginated entities.
*/
val max: Snowflake = Snowflake(ULong.MAX_VALUE)
val max: Snowflake = Snowflake(validValues.last)

/**
* The minimum value a Snowflake can hold.
* Useful when requesting paginated entities.
*/
val min: Snowflake = Snowflake(ULong.MIN_VALUE)
val min: Snowflake = Snowflake(validValues.first)
}

@OptIn(ExperimentalSerializationApi::class)
Expand All @@ -112,15 +158,14 @@ class Snowflake(val value: ULong) : Comparable<Snowflake> {
}
}

private class SnowflakeTimeMark(private val timeStamp: Instant) : TimeMark() {
private class SnowflakeTimeMark(private val timestamp: Instant) : TimeMark() {

override fun elapsedNow(): Duration = Clock.System.now() - timeStamp
override fun elapsedNow(): Duration = Clock.System.now() - timestamp
}

/**
* Creates a [Snowflake] from a given Long [value].
*
* Note: a negative [value] will be interpreted as an unsigned integer with the same binary representation, e.g.
* passing `-1L` for [value] will return a [Snowflake] with a [value][Snowflake.value] of [ULong.MAX_VALUE].
* Values are [coerced in][coerceIn] [validValues].
*/
fun Snowflake(value: Long): Snowflake = Snowflake(value.toULong())
fun Snowflake(value: Long): Snowflake = Snowflake(value.coerceAtLeast(0).toULong())
25 changes: 13 additions & 12 deletions common/src/main/kotlin/entity/optional/OptionalSnowflake.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder

/**
* Represents a value that encapsulate [the optional and value state of a Long in the Discord API](https://discord.com/developers/docs/reference#nullable-and-optional-resource-fields).
* Represents a value that encapsulate a [Snowflake]'s
* [optional and value state in the Discord API](https://discord.com/developers/docs/reference#nullable-and-optional-resource-fields).
*
* Specifically:

* * [Missing] - a Long field that was not present in the serialized entity.
* * [Value] - a Long field that was assigned a non-null value in the serialized entity.
*
* > Note that there is no nullable variant present. Use Long? or `OptionalSnowflake?` for this case instead.
* * [Missing] - a [Snowflake] field that was not present in the serialized entity.
* * [Value] - a [Snowflake] field that was assigned a non-null value in the serialized entity.
*
* > Note that there is no nullable variant present. Use `Snowflake?` or `OptionalSnowflake?` for this case instead.
*
* The base class is (de)serializable with kotlinx.serialization.
*
Expand Down Expand Up @@ -56,17 +58,18 @@ sealed class OptionalSnowflake {
}

/**
* Represents a Long field that was not present in the serialized entity.
* Represents a [Snowflake] field that was not present in the serialized entity.
*/
object Missing : OptionalSnowflake() {
override fun toString(): String = "OptionalSnowflake.Missing"
}

/**
* Represents a field that was assigned a non-null value in the serialized entity.
* Represents a [Snowflake] field that was assigned a non-null value in the serialized entity.
* Equality and hashcode is implemented through its [value].
*
* @param uLongValue the value this optional wraps.
* @param uLongValue the raw value this optional wraps.
* See [Snowflake.value] and [Snowflake.validValues] for more details.
*/
class Value(private val uLongValue: ULong) : OptionalSnowflake() {

Expand All @@ -91,8 +94,8 @@ sealed class OptionalSnowflake {

@OptIn(ExperimentalSerializationApi::class)
internal object Serializer : KSerializer<OptionalSnowflake> {
override val descriptor: SerialDescriptor
get() = @OptIn(ExperimentalUnsignedTypes::class) ULong.serializer().descriptor
override val descriptor: SerialDescriptor =
@OptIn(ExperimentalUnsignedTypes::class) ULong.serializer().descriptor

override fun deserialize(decoder: Decoder): OptionalSnowflake =
Value(decoder.decodeInline(descriptor).decodeLong().toULong())
Expand All @@ -101,9 +104,7 @@ sealed class OptionalSnowflake {
Missing -> Unit // ignore value
is Value -> encoder.encodeInline(descriptor).encodeLong(value.value.value.toLong())
}

}

}

/**
Expand Down
36 changes: 26 additions & 10 deletions common/src/test/kotlin/entity/SnowflakeTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,38 +11,54 @@ import kotlin.test.*
class SnowflakeTest {

@Test
fun `Snowflake with value ULong MIN_VALUE has timeStamp equal to discordEpochStart`() {
fun `min Snowflake's timestamp is equal to discordEpoch`() {
assertEquals(Snowflake.discordEpoch, Snowflake.min.timestamp)
}

@Test
fun `max Snowflake's timestamp is equal to endOfTime`() {
assertEquals(Snowflake.endOfTime, Snowflake.max.timestamp)
}

@Test
fun `Snowflake created from ULong MIN_VALUE has timestamp equal to discordEpoch`() {
val snowflake = Snowflake(ULong.MIN_VALUE)
assertEquals(Snowflake.discordEpochStart, snowflake.timeStamp)
assertEquals(Snowflake.discordEpoch, snowflake.timestamp)
}

@Test
fun `Snowflake with value ULong MAX_VALUE has timeStamp equal to endOfTime`() {
fun `Snowflake created from ULong MAX_VALUE has timestamp equal to endOfTime`() {
val snowflake = Snowflake(ULong.MAX_VALUE)
assertEquals(Snowflake.endOfTime, snowflake.timeStamp)
assertEquals(Snowflake.endOfTime, snowflake.timestamp)
}

@Test
fun `Snowflake created from Long MIN_VALUE has timestamp equal to discordEpoch`() {
val snowflake = Snowflake(Long.MIN_VALUE)
assertEquals(Snowflake.discordEpoch, snowflake.timestamp)
}

@Test
fun `Snowflake created from instant far in the past has timeStamp equal to the timeStamp of Snowflake min`() {
fun `Snowflake created from instant far in the past has timestamp equal to discordEpoch`() {
val snowflake = Snowflake(Instant.DISTANT_PAST)
assertEquals(Snowflake.min.timeStamp, snowflake.timeStamp)
assertEquals(Snowflake.discordEpoch, snowflake.timestamp)
}

@Test
fun `Snowflake created from instant far in the future has timeStamp equal to the timeStamp of Snowflake max`() {
fun `Snowflake created from instant far in the future has timestamp equal to endOfTime`() {
val snowflake = Snowflake(Instant.DISTANT_FUTURE)
assertEquals(Snowflake.max.timeStamp, snowflake.timeStamp)
assertEquals(Snowflake.endOfTime, snowflake.timestamp)
}

@Test
fun `Snowflake's timeStamp calculates an Instant close to the Instant the Snowflake was created from`() {
fun `Snowflake's timestamp calculates an Instant close to the Instant the Snowflake was created from`() {
val instant = Clock.System.now()
val snowflake = Snowflake(instant)

// snowflake timestamps have a millisecond accuracy -> allow +/-1 millisecond from original instant
val validTimeRange = instant.minus(1, MILLISECOND)..instant.plus(1, MILLISECOND)

assertContains(validTimeRange, snowflake.timeStamp)
assertContains(validTimeRange, snowflake.timestamp)
}

@Test
Expand Down
2 changes: 1 addition & 1 deletion core/src/main/kotlin/Util.kt
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ internal fun paginateThreads(
paginateByDate(
start,
batchSize,
{ threads -> threads.minOfOrNull { it.archiveTimeStamp } },
{ threads -> threads.minOfOrNull { it.archiveTimestamp } },
request
)

Expand Down
4 changes: 2 additions & 2 deletions core/src/main/kotlin/behavior/UserBehavior.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ interface UserBehavior : KordEntity, Strategizable {
* @throws [RestRequestException] if something went wrong during the request.
*/
suspend fun getDmChannel(): DmChannel {
val response = kord.rest.user.createDM(DMCreateRequest(id.asString))
val response = kord.rest.user.createDM(DMCreateRequest(id))
val data = ChannelData.from(response)

return Channel.from(data, kord) as DmChannel
Expand Down Expand Up @@ -122,4 +122,4 @@ fun UserBehavior(
override fun toString(): String {
return "UserBehavior(id=$id, kord=kord, supplier=$supplier)"
}
}
}
Loading