refactor: split up core into multiple modules
This commit is contained in:
parent
2725342c3f
commit
0d84411f14
38 changed files with 344 additions and 149 deletions
|
|
@ -18,9 +18,10 @@ kotlin {
|
|||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(project(":api:gateway"))
|
||||
implementation(project(":api:rest"))
|
||||
implementation(project(":common"))
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.websockets)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
|
||||
implementation(project.dependencies.platform(libs.koin.bom))
|
||||
|
|
@ -32,12 +33,6 @@ kotlin {
|
|||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
package moe.lava.neon.core.api
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
@Suppress("ConstantLocale")
|
||||
internal actual val platformSuperProps = PlatformProps(
|
||||
device = android.os.Build.DEVICE,
|
||||
// TODO: this only outputs language but not country (e.g. en instead of en-AU)
|
||||
// .toLanguageTag() is close, but returns too much junk (e.g. en-AU-u-fw-mon)
|
||||
systemLocale = Locale.getDefault().language,
|
||||
osVersion = "${android.os.Build.VERSION.SDK_INT}",
|
||||
)
|
||||
|
|
@ -3,7 +3,7 @@ package moe.lava.neon.core
|
|||
import com.russhwolf.settings.Settings
|
||||
import com.russhwolf.settings.nullableString
|
||||
|
||||
class AppSettings {
|
||||
internal class AppSettings {
|
||||
private val settings = Settings()
|
||||
|
||||
var fingerprint by settings.nullableString()
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
package moe.lava.neon.core.api
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.HttpSend
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.cookies.HttpCookies
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.plugins.plugin
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.userAgent
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.util.appendAll
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNamingStrategy
|
||||
import moe.lava.neon.core.api.captcha.CaptchaRequest
|
||||
import moe.lava.neon.core.api.captcha.CaptchaResponse
|
||||
|
||||
class ApiClient {
|
||||
private val logger = Logger.withTag("neon.core.api/client")
|
||||
|
||||
private var captchaHandler: (suspend (CaptchaRequest) -> CaptchaResponse)? = null
|
||||
|
||||
fun setCaptchaHandler(handler: suspend (CaptchaRequest) -> CaptchaResponse) {
|
||||
this.captchaHandler = handler
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
val client = HttpClient {
|
||||
expectSuccess = true
|
||||
install(ContentNegotiation) {
|
||||
json(ApiConstants.json)
|
||||
}
|
||||
install(WebSockets)
|
||||
install(HttpCookies)
|
||||
defaultRequest {
|
||||
url("https://discord.com/api/v9/")
|
||||
userAgent(ApiConstants.userAgent)
|
||||
headers.appendAll(ApiConstants.baseHeaders)
|
||||
}
|
||||
}.apply {
|
||||
plugin(HttpSend).intercept { req ->
|
||||
logger.d { "Intercepting ${req.url.buildString()}" }
|
||||
val call = execute(req)
|
||||
if (call.response.status.value != 400) return@intercept call
|
||||
logger.d { "Found 400 response: ${call.response.bodyAsText()}" }
|
||||
val captchaRequest = runCatching { call.response.body<CaptchaRequest>() }
|
||||
.getOrNull()
|
||||
?: return@intercept call
|
||||
|
||||
logger.d { "Starting captcha flow for: $captchaRequest" }
|
||||
|
||||
val captcha = captchaHandler
|
||||
if (captcha == null) {
|
||||
logger.w { "Captcha handler not found, passing through!" }
|
||||
return@intercept call
|
||||
}
|
||||
|
||||
val solved = captcha(captchaRequest)
|
||||
logger.d { "Captcha solved $solved" }
|
||||
if (solved !is CaptchaResponse.Success) {
|
||||
val failure = solved as CaptchaResponse.Failed
|
||||
logger.w(failure.error) { "Captcha failed" }
|
||||
return@intercept call
|
||||
}
|
||||
|
||||
logger.d { "Refiring" }
|
||||
req.apply {
|
||||
header("X-Captcha-Key", solved.token)
|
||||
if (captchaRequest.captchaSessionId != null) {
|
||||
header("X-Captcha-Session-Id", captchaRequest.captchaSessionId)
|
||||
}
|
||||
if (captchaRequest.captchaRqtoken != null) {
|
||||
header("X-Captcha-Rqtoken", captchaRequest.captchaRqtoken)
|
||||
}
|
||||
}.let { execute(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun buildApiClient() = HttpClient {
|
||||
expectSuccess = true
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
install(WebSockets)
|
||||
install(HttpCookies)
|
||||
defaultRequest {
|
||||
url("https://discord.com/api/v9/")
|
||||
headers.appendAll(ApiConstants.baseHeaders)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
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(
|
||||
"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",
|
||||
)
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
package moe.lava.neon.core.api.captcha
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CaptchaRequest(
|
||||
val captchaKey: List<String>,
|
||||
val captchaService: String,
|
||||
val captchaSitekey: String?,
|
||||
val captchaSessionId: String?,
|
||||
val captchaRqdata: String?,
|
||||
val captchaRqtoken: String?,
|
||||
val shouldServeInvisible: Boolean? = false,
|
||||
)
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
package moe.lava.neon.core.api.captcha
|
||||
|
||||
sealed class CaptchaResponse {
|
||||
data class Success(val token: String) : CaptchaResponse()
|
||||
data class Failed(val error: Throwable) : CaptchaResponse()
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
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 }
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
package moe.lava.neon.core.api.gateway
|
||||
|
||||
import io.ktor.websocket.CloseReason
|
||||
|
||||
sealed interface GatewayCloseReason {
|
||||
sealed interface ClientInitiated : GatewayCloseReason
|
||||
sealed class ShouldReconnect(val resume: Boolean) : GatewayCloseReason
|
||||
sealed class KeepDisconnected : GatewayCloseReason
|
||||
|
||||
data object MissedHeartbeat : ShouldReconnect(resume = true), ClientInitiated
|
||||
data class SkippedSequence(val next: Int) : ShouldReconnect(resume = true), ClientInitiated
|
||||
data class InvalidSession(val resumable: Boolean) : ShouldReconnect(resume = resumable), ClientInitiated
|
||||
// TODO: handle non-resumable cases properly
|
||||
data class ServerClosed(val closeCode: CloseReason) : ShouldReconnect(resume = true)
|
||||
data object ServerReconnect : ShouldReconnect(resume = true), ClientInitiated
|
||||
|
||||
data object ClientPaused : KeepDisconnected(), ClientInitiated
|
||||
|
||||
data object Unknown : ShouldReconnect(resume = true)
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
package moe.lava.neon.core.api.gateway
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import moe.lava.neon.core.api.gateway.handlers.EventHandlers
|
||||
import moe.lava.neon.core.repository.AuthRepository
|
||||
import kotlin.math.pow
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class GatewayHandler(
|
||||
private val auth: AuthRepository,
|
||||
private val eventHandlers: EventHandlers,
|
||||
) {
|
||||
private val logger = Logger.withTag("neon.core.api.gateway/handler")
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
private var session: GatewaySession? = null
|
||||
private var resumeProps: ResumeProperties? = null
|
||||
|
||||
private var retryAttempts: Int = 0
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
suspend fun connect() {
|
||||
if (session != null) {
|
||||
logger.w(Throwable()) { "Attempted to connect, but client already connected, ignoring..." }
|
||||
return
|
||||
}
|
||||
val token = auth.token
|
||||
?: throw IllegalStateException("Tried to connect to gateway with no token")
|
||||
|
||||
session = GatewaySession.start(
|
||||
token = token,
|
||||
eventHandlers = eventHandlers,
|
||||
resumeProps = resumeProps,
|
||||
onSuccess = {
|
||||
logger.d { "Successful session start" }
|
||||
retryAttempts = 0
|
||||
},
|
||||
onDestroy = { reason, resumeProps ->
|
||||
session = null
|
||||
|
||||
if (reason is GatewayCloseReason.KeepDisconnected) {
|
||||
this.resumeProps = resumeProps
|
||||
}
|
||||
|
||||
if (reason is GatewayCloseReason.ShouldReconnect) {
|
||||
if (reason.resume) {
|
||||
this.resumeProps = resumeProps
|
||||
} else {
|
||||
this.resumeProps = null
|
||||
}
|
||||
scope.launch {
|
||||
var res: Result<Unit>
|
||||
do {
|
||||
val dur = 2.0.pow(retryAttempts).seconds
|
||||
logger.d { "Reconnecting in ${dur.inWholeMilliseconds}ms" }
|
||||
delay(dur)
|
||||
retryAttempts += 1
|
||||
res = runCatching { connect() }
|
||||
res.exceptionOrNull()?.let {
|
||||
logger.e(it) { "Reconnect failed" }
|
||||
}
|
||||
} while(res.isFailure)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun disconnect() {
|
||||
val session = session
|
||||
?: throw IllegalStateException("Tried disconnecting with no session")
|
||||
session.close(GatewayCloseReason.ClientPaused)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
package moe.lava.neon.core.api.gateway
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
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.readText
|
||||
import io.ktor.websocket.send
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.lava.neon.core.api.ApiConstants
|
||||
import moe.lava.neon.core.api.ApiConstants.json
|
||||
import moe.lava.neon.core.api.gateway.handlers.EventHandlers
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
private val logger = Logger.withTag("neon.core.api.gateway/session")
|
||||
|
||||
class GatewaySession private constructor(
|
||||
private var ws: DefaultClientWebSocketSession,
|
||||
private val token: String,
|
||||
private val handlers: EventHandlers,
|
||||
private val scope: CoroutineScope,
|
||||
private var resumeProps: ResumeProperties?,
|
||||
private val onDestroy: (GatewayCloseReason, ResumeProperties?) -> Unit,
|
||||
private val onSuccess: () -> Unit,
|
||||
) {
|
||||
private var lastSeq: Int? = resumeProps?.lastSequence
|
||||
private var missedHeartbeats = 0
|
||||
private var closeReason: GatewayCloseReason? = null
|
||||
|
||||
companion object {
|
||||
suspend fun start(
|
||||
token: String,
|
||||
eventHandlers: EventHandlers,
|
||||
client: HttpClient = HttpClient {
|
||||
install(HttpCookies)
|
||||
install(WebSockets)
|
||||
},
|
||||
scope: CoroutineScope = CoroutineScope(Dispatchers.IO),
|
||||
resumeProps: ResumeProperties? = null,
|
||||
onDestroy: (GatewayCloseReason, ResumeProperties?) -> Unit,
|
||||
onSuccess: () -> Unit,
|
||||
): GatewaySession {
|
||||
val ws = client.webSocketSession(
|
||||
resumeProps?.resumeGatewayUrl ?: "wss://gateway.discord.gg/"
|
||||
) {
|
||||
userAgent(ApiConstants.gatewayUserAgent)
|
||||
url {
|
||||
parameter("encoding", "json")
|
||||
parameter("v", "9")
|
||||
// parameter("compress", "zstd-stream")
|
||||
}
|
||||
}
|
||||
|
||||
return GatewaySession(ws, token, eventHandlers, scope, resumeProps, onDestroy, onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
ws.incoming.consumeAsFlow()
|
||||
.onCompletion { onClose(it) }
|
||||
.onEach { frame ->
|
||||
if (frame !is Frame.Text)
|
||||
// if (frame !is Frame.Text && frame !is Frame.Binary)
|
||||
return@onEach
|
||||
|
||||
logger.d { "Received payload ${frame.readText()}" }
|
||||
|
||||
val raw = json.decodeFromString<Payload.Unknown>(frame.readText())
|
||||
val seq = this.lastSeq ?: 0
|
||||
if (seq + 1 == raw.s) {
|
||||
this.lastSeq = raw.s
|
||||
} else if (raw.s != null) {
|
||||
close(GatewayCloseReason.SkippedSequence(raw.s))
|
||||
return@onEach
|
||||
}
|
||||
|
||||
when (val payload = raw.asIncoming()) {
|
||||
is Payload.Incoming<*> -> scope.launch { handlePayload(payload) }
|
||||
is Payload.Unknown -> scope.launch { handleUnknownPayload(payload) }
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
private suspend fun handlePayload(payload: Payload.Incoming<*>) {
|
||||
logger.d { payload.toString() }
|
||||
when (val event = payload.d) {
|
||||
is Event.Heartbeat -> handleHeartbeat()
|
||||
is Event.Reconnect -> close(GatewayCloseReason.ServerReconnect)
|
||||
is Event.InvalidSession -> close(GatewayCloseReason.InvalidSession(event.resumable))
|
||||
is Event.Hello -> handleHello(event)
|
||||
is Event.HeartbeatAck -> { missedHeartbeats -= 1 }
|
||||
|
||||
is Event.Ready -> handlers.ready.handle(event) {
|
||||
resumeProps = it
|
||||
onSuccess()
|
||||
}
|
||||
is Event.Resumed -> onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleUnknownPayload(payload: Payload.Unknown) {
|
||||
logger.w { "Unknown payload $payload" }
|
||||
}
|
||||
|
||||
private suspend fun handleHeartbeat() {
|
||||
logger.w { "Received heartbeat from server, possible connection issue" }
|
||||
Event.QoSHeartbeat(lastSeq).pack().send()
|
||||
missedHeartbeats += 1
|
||||
}
|
||||
|
||||
private suspend fun handleHello(e: Event.Hello) {
|
||||
val resumeProps = resumeProps
|
||||
if (resumeProps != null) {
|
||||
Event.Resume(
|
||||
token = token,
|
||||
sessionId = resumeProps.sessionId,
|
||||
seq = resumeProps.lastSequence
|
||||
).pack().send()
|
||||
} else {
|
||||
Event.Identify(token = token).pack().send()
|
||||
}
|
||||
|
||||
val interval = e.heartbeatInterval.milliseconds
|
||||
scope.launch {
|
||||
delay(interval * Random.nextDouble())
|
||||
while (true) {
|
||||
if (missedHeartbeats >= 1) {
|
||||
close(GatewayCloseReason.MissedHeartbeat)
|
||||
break
|
||||
}
|
||||
Event.QoSHeartbeat(lastSeq).pack().send()
|
||||
missedHeartbeats += 1
|
||||
delay(interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close(reason: GatewayCloseReason.ClientInitiated?) {
|
||||
val msg = when (reason) {
|
||||
is GatewayCloseReason.MissedHeartbeat ->
|
||||
"heartbeat missed"
|
||||
is GatewayCloseReason.SkippedSequence ->
|
||||
"payloads skipped one sequence (expected: $lastSeq, actual: ${reason.next})"
|
||||
is GatewayCloseReason.InvalidSession ->
|
||||
"invalid session (resumable: $reason)"
|
||||
is GatewayCloseReason.ClientPaused ->
|
||||
"client requested pause"
|
||||
is GatewayCloseReason.ServerReconnect ->
|
||||
"server requested reconnect"
|
||||
null ->
|
||||
"no reason"
|
||||
|
||||
}
|
||||
closeReason = reason
|
||||
|
||||
logger.e { "Client-initiated close, cause: $msg" }
|
||||
ws.cancel()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun onClose(error: Throwable? = null) {
|
||||
logger.d(error) { "Websocket connection closed, cleaning up..." }
|
||||
if (scope.isActive) scope.cancel()
|
||||
if (resumeProps == null) {
|
||||
logger.w { "No resume props stored" }
|
||||
}
|
||||
onDestroy(
|
||||
closeReason
|
||||
?: runCatching { ws.closeReason.getCompleted() }
|
||||
.getOrNull()
|
||||
?.let { GatewayCloseReason.ServerClosed(it) }
|
||||
?: GatewayCloseReason.Unknown,
|
||||
resumeProps?.copy(lastSequence = lastSeq ?: 0)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T : Event.Outgoing> Payload.Outgoing<T>.send() {
|
||||
logger.d { "Sending payload $this" }
|
||||
logger.d { "Raw: ${json.encodeToString(this)}" }
|
||||
ws.send(json.encodeToString(this))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
package moe.lava.neon.core.api.gateway
|
||||
|
||||
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 interface Payload {
|
||||
val op: Int
|
||||
val d: Any?
|
||||
|
||||
sealed interface WithSequence : Payload {
|
||||
val s: Int?
|
||||
val t: String?
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Incoming<T : Event.Incoming>(
|
||||
override val op: Int,
|
||||
override val d: T,
|
||||
override val s: Int?,
|
||||
override val t: String?,
|
||||
) : WithSequence
|
||||
|
||||
@Serializable
|
||||
data class Outgoing<T : Event.Outgoing>(
|
||||
override val op: Int,
|
||||
override val d: T,
|
||||
) : Payload
|
||||
|
||||
@Serializable
|
||||
data class Unknown(
|
||||
override val op: Int,
|
||||
override val d: JsonElement?,
|
||||
override val s: Int?,
|
||||
override val t: String?,
|
||||
) : WithSequence
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface Event {
|
||||
sealed interface Incoming : Event
|
||||
sealed interface Outgoing : Event
|
||||
sealed class Dispatch : Incoming
|
||||
|
||||
// 1
|
||||
@JvmInline
|
||||
@Serializable
|
||||
value class Heartbeat(val lastSequence: Int?) : Incoming, Outgoing
|
||||
|
||||
// 2
|
||||
@Serializable
|
||||
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
|
||||
|
||||
// 6
|
||||
@Serializable
|
||||
data class Resume(
|
||||
val token: String,
|
||||
val sessionId: String,
|
||||
val seq: Int,
|
||||
) : Outgoing
|
||||
|
||||
// 7
|
||||
@Serializable
|
||||
data object Reconnect : Incoming
|
||||
|
||||
// 9
|
||||
@JvmInline
|
||||
@Serializable
|
||||
value class InvalidSession(val resumable: Boolean) : Incoming
|
||||
|
||||
// 10
|
||||
@Serializable
|
||||
data class Hello(val heartbeatInterval: Int) : Incoming
|
||||
|
||||
// 11
|
||||
@Serializable
|
||||
data object HeartbeatAck : Incoming
|
||||
|
||||
// 40
|
||||
@Serializable
|
||||
data class QoSHeartbeat(
|
||||
val seq: Int?,
|
||||
val qos: QoSPayload = QoSPayload(),
|
||||
) : Outgoing {
|
||||
@Serializable
|
||||
data class QoSPayload(
|
||||
val ver: Int = 27,
|
||||
val active: Boolean = true,
|
||||
val reasons: List<String> = listOf("foregrounded"),
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Ready(
|
||||
val v: Int,
|
||||
val user: User,
|
||||
// val guilds: List<UnavailableGuild>,
|
||||
val sessionId: String,
|
||||
val resumeGatewayUrl: String,
|
||||
// val application: Application,
|
||||
) : Dispatch()
|
||||
|
||||
@Serializable
|
||||
data object Resumed : Dispatch()
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package moe.lava.neon.core.api.gateway
|
||||
|
||||
data class ResumeProperties(
|
||||
val sessionId: String,
|
||||
val resumeGatewayUrl: String,
|
||||
val lastSequence: Int,
|
||||
)
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
package moe.lava.neon.core.api.gateway
|
||||
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import moe.lava.neon.core.api.ApiConstants
|
||||
|
||||
private val json = ApiConstants.json
|
||||
|
||||
fun <T : Event.Outgoing> T.pack(): Payload.Outgoing<T> {
|
||||
val opcode: Int = when (this) {
|
||||
is Event.Heartbeat -> 1
|
||||
is Event.Identify -> 2
|
||||
is Event.Resume -> 6
|
||||
is Event.QoSHeartbeat -> 40
|
||||
}
|
||||
return Payload.Outgoing(op = opcode, d = this)
|
||||
}
|
||||
|
||||
fun Payload.Unknown.asIncoming() : Payload.WithSequence {
|
||||
return when (op) {
|
||||
0 -> when (t) {
|
||||
"READY" -> decode<Event.Ready>()
|
||||
"RESUMED" -> decode<Event.Resumed>()
|
||||
else -> this
|
||||
}
|
||||
1 -> decode<Event.Heartbeat>()
|
||||
7 -> decodeObject(Event.Reconnect)
|
||||
9 -> decode<Event.InvalidSession>()
|
||||
10 -> decode<Event.Hello>()
|
||||
11 -> decodeObject(Event.HeartbeatAck)
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T : Event.Incoming> Payload.Unknown.decode(): Payload.Incoming<T> =
|
||||
Payload.Incoming(
|
||||
op = op,
|
||||
d = json.decodeFromJsonElement<T>(d ?: JsonNull),
|
||||
s = s,
|
||||
t = t,
|
||||
)
|
||||
|
||||
private inline fun <reified T : Event.Incoming> Payload.Unknown.decodeObject(obj: T): Payload.Incoming<T> =
|
||||
Payload.Incoming(
|
||||
op = op,
|
||||
d = obj,
|
||||
s = s,
|
||||
t = t,
|
||||
)
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
package moe.lava.neon.core.api.gateway.handlers
|
||||
|
||||
import moe.lava.neon.core.api.gateway.Event
|
||||
|
||||
sealed interface Handler<T: Event.Incoming>
|
||||
|
||||
class EventHandlers(
|
||||
val ready: ReadyHandler
|
||||
)
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
package moe.lava.neon.core.api.gateway.handlers
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import moe.lava.neon.core.api.gateway.Event
|
||||
import moe.lava.neon.core.api.gateway.ResumeProperties
|
||||
|
||||
private val logger = Logger.withTag("neon.core.api.events/ready")
|
||||
|
||||
class ReadyHandler : Handler<Event.Ready> {
|
||||
fun handle(event: Event.Ready, updateResumeProps: (ResumeProperties) -> Unit) {
|
||||
logger.i { "Received payload $event" }
|
||||
updateResumeProps(ResumeProperties(
|
||||
sessionId = event.sessionId,
|
||||
resumeGatewayUrl = event.resumeGatewayUrl,
|
||||
lastSequence = 0,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
package moe.lava.neon.core.api.structures
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.builtins.LongAsStringSerializer
|
||||
|
||||
typealias Snowflake = @Serializable(LongAsStringSerializer::class) Long
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package moe.lava.neon.core.api.structures
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class User(
|
||||
val id: Snowflake,
|
||||
val username: String,
|
||||
val discriminator: String,
|
||||
)
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
package moe.lava.neon.core.di
|
||||
|
||||
import moe.lava.neon.api.ApiClient
|
||||
import moe.lava.neon.api.gateway.GatewayHandler
|
||||
import moe.lava.neon.core.AppSettings
|
||||
import moe.lava.neon.core.api.ApiClient
|
||||
import moe.lava.neon.core.api.gateway.GatewayHandler
|
||||
import moe.lava.neon.core.api.gateway.handlers.EventHandlers
|
||||
import moe.lava.neon.core.api.gateway.handlers.ReadyHandler
|
||||
import moe.lava.neon.core.repository.AuthRepository
|
||||
import moe.lava.neon.core.repository.CaptchaRepository
|
||||
import moe.lava.neon.core.repository.GatewayRepository
|
||||
import moe.lava.neon.core.repository.UserRepository
|
||||
import org.koin.dsl.module
|
||||
import org.koin.plugin.module.dsl.single
|
||||
|
|
@ -15,10 +15,9 @@ val coreModule = module {
|
|||
single<AppSettings>()
|
||||
|
||||
single<AuthRepository>()
|
||||
single<CaptchaRepository>()
|
||||
single<GatewayRepository>()
|
||||
single<UserRepository>()
|
||||
|
||||
single<GatewayHandler>()
|
||||
|
||||
single<ReadyHandler>()
|
||||
single<EventHandlers>()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import io.ktor.client.request.setBody
|
|||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.serialization.Serializable
|
||||
import moe.lava.neon.api.ApiClient
|
||||
import moe.lava.neon.core.AppSettings
|
||||
import moe.lava.neon.core.api.ApiClient
|
||||
|
||||
@Serializable
|
||||
private data class ExperimentResponse(
|
||||
|
|
@ -43,16 +43,15 @@ sealed class AuthResponse {
|
|||
// data class MFARequested() : AuthResponse()
|
||||
}
|
||||
|
||||
class AuthRepository(
|
||||
class AuthRepository internal constructor(
|
||||
private val settings: AppSettings,
|
||||
private val api: ApiClient,
|
||||
) {
|
||||
private val logger = Logger.withTag("neon.core.repo/auth")
|
||||
var token by settings::token
|
||||
private set
|
||||
private var token by settings::token
|
||||
private var fingerprint by settings::fingerprint
|
||||
|
||||
var fingerprint by settings::fingerprint
|
||||
private set
|
||||
val loggedIn get() = token != null
|
||||
|
||||
suspend fun login(
|
||||
email: String,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package moe.lava.neon.core.repository
|
||||
|
||||
import moe.lava.neon.api.ApiClient
|
||||
import moe.lava.neon.common.captcha.CaptchaRequest
|
||||
import moe.lava.neon.common.captcha.CaptchaResponse
|
||||
|
||||
class CaptchaRepository(
|
||||
private val api: ApiClient,
|
||||
) {
|
||||
fun setHandler(handler: suspend (CaptchaRequest) -> CaptchaResponse) = api.setCaptchaHandler(handler)
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package moe.lava.neon.core.repository
|
||||
|
||||
import moe.lava.neon.api.gateway.GatewayHandler
|
||||
import moe.lava.neon.core.AppSettings
|
||||
|
||||
class GatewayRepository internal constructor(
|
||||
private val gateway: GatewayHandler,
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
suspend fun start(): Result<Unit> = runCatching {
|
||||
val token = settings.token
|
||||
?: throw IllegalArgumentException("Tried to start gateway with no token")
|
||||
|
||||
gateway.connect(token)
|
||||
}
|
||||
|
||||
suspend fun pause() = runCatching { gateway.disconnect() }
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package moe.lava.neon.core.api
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
@Suppress("ConstantLocale")
|
||||
internal actual val platformSuperProps = PlatformProps(
|
||||
device = "",
|
||||
systemLocale = Locale.getDefault().language,
|
||||
osVersion = "",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue