package chat.stoat.api import android.os.Handler import android.os.Looper import android.util.Log import androidx.compose.runtime.mutableStateMapOf import chat.stoat.BuildConfig import chat.stoat.StoatApplication import chat.stoat.api.StoatAPI.initialize import chat.stoat.api.internals.Members import chat.stoat.api.realtime.DisconnectionState import chat.stoat.api.realtime.RealtimeSocket import chat.stoat.api.routes.user.fetchSelf import chat.stoat.api.unreads.Unreads import chat.stoat.core.model.schemas.AutumnResource import chat.stoat.core.model.schemas.ChannelType import chat.stoat.core.model.schemas.Emoji import chat.stoat.core.model.schemas.Message import chat.stoat.core.model.schemas.Server import chat.stoat.core.model.schemas.User import chat.stoat.core.model.util.ChannelVoiceState import chat.stoat.persistence.Database import chat.stoat.persistence.SqlStorage import com.chuckerteam.chucker.api.ChuckerCollector import com.chuckerteam.chucker.api.ChuckerInterceptor import com.chuckerteam.chucker.api.RetentionManager import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.DefaultRequest import io.ktor.client.plugins.HttpRequestRetry import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.request.header import io.ktor.serialization.kotlinx.json.json import io.sentry.Sentry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.cbor.Cbor import kotlinx.serialization.json.Json import java.net.SocketException import chat.stoat.core.model.schemas.Channel as ChannelSchema private const val USE_ALPHA_API = false val STOAT_BASE = if (USE_ALPHA_API) "https://alpha.revolt.chat/api" else "https://api.stoat.chat/0.8" const val STOAT_SUPPORT = "https://support.stoat.chat" const val STOAT_MARKETING = "https://stoat.chat" val STOAT_FILES = if (USE_ALPHA_API) "https://alpha.revolt.chat/autumn" else "https://cdn.stoatusercontent.com" val STOAT_PROXY = if (USE_ALPHA_API) "https://alpha.revolt.chat/january" else "https://proxy.stoatusercontent.com" const val STOAT_WEB_APP = "https://stoat.chat" const val STOAT_INVITES = "https://stt.gg" val STOAT_WEBSOCKET = if (USE_ALPHA_API) "wss://alpha.revolt.chat/ws" else "wss://events.stoat.chat" fun String.api(): String { return "$STOAT_BASE$this" } fun buildUserAgent(accessMethod: String = "Ktor"): String { return "$accessMethod StoatForAndroid/${BuildConfig.VERSION_NAME} " + "${BuildConfig.APPLICATION_ID} Android/${android.os.Build.VERSION.SDK_INT} " + "(${android.os.Build.MANUFACTURER} ${android.os.Build.DEVICE}) Kotlin/${KotlinVersion.CURRENT}" } @OptIn(ExperimentalSerializationApi::class) val StoatJson = Json { ignoreUnknownKeys = true explicitNulls = false } @OptIn(ExperimentalSerializationApi::class) val StoatCbor = Cbor { ignoreUnknownKeys = true } val StoatHttp = HttpClient(OkHttp) { install(DefaultRequest) install(ContentNegotiation) { json(StoatJson) } install(WebSockets) install(HttpRequestRetry) { retryOnServerErrors(maxRetries = 5) retryOnException(maxRetries = 5) modifyRequest { request -> request.headers.append("x-retry-count", retryCount.toString()) } exponentialDelay() } install(Logging) { level = LogLevel.INFO } val chuckerCollector = ChuckerCollector( context = StoatApplication.instance, showNotification = true, retentionPeriod = RetentionManager.Period.ONE_DAY ) val chuckerInterceptor = ChuckerInterceptor.Builder(StoatApplication.instance) .collector(chuckerCollector) .maxContentLength(250_000L) .redactHeaders(StoatAPI.TOKEN_HEADER_NAME) .alwaysReadResponseBody(true) .createShortcut(false) .build() engine { addInterceptor { chain -> val request = chain.request().newBuilder() .apply { if (chain.request().headers[StoatAPI.TOKEN_HEADER_NAME] == null) { header(StoatAPI.TOKEN_HEADER_NAME, StoatAPI.sessionToken) } } .build() chain.proceed(request) } addInterceptor(chuckerInterceptor) } defaultRequest { url(STOAT_BASE) header("User-Agent", buildUserAgent()) } } val mainHandler = Handler(Looper.getMainLooper()) object StoatAPI { const val TOKEN_HEADER_NAME = "x-session-token" val userCache = mutableStateMapOf() val serverCache = mutableStateMapOf() val channelCache = mutableStateMapOf() val emojiCache = mutableStateMapOf() val messageCache = mutableStateMapOf() val voiceStateCache = mutableStateMapOf() val members = Members() val unreads = Unreads() var selfId: String? = null var sessionToken: String = "" private set var sessionId: String = "" private set @OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class) val realtimeContext = newSingleThreadContext("RealtimeContext") val wsFrameChannel = MutableSharedFlow( replay = 0, extraBufferCapacity = Int.MAX_VALUE, ) private var socketCoroutine: Job? = null private var openForLocalHydration = true fun setSessionHeader(token: String) { sessionToken = token } fun setSessionId(id: String) { sessionId = id } suspend fun loginAs(token: String) { setSessionHeader(token) fetchSelf() startSocketOps() unreads.sync() } @OptIn(ExperimentalCoroutinesApi::class) suspend fun connectWS() { socketCoroutine = CoroutineScope(Dispatchers.IO).launch { try { withContext(realtimeContext) { try { RealtimeSocket.connect(sessionToken) } catch (e: SocketException) { Log.d("RevoltAPI", "Socket closed, probably no big deal /// " + e.message) RealtimeSocket.updateDisconnectionState(DisconnectionState.Disconnected) } catch (e: Exception) { Log.e("RevoltAPI", "WebSocket error", e) RealtimeSocket.updateDisconnectionState(DisconnectionState.Disconnected) } } } catch (e: Exception) { try { if (e is InterruptedException) { Log.d("RevoltAPI", "Socket interrupted") } else { Log.e("RevoltAPI", "WebSocket error", e) } RealtimeSocket.updateDisconnectionState(DisconnectionState.Disconnected) } catch (e: Exception) { Sentry.captureMessage("Error in socket error handling: $e") } } } } private suspend fun startSocketOps() { connectWS() // Send a ping every roughly 30 seconds else the socket dies // Same interval as the web clients (/revolt.js) // Note: This will run even if the socket is closed (sendPing will just exit early) mainHandler.post(object : Runnable { override fun run() { runBlocking { RealtimeSocket.sendPing() } mainHandler.postDelayed(this, 30 * 1000) } }) } suspend fun initialize() { if (sessionToken != "") { fetchSelf() } } /** * Returns true if the user is logged in and the current user has been fetched at least once. * Call [initialize] to fetch the current user first, else this will return false. */ fun isLoggedIn(): Boolean { return selfId != null } /** * Clears the API client's state completely. */ fun logout() { selfId = null sessionToken = "" sessionId = "" userCache.clear() serverCache.clear() channelCache.clear() emojiCache.clear() messageCache.clear() members.clear() unreads.clear() socketCoroutine?.cancel() mainHandler.removeCallbacksAndMessages(null) clearPersistentCache() } /** * Checks if a session token is valid. */ suspend fun checkSessionToken(token: String): Boolean { return try { setSessionHeader(token) fetchSelf() true } catch (e: Exception) { false } } /** * Hydrate caches from a local database. */ fun hydrateFromPersistentCache() { if (!openForLocalHydration) { Log.w("RevoltAPI", "Hydration is closed, but was called") // Stale data is worst case, let's track it even in prod Sentry.captureMessage("Local hydration called twice or after real data was fetched") return } val db = Database(SqlStorage.driver) val channels = db.channelQueries.selectAll().executeAsList().map { ChannelSchema( id = it.id, channelType = try { ChannelType.valueOf(it.channelType) } catch (e: Exception) { null }, user = it.userId, name = it.name, owner = it.owner, description = it.description, recipients = selfId?.let { selfId -> it.userId?.let { u -> listOf(u, selfId) } } ?: it.userId?.let { u -> listOf(u) }, icon = AutumnResource( id = it.iconId, ), server = it.server, lastMessageID = it.lastMessageId, active = it.active == 1L, nsfw = it.nsfw == 1L ) } channelCache.clear() channelCache.putAll(channels.associateBy { it.id!! }) val servers = db.serverQueries.selectAll().executeAsList().map { Server( id = it.id, owner = it.owner, name = it.name, description = it.description, icon = AutumnResource( id = it.iconId, ), banner = AutumnResource( id = it.bannerId, ), flags = it.flags, channels = channels .filter { c -> c.server == it.id } .filterNot { c -> c.id == null } .map { c -> c.id!! }, ) } serverCache.clear() serverCache.putAll(servers.associateBy { it.id!! }) openForLocalHydration = false } /** * Clear the local caching database. */ private fun clearPersistentCache() { val db = Database(SqlStorage.driver) db.serverQueries.clear() db.channelQueries.clear() } /** * Marks database as hydrated (after real data was fetched, for example). */ fun closeHydration() { openForLocalHydration = false } } @Serializable data class StoatAPIError(val type: String) @Serializable data class RateLimitResponse(@SerialName("retry_after") val retryAfter: Int) { fun toException(): HitRateLimitException { return HitRateLimitException(retryAfter) } } internal const val NO_RETRY_AFTER = Int.MIN_VALUE class HitRateLimitException(retryAfter: Int = NO_RETRY_AFTER) : Exception(if (retryAfter == NO_RETRY_AFTER) "Hit rate limit" else "Hit rate limit, retry after ${retryAfter}ms")