From cd50f75c1018caa4b6ffcc8ee423ff196021f3ae Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Tue, 27 Jan 2026 02:23:26 +1100 Subject: [PATCH] feat(core): gateway connection and identify --- .../moe/lava/neon/core/api/ApiClient.kt | 5 +- .../moe/lava/neon/core/api/ApiConstants.kt | 108 ++++++++++------ .../lava/neon/core/api/gateway/Capability.kt | 29 +++++ .../core/api/gateway/EventPolySerializer.kt | 25 ++++ .../moe/lava/neon/core/api/gateway/Gateway.kt | 117 ++++++++++++++++++ .../lava/neon/core/api/gateway/Payloads.kt | 97 +++++++++++++++ .../neon/core/api/gateway/handlers/Handler.kt | 7 ++ .../core/api/gateway/handlers/ReadyHandler.kt | 14 +++ .../neon/core/api/structures/Snowflake.kt | 6 + .../moe/lava/neon/core/api/structures/User.kt | 10 ++ .../kotlin/moe/lava/neon/core/di/AppGraph.kt | 2 + .../lava/neon/core/di/GatewayHandlerGraph.kt | 12 ++ .../kotlin/moe/lava/neon/ui/screens/Sample.kt | 26 +++- 13 files changed, 418 insertions(+), 40 deletions(-) create mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Capability.kt create mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/EventPolySerializer.kt create mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Gateway.kt create mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Payloads.kt create mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/Handler.kt create mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/ReadyHandler.kt create mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/api/structures/Snowflake.kt create mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/api/structures/User.kt create mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/di/GatewayHandlerGraph.kt diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt index cd79099..d34dbf3 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt @@ -38,10 +38,7 @@ class ApiClient { val client = HttpClient { expectSuccess = true install(ContentNegotiation) { - json(Json { - namingStrategy = JsonNamingStrategy.SnakeCase - ignoreUnknownKeys = true - }) + json(ApiConstants.json) } install(WebSockets) install(HttpCookies) diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiConstants.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiConstants.kt index 5c5777e..42fc251 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiConstants.kt +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiConstants.kt @@ -1,47 +1,14 @@ package moe.lava.neon.core.api +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNamingStrategy import java.time.ZoneId import kotlin.io.encoding.Base64 import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid -object ApiConstants { - val superProps = Base64.encode(Json.encodeToString(SuperProperties()).encodeToByteArray()) - val baseHeaders = mapOf( - "X-Debug-Options" to "bugReporterEnabled", - "X-Discord-Locale" to "en-US", - "X-Discord-Timezone" to ZoneId.systemDefault().id, - "X-Super-Properties" to superProps, - ) - const val userAgent = "Discord-Android/311020;RNA" -} - -// TODO: Desktop uses separate properties -@Suppress("PropertyName") -@Serializable -data class SuperProperties( - val os: String = "Android", - val browser: String = "Discord Android", - val device: String = platformSuperProps.device, - val system_locale: String = platformSuperProps.systemLocale, - val has_client_mods: Boolean = false, - val client_version: String = "311.20 - rn", - val release_channel: String = "googleRelease", - val device_vendor_id: String = storedVendorId, - val design_id: Int = 2, - val browser_user_agent: String = "", - val browser_version: String = "", - val os_version: String = platformSuperProps.osVersion, - val client_build_number: Long = 31102000334720, - val client_event_source: String? = null, - val client_launch_id: String = storedLaunchId, - // TODO: this is a random snowflake - val launch_signature: String = "1769227908736837151", - val client_app_state: String = "active", -) - @OptIn(ExperimentalUuidApi::class) private val storedVendorId = Uuid.random().toString().lowercase() @@ -55,3 +22,74 @@ internal data class PlatformProps( ) internal expect val platformSuperProps: PlatformProps + +object ApiConstants { + val superProps = Base64.encode(Json.encodeToString(SuperProperties()).encodeToByteArray()) + val baseHeaders = mapOf( + "X-Debug-Options" to "bugReporterEnabled", + "X-Discord-Locale" to "en-US", + "X-Discord-Timezone" to ZoneId.systemDefault().id, + "X-Super-Properties" to superProps, + ) + const val userAgent = "Discord-Android/311020;RNA" + const val gatewayUserAgent = "okhttp/4.12.0" + + @OptIn(ExperimentalSerializationApi::class) + val json = Json { + namingStrategy = JsonNamingStrategy.SnakeCase + ignoreUnknownKeys = true + encodeDefaults = true + } + + // TODO: Desktop uses separate properties + @Suppress("PropertyName") + @Serializable + data class SuperProperties( + val os: String = "Android", + val browser: String = "Discord Android", + val device: String = platformSuperProps.device, + val system_locale: String = platformSuperProps.systemLocale, + val has_client_mods: Boolean = false, + val client_version: String = "311.20 - rn", + val release_channel: String = "googleRelease", + val device_vendor_id: String = storedVendorId, + val design_id: Int = 2, + val browser_user_agent: String = "", + val browser_version: String = "", + val os_version: String = platformSuperProps.osVersion, + val client_build_number: Long = 31102000334720, + val client_event_source: String? = null, + val client_launch_id: String = storedLaunchId, + // TODO: this is a random snowflake + val launch_signature: String = "1769227908736837151", + val client_app_state: String = "active", + ) + + // *Ideally* we extend the previous class, but pretty much every way + // I came up with to do it looks disgusting (I'm open to suggestions though!) + @Suppress("PropertyName") + @Serializable + data class GatewayProperties( + val os: String = "Android", + val browser: String = "Discord Android", + val device: String = platformSuperProps.device, + val system_locale: String = platformSuperProps.systemLocale, + val has_client_mods: Boolean = false, + val client_version: String = "311.20 - rn", + val release_channel: String = "googleRelease", + val device_vendor_id: String = storedVendorId, + val design_id: Int = 2, + val browser_user_agent: String = "", + val browser_version: String = "", + val os_version: String = platformSuperProps.osVersion, + val client_build_number: Long = 31102000334720, + val client_event_source: String? = null, + val client_launch_id: String = storedLaunchId, + // TODO: this is a random snowflake + val launch_signature: String = "1769227908736837151", + val client_app_state: String = "active", + + val is_fast_connect: Boolean = true, + val gateway_connect_reasons: String = "executeRunnable:Main", + ) +} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Capability.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Capability.kt new file mode 100644 index 0000000..76347ee --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Capability.kt @@ -0,0 +1,29 @@ +package moe.lava.neon.core.api.gateway + +@Suppress("unused") +object Capability { + const val LAZY_USER_NOTES = 1 shl 0 + const val NO_AFFINE_USER_IDS = 1 shl 1 + const val VERSIONED_READ_STATES = 1 shl 2 + const val VERSIONED_USER_GUILD_SETTINGS = 1 shl 3 + const val DEDUPE_USER_OBJECTS = 1 shl 4 + const val PRIORITIZED_READY_PAYLOAD = 1 shl 5 + const val MULTIPLE_GUILD_EXPERIMENT_POPULATIONS = 1 shl 6 + const val NON_CHANNEL_READ_STATES = 1 shl 7 + const val AUTH_TOKEN_REFRESH = 1 shl 8 + const val USER_SETTINGS_PROTO = 1 shl 9 + const val CLIENT_STATE_V2 = 1 shl 10 + const val PASSIVE_GUILD_UPDATE = 1 shl 11 // off in rn 311.20 + const val AUTO_CALL_CONNECT = 1 shl 12 + const val DEBOUNCE_MESSAGE_REACTIONS = 1 shl 13 + const val PASSIVE_GUILD_UPDATE_V2 = 1 shl 14 + const val UNKNOWN_15 = 1 shl 15 // off in rn 311.20 + const val AUTO_LOBBY_CONNECT = 1 shl 16 // off in rn 311.20 + const val UNKNOWN_17 = 1 shl 17 + const val UNKNOWN_18 = 1 shl 18 // off in rn 311.20 + const val UNKNOWN_19 = 1 shl 19 + const val UNKNOWN_20 = 1 shl 20 + + inline fun from(builder: Capability.() -> List): Int = + builder().reduce { a, b -> a + b } +} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/EventPolySerializer.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/EventPolySerializer.kt new file mode 100644 index 0000000..153a982 --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/EventPolySerializer.kt @@ -0,0 +1,25 @@ +package moe.lava.neon.core.api.gateway + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +object EventPolySerializer : JsonContentPolymorphicSerializer(Event::class) { + override fun selectDeserializer(element: JsonElement): DeserializationStrategy { + val op = element.jsonObject["op"]!!.jsonPrimitive.int + if (op == 0) { + val name = element.jsonObject["t"]?.jsonPrimitive?.content + return when (name) { + "READY" -> Event.Incoming.serializer(Payload.Ready.serializer()) + else -> Event.Unknown.serializer() + } + } + return when (op) { + 10 -> Event.Incoming.serializer(Payload.Hello.serializer()) + else -> Event.Unknown.serializer() + } + } +} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Gateway.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Gateway.kt new file mode 100644 index 0000000..faeadb4 --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Gateway.kt @@ -0,0 +1,117 @@ +package moe.lava.neon.core.api.gateway + +import co.touchlab.kermit.Logger +import dev.zacsweers.metro.Inject +import io.ktor.client.HttpClient +import io.ktor.client.plugins.cookies.HttpCookies +import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.client.request.parameter +import io.ktor.http.userAgent +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import io.ktor.websocket.send +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.serialization.ExperimentalSerializationApi +import moe.lava.neon.core.api.ApiConstants +import moe.lava.neon.core.di.GatewayHandlerGraph +import moe.lava.neon.core.repository.AuthRepository + +@Inject +class Gateway( + private val auth: AuthRepository, + private val handlers: GatewayHandlerGraph, +) { + private val logger = Logger.withTag("neon.core.api/gateway") + private val scope = CoroutineScope(Dispatchers.IO) + private var ws: DefaultClientWebSocketSession? = null + + private val json = ApiConstants.json + + @OptIn(ExperimentalSerializationApi::class) + suspend fun connect() { + if (ws != null) { + logger.w(Throwable()) { "Attempted to connect, but client already connected, ignoring..." } + return + } + if (auth.token == null) { + throw IllegalStateException("Tried to connect to gateway with no token") + } + val ws = HttpClient { + install(HttpCookies) + install(WebSockets) + }.webSocketSession("wss://gateway.discord.gg/") { + userAgent(ApiConstants.gatewayUserAgent) + url { + parameter("encoding", "json") + parameter("v", "9") +// parameter("compress", "zstd-stream") + } + } + this.ws = ws + + ws.incoming.consumeAsFlow() + .onCompletion { cleanup(it) } + .onEach { frame -> + if (frame !is Frame.Text) +// if (frame !is Frame.Text && frame !is Frame.Binary) + return@onEach + + logger.d { "Received event ${frame.readText()}" } + + when (val msg = json.decodeFromString(EventPolySerializer, frame.readText())) { + is Event.Incoming<*> -> scope.launch { handleEvent(msg) } + is Event.Unknown -> logger.w { "Unknown event $msg" } + is Event.Outgoing<*> -> throw UnsupportedOperationException("Tried to decode outgoing message") + } + } + .launchIn(scope) + } + + suspend fun handleEvent(e: Event.Incoming<*>) { + logger.d { e.toString() } + when (val payload = e.d) { + is Payload.Hello -> handleHello(payload) + is Payload.Ready -> handlers.ready.handle(payload) + is Payload.Heartbeat -> {} + is Payload.HeartbeatAck -> {} + } + } + + suspend fun handleHello(e: Payload.Hello) { + val token = auth.token + ?: throw IllegalStateException("Token missing between connection and hello, cannot send Identify") + Payload.Identify(token = token).pack().send() + } + + // TODO: handle resuming, etc.. + suspend fun cleanup(error: Throwable? = null) { + logger.d(error) { "Websocket connection closed, cleaning up..." } + } + + suspend fun disconnect() { + val ws = ws + if (ws == null) { + logger.w(Throwable()) { "Attempted to disconnect, but client was not connected" } + return + } + this.ws = null + ws.close() + } + + private suspend inline fun Event.Outgoing.send() { + val ws = ws + ?: throw IllegalStateException("Tried to send with no connection") + logger.d { "Sending event $this" } + logger.d { "Raw: ${json.encodeToString(this)}" } + ws.send(json.encodeToString(this)) + } +} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Payloads.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Payloads.kt new file mode 100644 index 0000000..d750f79 --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Payloads.kt @@ -0,0 +1,97 @@ +package moe.lava.neon.core.api.gateway + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import moe.lava.neon.core.api.ApiConstants +import moe.lava.neon.core.api.structures.User + +sealed class Event { + abstract val op: Int + abstract val d: Any? + + @Serializable + data class Incoming( + override val op: Int, + override val d: T, + val s: Int?, + val t: String?, + ) : Event() + + @Serializable + data class Outgoing( + override val op: Int, + override val d: T, + ) : Event() + + @Serializable + data class Unknown( + override val op: Int, + override val d: JsonElement?, + val s: Int?, + val t: String?, + ) : Event() +} + +@Serializable +sealed interface Payload { + sealed interface Incoming : Payload + sealed interface Outgoing : Payload + sealed class Dispatch : Incoming + + // 1 + @JvmInline + @Serializable + value class Heartbeat(val lastSequence: Int?) : Incoming, Outgoing + + // 40 + @JvmInline + @Serializable + value class QoSHeartbeat(val lastSequence: Int?) : Outgoing + + // 11 + @JvmInline + @Serializable + value class HeartbeatAck(private val nothing: Nothing?) : Incoming, Outgoing + + // 10 + @Serializable + data class Hello(val heartbeatInterval: Int) : Incoming + + // 2 + @Serializable + @OptIn(ExperimentalSerializationApi::class) + data class Identify( + val token: String, + val properties: ApiConstants.GatewayProperties = ApiConstants.GatewayProperties(), + val capabilities: Int = Capability.from { listOf( + LAZY_USER_NOTES, + NO_AFFINE_USER_IDS, + DEDUPE_USER_OBJECTS, + USER_SETTINGS_PROTO, + DEBOUNCE_MESSAGE_REACTIONS + ) }, + // TODO: Client state v2 +// val clientState: ClientState, + ) : Outgoing + + @Serializable + data class Ready( + val v: Int, + val user: User, +// val guilds: List, + val sessionId: String, + val resumeGatewayUrl: String, +// val application: Application, + ) : Dispatch() +} + +fun T.pack(): Event.Outgoing { + val opcode: Int = when (this) { + is Payload.Heartbeat -> 1 + is Payload.QoSHeartbeat -> 40 + is Payload.HeartbeatAck -> 11 + is Payload.Identify -> 2 + } + return Event.Outgoing(op = opcode, d = this) +} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/Handler.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/Handler.kt new file mode 100644 index 0000000..8e79987 --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/Handler.kt @@ -0,0 +1,7 @@ +package moe.lava.neon.core.api.gateway.handlers + +import moe.lava.neon.core.api.gateway.Payload + +sealed interface Handler { + fun handle(payload: T) +} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/ReadyHandler.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/ReadyHandler.kt new file mode 100644 index 0000000..5addf23 --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/ReadyHandler.kt @@ -0,0 +1,14 @@ +package moe.lava.neon.core.api.gateway.handlers + +import co.touchlab.kermit.Logger +import dev.zacsweers.metro.Inject +import moe.lava.neon.core.api.gateway.Payload + +private val logger = Logger.withTag("neon.core.api.events/ready") + +@Inject +class ReadyHandler : Handler { + override fun handle(payload: Payload.Ready) { + logger.i { "Received payload $payload" } + } +} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/structures/Snowflake.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/structures/Snowflake.kt new file mode 100644 index 0000000..4d8dec5 --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/api/structures/Snowflake.kt @@ -0,0 +1,6 @@ +package moe.lava.neon.core.api.structures + +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.LongAsStringSerializer + +typealias Snowflake = @Serializable(LongAsStringSerializer::class) Long diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/structures/User.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/structures/User.kt new file mode 100644 index 0000000..eb78a93 --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/api/structures/User.kt @@ -0,0 +1,10 @@ +package moe.lava.neon.core.api.structures + +import kotlinx.serialization.Serializable + +@Serializable +data class User( + val id: Snowflake, + val username: String, + val discriminator: String, +) diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/di/AppGraph.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/di/AppGraph.kt index 4719db1..b87e5e4 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/di/AppGraph.kt +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/di/AppGraph.kt @@ -13,4 +13,6 @@ interface AppGraph { val auth: AuthRepository val users: UserRepository + + val gatewayHandlers: GatewayHandlerGraph } diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/di/GatewayHandlerGraph.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/di/GatewayHandlerGraph.kt new file mode 100644 index 0000000..0e5e83f --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/di/GatewayHandlerGraph.kt @@ -0,0 +1,12 @@ +package moe.lava.neon.core.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.GraphExtension +import moe.lava.neon.core.api.gateway.handlers.ReadyHandler + +@GraphExtension +@ContributesTo(AppScope::class) +interface GatewayHandlerGraph { + val ready: ReadyHandler +} diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt index 7ee5ad9..048785a 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt @@ -18,11 +18,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject import dev.zacsweers.metrox.viewmodel.ViewModelKey import dev.zacsweers.metrox.viewmodel.metroViewModel +import kotlinx.coroutines.launch +import moe.lava.neon.core.api.gateway.Gateway import moe.lava.neon.core.repository.AuthRepository import moe.lava.neon.resources.Res import moe.lava.neon.resources.compose_multiplatform @@ -54,6 +57,16 @@ fun Sample(onRequestLogout: () -> Unit) { Text("Passed token: ${viewModel.token?.slice(0..10)}...") } } + Button(onClick = { + viewModel.connect() + }) { + Text("Connect!") + } + Button(onClick = { + viewModel.disconnect() + }) { + Text("Disconnect!") + } Button(onClick = { viewModel.logout() onRequestLogout() @@ -67,10 +80,21 @@ fun Sample(onRequestLogout: () -> Unit) { @ViewModelKey(SampleViewModel::class) @ContributesIntoMap(AppScope::class) class SampleViewModel( - private val auth: AuthRepository + private val auth: AuthRepository, + private val gateway: Gateway, ) : ViewModel() { val token get() = auth.token + fun connect() { + viewModelScope.launch { + gateway.connect() + } + } + fun disconnect() { + viewModelScope.launch { + gateway.disconnect() + } + } fun logout() { auth.logout() }