feat(core): gateway connection and identify
This commit is contained in:
parent
3a28fe17f8
commit
cd50f75c10
13 changed files with 418 additions and 40 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -13,4 +13,6 @@ interface AppGraph {
|
|||
|
||||
val auth: AuthRepository
|
||||
val users: UserRepository
|
||||
|
||||
val gatewayHandlers: GatewayHandlerGraph
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue