-
-
Notifications
You must be signed in to change notification settings - Fork 170
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Whoops, forgot the switch-twitch module
- Loading branch information
1 parent
e7f5efa
commit 3577e60
Showing
16 changed files
with
523 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |
296 changes: 296 additions & 0 deletions
296
switch-twitch/src/main/kotlin/net/perfectdreams/switchtwitch/SwitchTwitchAPI.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |
9 changes: 9 additions & 0 deletions
9
switch-twitch/src/main/kotlin/net/perfectdreams/switchtwitch/data/GetStreamsResponse.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
8 changes: 8 additions & 0 deletions
8
switch-twitch/src/main/kotlin/net/perfectdreams/switchtwitch/data/GetUsersResponse.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) |
8 changes: 8 additions & 0 deletions
8
switch-twitch/src/main/kotlin/net/perfectdreams/switchtwitch/data/Pagination.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
9 changes: 9 additions & 0 deletions
9
switch-twitch/src/main/kotlin/net/perfectdreams/switchtwitch/data/SubTransport.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
10 changes: 10 additions & 0 deletions
10
switch-twitch/src/main/kotlin/net/perfectdreams/switchtwitch/data/SubTransportCreate.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
11 changes: 11 additions & 0 deletions
11
...h-twitch/src/main/kotlin/net/perfectdreams/switchtwitch/data/SubscriptionCreateRequest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
Oops, something went wrong.