feat(core): gateway connection and identify

This commit is contained in:
Cilly Leang 2026-01-27 02:23:26 +11:00
parent 3a28fe17f8
commit cd50f75c10
Signed by: cilly
GPG key ID: 6500251E087653C9
13 changed files with 418 additions and 40 deletions

View file

@ -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)

View file

@ -1,12 +1,28 @@
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
@OptIn(ExperimentalUuidApi::class)
private val storedVendorId = Uuid.random().toString().lowercase()
@OptIn(ExperimentalUuidApi::class)
private val storedLaunchId = Uuid.random().toString().lowercase()
internal data class PlatformProps(
val device: String,
val systemLocale: String,
val osVersion: String,
)
internal expect val platformSuperProps: PlatformProps
object ApiConstants {
val superProps = Base64.encode(Json.encodeToString(SuperProperties()).encodeToByteArray())
val baseHeaders = mapOf(
@ -16,12 +32,19 @@ object ApiConstants {
"X-Super-Properties" to superProps,
)
const val userAgent = "Discord-Android/311020;RNA"
}
const val gatewayUserAgent = "okhttp/4.12.0"
// TODO: Desktop uses separate properties
@Suppress("PropertyName")
@Serializable
data class SuperProperties(
@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,
@ -40,18 +63,33 @@ data class SuperProperties(
// 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()
// *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",
@OptIn(ExperimentalUuidApi::class)
private val storedLaunchId = Uuid.random().toString().lowercase()
internal data class PlatformProps(
val device: String,
val systemLocale: String,
val osVersion: String,
)
internal expect val platformSuperProps: PlatformProps
val is_fast_connect: Boolean = true,
val gateway_connect_reasons: String = "executeRunnable:Main",
)
}

View file

@ -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>): Int =
builder().reduce { a, b -> a + b }
}

View file

@ -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>(Event::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<Event> {
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()
}
}
}

View file

@ -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 <reified T : Payload.Outgoing> Event.Outgoing<T>.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))
}
}

View file

@ -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<T : Payload.Incoming>(
override val op: Int,
override val d: T,
val s: Int?,
val t: String?,
) : Event()
@Serializable
data class Outgoing<T : Payload.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<UnavailableGuild>,
val sessionId: String,
val resumeGatewayUrl: String,
// val application: Application,
) : Dispatch()
}
fun <T : Payload.Outgoing> T.pack(): Event.Outgoing<T> {
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)
}

View file

@ -0,0 +1,7 @@
package moe.lava.neon.core.api.gateway.handlers
import moe.lava.neon.core.api.gateway.Payload
sealed interface Handler<T: Payload.Incoming> {
fun handle(payload: T)
}

View file

@ -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<Payload.Ready> {
override fun handle(payload: Payload.Ready) {
logger.i { "Received payload $payload" }
}
}

View file

@ -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

View file

@ -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,
)

View file

@ -13,4 +13,6 @@ interface AppGraph {
val auth: AuthRepository
val users: UserRepository
val gatewayHandlers: GatewayHandlerGraph
}

View file

@ -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
}

View file

@ -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()
}