feat: basic api, captcha, and login flow

whew, that's a lot
This commit is contained in:
Cilly Leang 2026-01-25 03:31:24 +11:00
parent 946429a2f5
commit a2fb59c6f8
Signed by: cilly
GPG key ID: 6500251E087653C9
25 changed files with 605 additions and 50 deletions

View file

@ -19,13 +19,29 @@ kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.settings)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.websockets)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kermit)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
jvmMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
}
}
dependencies {
coreLibraryDesugaring(libs.desugar)
}
android {
namespace = "moe.lava.neon.core"
compileSdk = libs.versions.android.compileSdk.get().toInt()
@ -46,6 +62,7 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
}

View file

@ -0,0 +1,10 @@
package moe.lava.neon.core.api
import java.util.Locale
@Suppress("ConstantLocale")
internal actual val platformSuperProps = PlatformProps(
device = android.os.Build.DEVICE,
systemLocale = Locale.getDefault().language,
osVersion = "${android.os.Build.VERSION.SDK_INT}",
)

View file

@ -2,7 +2,6 @@ package moe.lava.neon.core
import com.russhwolf.settings.Settings
import com.russhwolf.settings.nullableString
import com.russhwolf.settings.string
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
@ -12,5 +11,6 @@ import dev.zacsweers.metro.SingleIn
class AppSettings {
private val settings = Settings()
var fingerprint by settings.nullableString()
var token by settings.nullableString()
}

View file

@ -0,0 +1,108 @@
package moe.lava.neon.core.api
import co.touchlab.kermit.Logger
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
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
@SingleIn(AppScope::class)
@Inject
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(Json {
namingStrategy = JsonNamingStrategy.SnakeCase
ignoreUnknownKeys = true
})
}
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)
}
}

View file

@ -0,0 +1,57 @@
package moe.lava.neon.core.api
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
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()
@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

View file

@ -0,0 +1,14 @@
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,
)

View file

@ -0,0 +1,6 @@
package moe.lava.neon.core.api.captcha
sealed class CaptchaResponse {
data class Success(val token: String) : CaptchaResponse()
data class Failed(val error: Throwable) : CaptchaResponse()
}

View file

@ -1,17 +1,16 @@
package moe.lava.neon.core.di
import com.russhwolf.settings.Settings
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.DependencyGraph
import dev.zacsweers.metro.GraphExtension
import dev.zacsweers.metro.SingleIn
import moe.lava.neon.core.AppSettings
import moe.lava.neon.core.api.ApiClient
import moe.lava.neon.core.repository.AuthRepository
import moe.lava.neon.core.repository.UserRepository
@GraphExtension
interface AppGraph {
val api: ApiClient
val settings: AppSettings
val auth: AuthRepository
val users: UserRepository
val settings: AppSettings
}

View file

@ -1,12 +0,0 @@
package moe.lava.neon.core.di
import com.russhwolf.settings.Settings
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
@ContributesTo(AppScope::class)
interface Providers {
// @Provides
// fun providesSettings(): Settings = Settings()
}

View file

@ -1,20 +1,97 @@
package moe.lava.neon.core.repository
import co.touchlab.kermit.Logger
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.Serializable
import moe.lava.neon.core.AppSettings
import moe.lava.neon.core.api.ApiClient
import moe.lava.neon.core.api.captcha.CaptchaRequest
import moe.lava.neon.core.api.captcha.CaptchaResponse
@Serializable
private data class ExperimentResponse(
val fingerprint: String,
)
@Serializable
private data class LoginRequest(
val login: String,
val password: String,
val undelete: Boolean = false,
val loginSource: String? = null,
val giftCodeSkuId: String? = null,
)
@Serializable
private data class LoginResponse(
val userId: String,
val token: String,
val userSettings: UserSettings,
) {
@Serializable
data class UserSettings(val locale: String, val theme: String)
}
sealed class AuthResponse {
data class Success(val token: String) : AuthResponse()
// TODO
// data class MFARequested() : AuthResponse()
}
@Inject
@SingleIn(AppScope::class)
class AuthRepository(private val settings: AppSettings) {
class AuthRepository(
private val settings: AppSettings,
private val api: ApiClient,
) {
private val logger = Logger.withTag("neon.core.repo/auth")
var token by settings::token
private set
suspend fun login(username: String, password: String) {
// api.login(username, password)
var fingerprint by settings::fingerprint
private set
suspend fun login(
email: String,
password: String,
captcha: Pair<CaptchaRequest, CaptchaResponse.Success>? = null,
): AuthResponse {
if (fingerprint == null) {
fingerprint = api.client.get("experiments") {
parameter("with_guild_experiments", "true")
}.body<ExperimentResponse>().fingerprint
}
val res = api.client.post("auth/login") {
header("X-Fingerprint", fingerprint)
contentType(ContentType.Application.Json)
setBody(LoginRequest(
login = email,
password = password,
))
}
val body = res.body<LoginResponse>()
logger.i { "Login success $body" }
this.token = body.token
return AuthResponse.Success(body.token)
}
suspend fun login(token: String) {
suspend fun login(token: String): String {
this.token = token
return token
}
fun logout() {
token = null
}
}

View file

@ -0,0 +1,10 @@
package moe.lava.neon.core.api
import java.util.Locale
@Suppress("ConstantLocale")
internal actual val platformSuperProps = PlatformProps(
device = "",
systemLocale = Locale.getDefault().language,
osVersion = "",
)