Skip to content

Commit e9ec786

Browse files
Add support for voice messages (#814)
see discord/discord-api-docs#6082 --------- Co-authored-by: Lukellmann <lukellmann@gmail.com>
1 parent d018a09 commit e9ec786

File tree

11 files changed

+234
-34
lines changed

11 files changed

+234
-34
lines changed

common/api/common.api

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2418,11 +2418,13 @@ public final class dev/kord/common/entity/DiscordApplicationKt {
24182418

24192419
public final class dev/kord/common/entity/DiscordAttachment {
24202420
public static final field Companion Ldev/kord/common/entity/DiscordAttachment$Companion;
2421-
public synthetic fun <init> (ILdev/kord/common/entity/Snowflake;Ljava/lang/String;Ldev/kord/common/entity/optional/Optional;Ldev/kord/common/entity/optional/Optional;ILjava/lang/String;Ljava/lang/String;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalBoolean;Lkotlinx/serialization/internal/SerializationConstructorMarker;)V
2422-
public fun <init> (Ldev/kord/common/entity/Snowflake;Ljava/lang/String;Ldev/kord/common/entity/optional/Optional;Ldev/kord/common/entity/optional/Optional;ILjava/lang/String;Ljava/lang/String;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalBoolean;)V
2423-
public synthetic fun <init> (Ldev/kord/common/entity/Snowflake;Ljava/lang/String;Ldev/kord/common/entity/optional/Optional;Ldev/kord/common/entity/optional/Optional;ILjava/lang/String;Ljava/lang/String;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalBoolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
2421+
public synthetic fun <init> (ILdev/kord/common/entity/Snowflake;Ljava/lang/String;Ldev/kord/common/entity/optional/Optional;Ldev/kord/common/entity/optional/Optional;ILjava/lang/String;Ljava/lang/String;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalBoolean;Ldev/kord/common/entity/optional/Optional;Ldev/kord/common/entity/optional/Optional;Lkotlinx/serialization/internal/SerializationConstructorMarker;)V
2422+
public fun <init> (Ldev/kord/common/entity/Snowflake;Ljava/lang/String;Ldev/kord/common/entity/optional/Optional;Ldev/kord/common/entity/optional/Optional;ILjava/lang/String;Ljava/lang/String;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalBoolean;Ldev/kord/common/entity/optional/Optional;Ldev/kord/common/entity/optional/Optional;)V
2423+
public synthetic fun <init> (Ldev/kord/common/entity/Snowflake;Ljava/lang/String;Ldev/kord/common/entity/optional/Optional;Ldev/kord/common/entity/optional/Optional;ILjava/lang/String;Ljava/lang/String;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalBoolean;Ldev/kord/common/entity/optional/Optional;Ldev/kord/common/entity/optional/Optional;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
24242424
public final fun component1 ()Ldev/kord/common/entity/Snowflake;
24252425
public final fun component10 ()Ldev/kord/common/entity/optional/OptionalBoolean;
2426+
public final fun component11 ()Ldev/kord/common/entity/optional/Optional;
2427+
public final fun component12 ()Ldev/kord/common/entity/optional/Optional;
24262428
public final fun component2 ()Ljava/lang/String;
24272429
public final fun component3 ()Ldev/kord/common/entity/optional/Optional;
24282430
public final fun component4 ()Ldev/kord/common/entity/optional/Optional;
@@ -2431,18 +2433,20 @@ public final class dev/kord/common/entity/DiscordAttachment {
24312433
public final fun component7 ()Ljava/lang/String;
24322434
public final fun component8 ()Ldev/kord/common/entity/optional/OptionalInt;
24332435
public final fun component9 ()Ldev/kord/common/entity/optional/OptionalInt;
2434-
public final fun copy (Ldev/kord/common/entity/Snowflake;Ljava/lang/String;Ldev/kord/common/entity/optional/Optional;Ldev/kord/common/entity/optional/Optional;ILjava/lang/String;Ljava/lang/String;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalBoolean;)Ldev/kord/common/entity/DiscordAttachment;
2435-
public static synthetic fun copy$default (Ldev/kord/common/entity/DiscordAttachment;Ldev/kord/common/entity/Snowflake;Ljava/lang/String;Ldev/kord/common/entity/optional/Optional;Ldev/kord/common/entity/optional/Optional;ILjava/lang/String;Ljava/lang/String;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalBoolean;ILjava/lang/Object;)Ldev/kord/common/entity/DiscordAttachment;
2436+
public final fun copy (Ldev/kord/common/entity/Snowflake;Ljava/lang/String;Ldev/kord/common/entity/optional/Optional;Ldev/kord/common/entity/optional/Optional;ILjava/lang/String;Ljava/lang/String;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalBoolean;Ldev/kord/common/entity/optional/Optional;Ldev/kord/common/entity/optional/Optional;)Ldev/kord/common/entity/DiscordAttachment;
2437+
public static synthetic fun copy$default (Ldev/kord/common/entity/DiscordAttachment;Ldev/kord/common/entity/Snowflake;Ljava/lang/String;Ldev/kord/common/entity/optional/Optional;Ldev/kord/common/entity/optional/Optional;ILjava/lang/String;Ljava/lang/String;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalInt;Ldev/kord/common/entity/optional/OptionalBoolean;Ldev/kord/common/entity/optional/Optional;Ldev/kord/common/entity/optional/Optional;ILjava/lang/Object;)Ldev/kord/common/entity/DiscordAttachment;
24362438
public fun equals (Ljava/lang/Object;)Z
24372439
public final fun getContentType ()Ldev/kord/common/entity/optional/Optional;
24382440
public final fun getDescription ()Ldev/kord/common/entity/optional/Optional;
2441+
public final fun getDurationSecs ()Ldev/kord/common/entity/optional/Optional;
24392442
public final fun getEphemeral ()Ldev/kord/common/entity/optional/OptionalBoolean;
24402443
public final fun getFilename ()Ljava/lang/String;
24412444
public final fun getHeight ()Ldev/kord/common/entity/optional/OptionalInt;
24422445
public final fun getId ()Ldev/kord/common/entity/Snowflake;
24432446
public final fun getProxyUrl ()Ljava/lang/String;
24442447
public final fun getSize ()I
24452448
public final fun getUrl ()Ljava/lang/String;
2449+
public final fun getWaveform ()Ldev/kord/common/entity/optional/Optional;
24462450
public final fun getWidth ()Ldev/kord/common/entity/optional/OptionalInt;
24472451
public fun hashCode ()I
24482452
public fun toString ()Ljava/lang/String;
@@ -7096,6 +7100,7 @@ public final class dev/kord/common/entity/MessageFlag : java/lang/Enum {
70967100
public static final field FailedToMentionSomeRolesInThread Ldev/kord/common/entity/MessageFlag;
70977101
public static final field HasThread Ldev/kord/common/entity/MessageFlag;
70987102
public static final field IsCrossPost Ldev/kord/common/entity/MessageFlag;
7103+
public static final field IsVoiceMessage Ldev/kord/common/entity/MessageFlag;
70997104
public static final field Loading Ldev/kord/common/entity/MessageFlag;
71007105
public static final field SourceMessageDeleted Ldev/kord/common/entity/MessageFlag;
71017106
public static final field SuppressEmbeds Ldev/kord/common/entity/MessageFlag;
@@ -7679,6 +7684,10 @@ public final class dev/kord/common/entity/Permission$SendTTSMessages : dev/kord/
76797684
public static final field INSTANCE Ldev/kord/common/entity/Permission$SendTTSMessages;
76807685
}
76817686

7687+
public final class dev/kord/common/entity/Permission$SendVoiceMessages : dev/kord/common/entity/Permission {
7688+
public static final field INSTANCE Ldev/kord/common/entity/Permission$SendVoiceMessages;
7689+
}
7690+
76827691
public final class dev/kord/common/entity/Permission$Speak : dev/kord/common/entity/Permission {
76837692
public static final field INSTANCE Ldev/kord/common/entity/Permission$Speak;
76847693
}
@@ -8705,6 +8714,15 @@ public final class dev/kord/common/serialization/DurationInDaysSerializer : dev/
87058714
public static final field INSTANCE Ldev/kord/common/serialization/DurationInDaysSerializer;
87068715
}
87078716

8717+
public final class dev/kord/common/serialization/DurationInDoubleSecondsSerializer : kotlinx/serialization/KSerializer {
8718+
public static final field INSTANCE Ldev/kord/common/serialization/DurationInDoubleSecondsSerializer;
8719+
public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
8720+
public fun deserialize-5sfh64U (Lkotlinx/serialization/encoding/Decoder;)J
8721+
public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
8722+
public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
8723+
public fun serialize-HG0u8IE (Lkotlinx/serialization/encoding/Encoder;J)V
8724+
}
8725+
87088726
public final class dev/kord/common/serialization/DurationInHoursSerializer : dev/kord/common/serialization/DurationAsLongSerializer {
87098727
public static final field INSTANCE Ldev/kord/common/serialization/DurationInHoursSerializer;
87108728
}

common/src/commonMain/kotlin/entity/DiscordMessage.kt

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import dev.kord.common.entity.optional.Optional
9191
import dev.kord.common.entity.optional.OptionalBoolean
9292
import dev.kord.common.entity.optional.OptionalInt
9393
import dev.kord.common.entity.optional.OptionalSnowflake
94+
import dev.kord.common.serialization.DurationInDoubleSeconds
9495
import dev.kord.common.serialization.LongOrStringSerializer
9596
import dev.kord.ksp.GenerateKordEnum
9697
import dev.kord.ksp.GenerateKordEnum.Entry
@@ -406,7 +407,12 @@ public enum class MessageFlag(public val code: Int) {
406407
FailedToMentionSomeRolesInThread(1 shl 8),
407408

408409
/** This message will not trigger push and desktop notifications. */
409-
SuppressNotifications(1 shl 12)
410+
SuppressNotifications(1 shl 12),
411+
412+
/**
413+
* This message is a voice message.
414+
*/
415+
IsVoiceMessage(1 shl 13)
410416
}
411417

412418
@Serializable(with = MessageFlags.Serializer::class)
@@ -502,15 +508,18 @@ public fun MessageFlags(flags: Iterable<MessageFlags>): MessageFlags = MessageFl
502508
/**
503509
* A representation of a [Discord Attachment structure](https://discord.com/developers/docs/resources/channel#attachment-object).
504510
*
505-
* @param id The attachment id.
506-
* @param filename The name of the attached file.
507-
* @param description The description for the file.
508-
* @param contentType The attachment's [media type](https://en.wikipedia.org/wiki/Media_type).
509-
* @param size The size of the file in bytes.
510-
* @param url The source url of the file.
511-
* @param proxyUrl A proxied url of the field.
512-
* @param height The height of the file (if it is an image).
513-
* @param width The width of the file (if it is an image).
511+
* @property id The attachment id.
512+
* @property filename The name of the attached file.
513+
* @property description The description for the file.
514+
* @property contentType The attachment's [media type](https://en.wikipedia.org/wiki/Media_type).
515+
* @property size The size of the file in bytes.
516+
* @property url The source url of the file.
517+
* @property proxyUrl A proxied url of the field.
518+
* @property height The height of the file (if it is an image).
519+
* @property width The width of the file (if it is an image).
520+
* @property ephemeral Whether this attachment is ephemeral
521+
* @property durationSecs The duration of the audio file (currently for voice messages)
522+
* @property waveform Base64 encoded bytearray representing a sampled waveform (currently for voice messages)
514523
*/
515524
@Serializable
516525
public data class DiscordAttachment(
@@ -534,7 +543,10 @@ public data class DiscordAttachment(
534543
*/
535544
val width: OptionalInt? = OptionalInt.Missing,
536545

537-
val ephemeral: OptionalBoolean = OptionalBoolean.Missing
546+
val ephemeral: OptionalBoolean = OptionalBoolean.Missing,
547+
@SerialName("duration_secs")
548+
val durationSecs: Optional<DurationInDoubleSeconds> = Optional.Missing(),
549+
val waveform: Optional<String> = Optional.Missing()
538550
)
539551

540552
/**

common/src/commonMain/kotlin/entity/Permission.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,10 @@ public sealed class Permission(public val code: DiscordBitSet) {
295295
/** Allows for using soundboard in a voice channel. */
296296
public object UseSoundboard : Permission(1L shl 42)
297297

298+
/**
299+
* Allows sending voice messages.
300+
*/
301+
public object SendVoiceMessages : Permission(1L shl 46)
298302

299303
/** All [Permission]s combined into one. */
300304
public object All : Permission(buildAll())
@@ -349,7 +353,8 @@ public sealed class Permission(public val code: DiscordBitSet) {
349353
UseEmbeddedActivities,
350354
ModerateMembers,
351355
ViewCreatorMonetizationAnalytics,
352-
UseSoundboard
356+
UseSoundboard,
357+
SendVoiceMessages
353358
)
354359
}
355360
}

common/src/commonMain/kotlin/serialization/DurationSerializers.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import kotlin.time.DurationUnit
1313
import kotlin.time.DurationUnit.*
1414
import kotlin.time.toDuration
1515

16+
// -------- as Long --------
1617

1718
/** Serializer that encodes and decodes [Duration]s as a [Long] number of the specified [unit]. */
1819
public sealed class DurationAsLongSerializer(
@@ -107,3 +108,22 @@ public object DurationInDaysSerializer : DurationAsLongSerializer(DAYS, "Duratio
107108

108109
/** A [Duration] that is [serializable][Serializable] with [DurationInDaysSerializer]. */
109110
public typealias DurationInDays = @Serializable(with = DurationInDaysSerializer::class) Duration
111+
112+
113+
// -------- as Double --------
114+
115+
/** Serializer that encodes and decodes [Duration]s as a [Double] number of seconds. */
116+
public object DurationInDoubleSecondsSerializer : KSerializer<Duration> {
117+
override val descriptor: SerialDescriptor =
118+
PrimitiveSerialDescriptor("dev.kord.common.serialization.DurationInDoubleSeconds", PrimitiveKind.DOUBLE)
119+
120+
override fun serialize(encoder: Encoder, value: Duration) {
121+
if (value.isInfinite()) throw SerializationException("Infinite Durations cannot be serialized, got $value")
122+
encoder.encodeDouble(value.toDouble(unit = SECONDS))
123+
}
124+
125+
override fun deserialize(decoder: Decoder): Duration = decoder.decodeDouble().toDuration(unit = SECONDS)
126+
}
127+
128+
/** A [Duration] that is [serializable][Serializable] with [DurationInDoubleSecondsSerializer]. */
129+
public typealias DurationInDoubleSeconds = @Serializable(with = DurationInDoubleSecondsSerializer::class) Duration
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package dev.kord.common.serialization
2+
3+
import kotlinx.serialization.SerializationException
4+
import kotlinx.serialization.json.Json
5+
import kotlin.test.Test
6+
import kotlin.test.assertEquals
7+
import kotlin.test.assertFailsWith
8+
import kotlin.time.Duration
9+
import kotlin.time.Duration.Companion.milliseconds
10+
import kotlin.time.Duration.Companion.nanoseconds
11+
import kotlin.time.Duration.Companion.seconds
12+
13+
class DurationInDoubleSecondsSerializerTest {
14+
private fun serialize(duration: Duration) = Json.encodeToString(DurationInDoubleSecondsSerializer, duration)
15+
private fun deserialize(json: String) = Json.decodeFromString(DurationInDoubleSecondsSerializer, json)
16+
17+
18+
@Test
19+
fun zero_Duration_can_be_serialized() {
20+
assertEquals(expected = 0.0.toString(), actual = serialize(Duration.ZERO))
21+
}
22+
23+
@Test
24+
fun zero_Duration_can_be_deserialized() {
25+
for (jsonZero in listOf("0", "0.0", "0.0000", "0.00e-0864", "0E+456")) {
26+
assertEquals(expected = Duration.ZERO, actual = deserialize(jsonZero))
27+
}
28+
}
29+
30+
31+
@Test
32+
fun infinite_Durations_cannot_be_serialized() {
33+
assertFailsWith<SerializationException> { serialize(Duration.INFINITE) }
34+
assertFailsWith<SerializationException> { serialize(-Duration.INFINITE) }
35+
}
36+
37+
private val largestFiniteDuration = (Long.MAX_VALUE / 2 - 1).milliseconds
38+
39+
init {
40+
check(largestFiniteDuration.isFinite())
41+
check(largestFiniteDuration + (1.milliseconds - 1.nanoseconds) == largestFiniteDuration)
42+
check((largestFiniteDuration + 1.milliseconds).isInfinite())
43+
}
44+
45+
@Test
46+
fun largest_finite_Durations_can_be_serialized() {
47+
assertEquals(4.611686018427388e+15.toString(), serialize(largestFiniteDuration))
48+
assertEquals((-4.611686018427388e+15).toString(), serialize(-largestFiniteDuration))
49+
}
50+
51+
52+
private val duration2Jsons = listOf(
53+
123.seconds to listOf(123.0.toString(), "123", "0.1230E+3", "1230.0e-1"),
54+
5646.876456.seconds to listOf("5646.876456", "5646.87645600", "5.646876456e003"),
55+
4631.89.seconds to listOf("4631.89", "4631.890000000", "46.3189000000E2"),
56+
4.595632e+1.seconds to listOf("45.95632", "4.595632e+1"),
57+
)
58+
59+
@Test
60+
fun positive_Durations_can_be_serialized() {
61+
for ((duration, jsons) in duration2Jsons) {
62+
assertEquals(expected = jsons.first(), actual = serialize(duration))
63+
}
64+
}
65+
66+
@Test
67+
fun positive_Durations_can_be_deserialized() {
68+
for ((duration, jsons) in duration2Jsons) {
69+
for (json in jsons) {
70+
assertEquals(expected = duration, actual = deserialize(json))
71+
}
72+
}
73+
}
74+
75+
@Test
76+
fun negative_Durations_can_be_serialized() {
77+
for ((duration, jsons) in duration2Jsons) {
78+
assertEquals(expected = "-${jsons.first()}", actual = serialize(-duration))
79+
}
80+
}
81+
82+
@Test
83+
fun negative_Durations_can_be_deserialized() {
84+
for ((duration, jsons) in duration2Jsons) {
85+
for (json in jsons) {
86+
assertEquals(expected = -duration, actual = deserialize("-$json"))
87+
}
88+
}
89+
}
90+
91+
92+
private val largeJson = "4611686018427388" // MAX_MILLIS / 1_000 + 1
93+
94+
@Test
95+
fun large_positive_Duration_gets_deserialized_as_Infinity() {
96+
assertEquals(expected = Duration.INFINITE, deserialize(largeJson))
97+
}
98+
99+
@Test
100+
fun large_negative_Duration_gets_deserialized_as_negative_Infinity() {
101+
assertEquals(expected = -Duration.INFINITE, deserialize("-$largeJson"))
102+
}
103+
}

common/src/commonTest/kotlin/serialization/DurationSerializersTests.kt

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import kotlin.time.Duration.Companion.nanoseconds
1616
import kotlin.time.Duration.Companion.seconds
1717
import kotlin.time.DurationUnit.MILLISECONDS
1818

19-
abstract class DurationSerializerTest(
19+
abstract class DurationAsLongSerializerTest(
2020
private val json: String,
2121
private val duration: Duration,
2222
private val durationToRound: Duration,
@@ -132,7 +132,7 @@ abstract class DurationSerializerTest(
132132
}
133133

134134

135-
class DurationInNanosecondsSerializerTest : DurationSerializerTest(
135+
class DurationInNanosecondsSerializerTest : DurationAsLongSerializerTest(
136136
json = "84169",
137137
duration = 84169.nanoseconds,
138138
durationToRound = 84169.48.nanoseconds,
@@ -142,7 +142,7 @@ class DurationInNanosecondsSerializerTest : DurationSerializerTest(
142142
serializer = DurationInNanosecondsSerializer,
143143
)
144144

145-
class DurationInMicrosecondsSerializerTest : DurationSerializerTest(
145+
class DurationInMicrosecondsSerializerTest : DurationAsLongSerializerTest(
146146
json = "25622456",
147147
duration = 25622456.microseconds,
148148
durationToRound = 25622456.4.microseconds,
@@ -152,39 +152,39 @@ class DurationInMicrosecondsSerializerTest : DurationSerializerTest(
152152
serializer = DurationInMicrosecondsSerializer,
153153
)
154154

155-
class DurationInMillisecondsSerializerTest : DurationSerializerTest(
155+
class DurationInMillisecondsSerializerTest : DurationAsLongSerializerTest(
156156
json = "3495189",
157157
duration = 3495189.milliseconds,
158158
durationToRound = 3495189.24.milliseconds,
159159
largeJson = "4611686018427387903", // the Duration implementation internal `MAX_MILLIS`
160160
serializer = DurationInMillisecondsSerializer,
161161
)
162162

163-
class DurationInSecondsSerializerTest : DurationSerializerTest(
163+
class DurationInSecondsSerializerTest : DurationAsLongSerializerTest(
164164
json = "987465",
165165
duration = 987465.seconds,
166166
durationToRound = 987465.489.seconds,
167167
largeJson = "4611686018427388", // MAX_MILLIS / 1_000 + 1
168168
serializer = DurationInSecondsSerializer,
169169
)
170170

171-
class DurationInMinutesSerializerTest : DurationSerializerTest(
171+
class DurationInMinutesSerializerTest : DurationAsLongSerializerTest(
172172
json = "24905",
173173
duration = 24905.minutes,
174174
durationToRound = 24905.164.minutes,
175175
largeJson = "76861433640457", // MAX_MILLIS / 1_000 / 60 + 1
176176
serializer = DurationInMinutesSerializer,
177177
)
178178

179-
class DurationInHoursSerializerTest : DurationSerializerTest(
179+
class DurationInHoursSerializerTest : DurationAsLongSerializerTest(
180180
json = "7245",
181181
duration = 7245.hours,
182182
durationToRound = 7245.24.hours,
183183
largeJson = "1281023894008", // MAX_MILLIS / 1_000 / 60 / 60 + 1
184184
serializer = DurationInHoursSerializer,
185185
)
186186

187-
class DurationInDaysSerializerTest : DurationSerializerTest(
187+
class DurationInDaysSerializerTest : DurationAsLongSerializerTest(
188188
json = "92",
189189
duration = 92.days,
190190
durationToRound = 92.12.days,

0 commit comments

Comments
 (0)