Skip to content

Commit

Permalink
Whoops, forgot the switch-twitch module
Browse files Browse the repository at this point in the history
  • Loading branch information
MrPowerGamerBR committed Oct 3, 2023
1 parent e7f5efa commit 3577e60
Show file tree
Hide file tree
Showing 16 changed files with 523 additions and 0 deletions.
25 changes: 25 additions & 0 deletions switch-twitch/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
java
kotlin("jvm")
kotlin("plugin.serialization")
}

dependencies {
implementation(kotlin("stdlib-jdk8"))
implementation("io.github.microutils:kotlin-logging:${Versions.KOTLIN_LOGGING}")
implementation("com.google.code.gson:gson:2.8.9")
implementation("com.github.salomonbrys.kotson:kotson:2.5.0")
implementation("io.ktor:ktor-client-core:${Versions.KTOR}")
implementation("io.ktor:ktor-client-cio:${Versions.KTOR}")
implementation(libs.kotlinx.serialization.json)
}

tasks.withType<KotlinCompile> {
kotlinOptions.jvmTarget = Versions.JVM_TARGET
}

tasks.test {
useJUnitPlatform()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
package net.perfectdreams.switchtwitch

import com.github.salomonbrys.kotson.long
import com.github.salomonbrys.kotson.obj
import com.github.salomonbrys.kotson.string
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.http.content.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import mu.KotlinLogging
import net.perfectdreams.switchtwitch.data.*

class SwitchTwitchAPI(
val clientId: String,
val clientSecret: String,
var accessToken: String? = null,
// https://discuss.dev.twitch.tv/t/id-token-missing-when-using-id-twitch-tv-oauth2-token-with-grant-type-refresh-token/18263/3
// var refreshToken: String? = null,
var expiresIn: Long? = null,
var generatedAt: Long? = null
) {
companion object {
private val JsonIgnoreUnknownKeys = Json { ignoreUnknownKeys = true }
private const val PREFIX = "https://id.twitch.tv"
private const val API_PREFIX = "https://api.twitch.tv"
private const val TOKEN_BASE_URL = "$PREFIX/oauth2/token"
private const val USER_AGENT = "SocialRelayer-Loritta-Morenitta-Twitch-Auth/1.0"
private val logger = KotlinLogging.logger {}
val http = HttpClient(CIO) {
this.expectSuccess = false
}

suspend fun fromAuthCode(clientId: String, clientSecret: String, authCode: String, redirectUri: String): SwitchTwitchAPI {
val parameters = Parameters.build {
append("client_id", clientId)
append("client_secret", clientSecret)
append("code", authCode)
append("grant_type", "authorization_code")
append("redirect_uri", redirectUri)
}

val start = System.currentTimeMillis()
val response = http.post("https://id.twitch.tv/oauth2/token") {
userAgent(USER_AGENT)
setBody(TextContent(parameters.formUrlEncode(), ContentType.Application.FormUrlEncoded))
}

val json = Json.parseToJsonElement(response.bodyAsText())
.jsonObject
val accessToken = json["access_token"]!!.jsonPrimitive.content
val refreshToken = json["refresh_token"]!!.jsonPrimitive.content
val expiresIn = json["expires_in"]!!.jsonPrimitive.long

return SwitchTwitchAPI(
clientId,
clientSecret,
accessToken,
// refreshToken,
expiresIn,
start
)
}
}

private val mutex = Mutex()

suspend fun doTokenExchange(): JsonObject {
logger.info { "doTokenExchange()" }

val parameters = Parameters.build {
append("client_id", clientId)
append("client_secret", clientSecret)
append("grant_type", "client_credentials")
}

return doStuff(checkForRefresh = false) {
val result = http.post {
url(TOKEN_BASE_URL)
userAgent(USER_AGENT)

setBody(TextContent(parameters.formUrlEncode(), ContentType.Application.FormUrlEncoded))
}.bodyAsText()

logger.info { result }

val tree = JsonParser.parseString(result).asJsonObject

if (tree.has("error"))
throw TokenExchangeException("Error while exchanging token: ${tree["error"].asString}")

readTokenPayload(tree)

tree
}
}

suspend fun refreshToken() {
logger.info { "refreshToken()" }
// https://discuss.dev.twitch.tv/t/id-token-missing-when-using-id-twitch-tv-oauth2-token-with-grant-type-refresh-token/18263/3
doTokenExchange()
}

private suspend fun refreshTokenIfNeeded() {
logger.info { "refreshTokenIfNeeded()" }
if (accessToken == null)
throw NeedsRefreshException()

val generatedAt = generatedAt
val expiresIn = expiresIn

if (generatedAt != null && expiresIn != null) {
if (System.currentTimeMillis() >= generatedAt + (expiresIn * 1000))
throw NeedsRefreshException()
}

return
}

private suspend fun <T> doStuff(checkForRefresh: Boolean = true, callback: suspend () -> (T)): T {
logger.info { "doStuff(...) mutex locked? ${mutex.isLocked}" }
return try {
if (checkForRefresh)
refreshTokenIfNeeded()

mutex.withLock {
callback.invoke()
}
} catch (e: RateLimitedException) {
logger.info { "rate limited exception! locked? ${mutex.isLocked}" }
doStuff(checkForRefresh, callback)
} catch (e: NeedsRefreshException) {
logger.info { "refresh exception!" }
refreshToken()
doStuff(checkForRefresh, callback)
} catch (e: TokenUnauthorizedException) {
logger.info { "Unauthorized token exception! Doing token exchange again and retrying..." }
doTokenExchange()
doStuff(checkForRefresh, callback)
}
}

private fun readTokenPayload(payload: JsonObject) {
accessToken = payload["access_token"].string
// https://discuss.dev.twitch.tv/t/id-token-missing-when-using-id-twitch-tv-oauth2-token-with-grant-type-refresh-token/18263/3
// refreshToken = payload["refresh_token"].string
expiresIn = payload["expires_in"].long
generatedAt = System.currentTimeMillis()
}

private suspend fun checkForRateLimit(element: JsonElement): Boolean {
if (element.isJsonObject) {
val asObject = element.obj
if (asObject.has("retry_after")) {
val retryAfter = asObject["retry_after"].long

logger.info { "Got rate limited, oof! Retry After: $retryAfter" }
// oof, ratelimited!
delay(retryAfter)
throw RateLimitedException()
}
}

return false
}

private suspend fun checkIfRequestWasValid(response: HttpResponse): HttpResponse {
if (response.status.value == 401)
throw TokenUnauthorizedException(response.status)

return response
}

suspend fun getSelfUserInfo(): TwitchUser {
val response = makeTwitchApiRequest("$API_PREFIX/helix/users") {}

return JsonIgnoreUnknownKeys.decodeFromString<GetUsersResponse>(response.bodyAsText())
.data
.first()
}

suspend fun getUsersInfoByLogin(vararg logins: String): List<TwitchUser> {
if (logins.isEmpty())
return emptyList()

val response = makeTwitchApiRequest("$API_PREFIX/helix/users") {
for (login in logins) {
parameter("login", login)
}
}

return JsonIgnoreUnknownKeys.decodeFromString<GetUsersResponse>(response.bodyAsText())
.data
}

suspend fun getUsersInfoById(vararg ids: Long): List<TwitchUser> {
if (ids.isEmpty())
return emptyList()

val response = makeTwitchApiRequest("$API_PREFIX/helix/users") {
for (id in ids) {
parameter("id", id)
}
}

return JsonIgnoreUnknownKeys.decodeFromString<GetUsersResponse>(response.bodyAsText())
.data
}

suspend fun getStreamsByUserId(vararg ids: Long): List<TwitchStream> {
if (ids.isEmpty())
return emptyList()

val response = makeTwitchApiRequest("$API_PREFIX/helix/streams") {
for (id in ids) {
parameter("user_id", id)
}
}

return JsonIgnoreUnknownKeys.decodeFromString<GetStreamsResponse>(response.bodyAsText())
.data
}

suspend fun createSubscription(subscriptionRequest: SubscriptionCreateRequest): SubscriptionCreateResponse {
val response = makeTwitchApiRequest("$API_PREFIX/helix/eventsub/subscriptions") {
method = HttpMethod.Post

setBody(TextContent(Json.encodeToString(subscriptionRequest), ContentType.Application.Json))
}

return JsonIgnoreUnknownKeys.decodeFromString<SubscriptionCreateResponse>(response.bodyAsText())
}

suspend fun deleteSubscription(subscriptionId: String) {
val response = makeTwitchApiRequest("$API_PREFIX/helix/eventsub/subscriptions?id=$subscriptionId") {
method = HttpMethod.Delete
}
}

suspend fun loadAllSubscriptions(): List<SubscriptionListResponse> {
val subscriptions = mutableListOf<SubscriptionListResponse>()

var cursor: String? = null
var first = true
while (first || cursor != null) {
val subscriptionListData = loadSubscriptions(cursor)
cursor = subscriptionListData.pagination.cursor
first = false
subscriptions.add(subscriptionListData)
}

return subscriptions
}

suspend fun loadSubscriptions(cursor: String? = null): SubscriptionListResponse {
return makeTwitchApiRequest("$API_PREFIX/helix/eventsub/subscriptions") {
method = HttpMethod.Get
if (cursor != null)
parameter("after", cursor)
}
.bodyAsText()
.let { Json.decodeFromString(it) }
}

suspend fun makeTwitchApiRequest(url: String, httpRequestBuilderBlock: HttpRequestBuilder.() -> (Unit)): HttpResponse {
return doStuff {
val result = checkIfRequestWasValid(
http.request(url) {
userAgent(USER_AGENT)
header("Authorization", "Bearer $accessToken")
header("Client-ID", clientId)

httpRequestBuilderBlock.invoke(this)
}
)
result
}
}

class TokenUnauthorizedException(status: HttpStatusCode) : RuntimeException()
class TokenExchangeException(message: String) : RuntimeException(message)
private class RateLimitedException : RuntimeException()
private class NeedsRefreshException : RuntimeException()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package net.perfectdreams.switchtwitch.data

import kotlinx.serialization.Serializable

@Serializable
data class GetStreamsResponse(
val data: List<TwitchStream>,
val pagination: Pagination
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package net.perfectdreams.switchtwitch.data

import kotlinx.serialization.Serializable

@Serializable
data class GetUsersResponse(
val data: List<TwitchUser>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package net.perfectdreams.switchtwitch.data

import kotlinx.serialization.Serializable

@Serializable
data class Pagination(
val cursor: String? = null
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package net.perfectdreams.switchtwitch.data

import kotlinx.serialization.Serializable

@Serializable
data class SubTransport(
val method: String,
val callback: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package net.perfectdreams.switchtwitch.data

import kotlinx.serialization.Serializable

@Serializable
data class SubTransportCreate(
val method: String,
val callback: String,
val secret: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package net.perfectdreams.switchtwitch.data

import kotlinx.serialization.Serializable

@Serializable
data class SubscriptionCreateRequest(
val type: String,
val version: String,
val condition: Map<String, String>,
val transport: SubTransportCreate
)
Loading

0 comments on commit 3577e60

Please sign in to comment.