Skip to content

Commit 4aa6559

Browse files
committed
Handle twitch token invalidation
1 parent 9bff46d commit 4aa6559

15 files changed

Lines changed: 219 additions & 55 deletions

src/main/kotlin/failchat/AppStateManager.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import failchat.goodgame.GgChatClient
2727
import failchat.goodgame.GgViewersCountLoader
2828
import failchat.peka2tv.Peka2tvApiClient
2929
import failchat.peka2tv.Peka2tvChatClient
30-
import failchat.twitch.TwitchApiClient
30+
import failchat.twitch.TokenAwareTwitchApiClient
3131
import failchat.twitch.TwitchChatClient
3232
import failchat.twitch.TwitchViewersCountLoader
3333
import failchat.util.CoroutineExceptionLogger
@@ -62,7 +62,7 @@ class AppStateManager(private val kodein: DirectDI) {
6262

6363
private val messageIdGenerator: MessageIdGenerator = kodein.instance()
6464
private val peka2tvApiClient: Peka2tvApiClient = kodein.instance()
65-
private val twitchApiClient: TwitchApiClient = kodein.instance()
65+
private val twitchApiClient: TokenAwareTwitchApiClient = kodein.instance()
6666
private val goodgameApiClient: GgApiClient = kodein.instance()
6767
private val configLoader: ConfigLoader = kodein.instance()
6868
private val ignoreFilter: IgnoreFilter = kodein.instance()

src/main/kotlin/failchat/ConfigKeys.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ object ConfigKeys {
1717
object Twitch {
1818
const val enabled = "twitch.enabled"
1919
const val channel = "twitch.channel"
20+
const val clientId = "twitch.client-id"
21+
const val clientSecret = "twitch.client-secret"
2022
const val expiresAt = "twitch.bearer-token-expires-at"
2123
const val token = "twitch.bearer-token"
2224
}

src/main/kotlin/failchat/Kodein.kt

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import failchat.twitch.FfzEmoticonHandler
7474
import failchat.twitch.SevenTvApiClient
7575
import failchat.twitch.SevenTvGlobalEmoticonLoadConfiguration
7676
import failchat.twitch.SevenTvGlobalEmoticonLoader
77+
import failchat.twitch.TokenAwareTwitchApiClient
7778
import failchat.twitch.TwitchApiClient
7879
import failchat.twitch.TwitchBadgeHandler
7980
import failchat.twitch.TwitchChatClient
@@ -250,7 +251,7 @@ val kodein = DI.direct {
250251
bind<BadgeManager>() with singleton {
251252
BadgeManager(
252253
instance<BadgeStorage>(),
253-
instance<TwitchApiClient>(),
254+
instance<TokenAwareTwitchApiClient>(),
254255
instance<Peka2tvApiClient>()
255256
)
256257
}
@@ -388,12 +389,18 @@ val kodein = DI.direct {
388389
TwitchApiClient(
389390
httpClient = instance<OkHttpClient>(),
390391
objectMapper = instance<ObjectMapper>(),
391-
clientId = config.getString("twitch.client-id"),
392-
clientSecret = config.getString("twitch.client-secret"),
392+
clientId = config.getString(ConfigKeys.Twitch.clientId)
393+
)
394+
}
395+
bind<TokenAwareTwitchApiClient>() with singleton {
396+
val config = instance<Configuration>()
397+
TokenAwareTwitchApiClient(
398+
twitchApiClient = instance(),
399+
clientSecret = config.getString(ConfigKeys.Twitch.clientSecret),
393400
tokenContainer = ConfigurationTokenContainer(instance<Configuration>())
394401
)
395402
}
396-
bind<TwitchGlobalEmoticonLoader>() with singleton { TwitchGlobalEmoticonLoader(instance<TwitchApiClient>()) }
403+
bind<TwitchGlobalEmoticonLoader>() with singleton { TwitchGlobalEmoticonLoader(instance<TokenAwareTwitchApiClient>()) }
397404
bind<TwitchEmoticonLoadConfiguration>() with singleton {
398405
TwitchEmoticonLoadConfiguration(
399406
instance<TwitchGlobalEmoticonLoader>()
@@ -419,7 +426,7 @@ val kodein = DI.direct {
419426
)
420427
}
421428
bind<TwitchViewersCountLoader>() with factory { channelName: String ->
422-
TwitchViewersCountLoader(channelName, instance<TwitchApiClient>())
429+
TwitchViewersCountLoader(channelName, instance<TokenAwareTwitchApiClient>())
423430
}
424431
bind<TwitchBadgeHandler>() with singleton {
425432
TwitchBadgeHandler(instance<BadgeFinder>())

src/main/kotlin/failchat/chat/badge/BadgeManager.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import failchat.chat.badge.BadgeOrigin.PEKA2TV
44
import failchat.chat.badge.BadgeOrigin.TWITCH_CHANNEL
55
import failchat.chat.badge.BadgeOrigin.TWITCH_GLOBAL
66
import failchat.peka2tv.Peka2tvApiClient
7-
import failchat.twitch.TwitchApiClient
7+
import failchat.twitch.TokenAwareTwitchApiClient
88
import failchat.util.CoroutineExceptionLogger
99
import kotlinx.coroutines.CoroutineScope
1010
import kotlinx.coroutines.Deferred
@@ -15,7 +15,7 @@ import mu.KLogging
1515

1616
class BadgeManager(
1717
private val badgeStorage: BadgeStorage,
18-
private val twitchApiClient: TwitchApiClient,
18+
private val twitchApiClient: TokenAwareTwitchApiClient,
1919
private val peka2tvApiClient: Peka2tvApiClient
2020
) {
2121

src/main/kotlin/failchat/twitch/ConfigurationTokenContainer.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class ConfigurationTokenContainer(
2323

2424
override fun setToken(token: HelixApiToken) {
2525
config.setProperty(ConfigKeys.Twitch.token, token.value)
26-
config.setProperty(ConfigKeys.Twitch.expiresAt, token.ttl.toEpochMilli())
26+
config.setProperty(ConfigKeys.Twitch.expiresAt, token.expiresAt.toEpochMilli())
2727
logger.info("Helix token was saved to configuration")
2828
}
2929
}

src/main/kotlin/failchat/twitch/HelixApiToken.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@ import java.time.Instant
44

55
data class HelixApiToken(
66
val value: String,
7-
val ttl: Instant
7+
val expiresAt: Instant
88
)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package failchat.twitch
2+
3+
class InvalidTokenException : RuntimeException("Invalid twitch token")
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package failchat.twitch
2+
3+
import failchat.chat.badge.ImageBadge
4+
import mu.KLogging
5+
6+
/**
7+
* The [TwitchApiClient] wrapper that:
8+
* - reuses existing token.
9+
* - retries the request if the token is expired.
10+
* */
11+
class TokenAwareTwitchApiClient(
12+
private val twitchApiClient: TwitchApiClient,
13+
private val clientSecret: String,
14+
private val tokenContainer: HelixTokenContainer
15+
) {
16+
17+
private companion object : KLogging()
18+
19+
suspend fun getUserId(userName: String): Long {
20+
return doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
21+
twitchApiClient.getUserId(userName, it)
22+
}
23+
}
24+
25+
suspend fun getViewersCount(userName: String): Int {
26+
return doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
27+
twitchApiClient.getViewersCount(userName, it)
28+
}
29+
}
30+
31+
suspend fun getGlobalEmoticons(): List<TwitchEmoticon> {
32+
return doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
33+
twitchApiClient.getGlobalEmoticons(it)
34+
}
35+
}
36+
37+
suspend fun getFirstLiveChannelName(): String {
38+
return doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
39+
twitchApiClient.getFirstLiveChannelName(it)
40+
}
41+
}
42+
43+
suspend fun getGlobalBadges(): Map<TwitchBadgeId, ImageBadge> {
44+
return doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
45+
twitchApiClient.getGlobalBadges(it)
46+
}
47+
}
48+
49+
suspend fun getChannelBadges(channelId: Long): Map<TwitchBadgeId, ImageBadge> {
50+
return doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
51+
twitchApiClient.getChannelBadges(channelId, it)
52+
}
53+
}
54+
}

src/main/kotlin/failchat/twitch/TwitchApiClient.kt

Lines changed: 22 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,7 @@ import kotlin.reflect.KClass
2323
class TwitchApiClient(
2424
private val httpClient: OkHttpClient,
2525
private val objectMapper: ObjectMapper,
26-
private val clientId: String,
27-
private val clientSecret: String,
28-
private val tokenContainer: HelixTokenContainer
26+
private val clientId: String
2927
) {
3028

3129
private companion object : KLogging() {
@@ -39,9 +37,9 @@ class TwitchApiClient(
3937
}
4038

4139
// https://dev.twitch.tv/docs/api/reference/#get-users
42-
suspend fun getUserId(userName: String): Long {
40+
suspend fun getUserId(userName: String, token: String): Long {
4341
val url = usersUrl.newBuilder().addQueryParameter("login", userName).build()
44-
val response = doRequest(url, UsersResponse::class)
42+
val response = doRequest(url, token, UsersResponse::class)
4543

4644
if (response.data.isEmpty()) {
4745
throw ChannelNotFoundException("Twitch user $userName not found")
@@ -51,9 +49,9 @@ class TwitchApiClient(
5149
}
5250

5351
// https://dev.twitch.tv/docs/api/reference/#get-streams
54-
suspend fun getViewersCount(userName: String): Int {
52+
suspend fun getViewersCount(userName: String, token: String): Int {
5553
val url = streamsUrl.newBuilder().addQueryParameter("user_login", userName).build()
56-
val response = doRequest(url, StreamsResponse::class)
54+
val response = doRequest(url, token, StreamsResponse::class)
5755

5856
if (response.data.isEmpty()) {
5957
throw ChannelOfflineException(Origin.TWITCH, userName)
@@ -63,8 +61,8 @@ class TwitchApiClient(
6361
}
6462

6563
// https://dev.twitch.tv/docs/api/reference/#get-global-emotes
66-
suspend fun getGlobalEmoticons(): List<TwitchEmoticon> {
67-
val response = doRequest(globalEmotesUrl, EmotesResponse::class)
64+
suspend fun getGlobalEmoticons(token: String): List<TwitchEmoticon> {
65+
val response = doRequest(globalEmotesUrl, token, EmotesResponse::class)
6866
return response.data.map {
6967
TwitchEmoticon(
7068
twitchId = it.id,
@@ -74,28 +72,28 @@ class TwitchApiClient(
7472
}
7573

7674
// https://dev.twitch.tv/docs/api/reference/#get-streams
77-
suspend fun getFirstLiveChannelName(): String {
75+
suspend fun getFirstLiveChannelName(token: String): String {
7876
val url = streamsUrl.newBuilder()
7977
.addQueryParameter("type", "live")
8078
.addQueryParameter("first", "1")
8179
.build()
82-
val response = doRequest(url, StreamsResponse::class)
80+
val response = doRequest(url, token, StreamsResponse::class)
8381
return response.data.first().userLogin
8482
}
8583

86-
suspend fun getGlobalBadges(): Map<TwitchBadgeId, ImageBadge> {
84+
suspend fun getGlobalBadges(token: String): Map<TwitchBadgeId, ImageBadge> {
8785
// https://dev.twitch.tv/docs/api/reference/#get-global-chat-badges
88-
return getBadges(globalBadgesUrl)
86+
return getBadges(globalBadgesUrl, token)
8987
}
9088

91-
suspend fun getChannelBadges(channelId: Long): Map<TwitchBadgeId, ImageBadge> {
89+
suspend fun getChannelBadges(channelId: Long, token: String): Map<TwitchBadgeId, ImageBadge> {
9290
// https://dev.twitch.tv/docs/api/reference/#get-channel-chat-badges
9391
val url = channelBadgesUrl.newBuilder().addQueryParameter("broadcaster_id", channelId.toString()).build()
94-
return getBadges(url)
92+
return getBadges(url, token)
9593
}
9694

97-
private suspend fun getBadges(url: HttpUrl): Map<TwitchBadgeId, ImageBadge> {
98-
val badgesResponse = doRequest(url, BadgesResponse::class)
95+
private suspend fun getBadges(url: HttpUrl, token: String): Map<TwitchBadgeId, ImageBadge> {
96+
val badgesResponse = doRequest(url, token, BadgesResponse::class)
9997

10098
return badgesResponse.data
10199
.flatMap { data ->
@@ -108,9 +106,7 @@ class TwitchApiClient(
108106
}
109107
}
110108

111-
private suspend fun <T : Any> doRequest(url: HttpUrl, responseType: KClass<T>): T {
112-
val token = getOrGenerateToken()
113-
109+
private suspend fun <T : Any> doRequest(url: HttpUrl, token: String, responseType: KClass<T>): T {
114110
val request = Request.Builder()
115111
.get()
116112
.url(url)
@@ -119,27 +115,17 @@ class TwitchApiClient(
119115
.build()
120116

121117
return httpClient.newCall(request).await().use { response ->
118+
if (response.code == 401) {
119+
throw InvalidTokenException()
120+
}
122121
if (!response.isSuccessful) {
123122
throw UnexpectedResponseCodeException(response.code, url.toString())
124123
}
125124
objectMapper.readValue(response.nonNullBody.charStream(), responseType.java)
126125
}
127126
}
128127

129-
private suspend fun getOrGenerateToken(): String {
130-
val token = tokenContainer.getToken()
131-
132-
if (token == null) {
133-
val newToken = generateToken()
134-
tokenContainer.setToken(newToken)
135-
return newToken.value
136-
}
137-
138-
logger.info("Helix token was retrieved from configuration")
139-
return token.value
140-
}
141-
142-
private suspend fun generateToken(): HelixApiToken {
128+
suspend fun generateToken(clientSecret: String): HelixApiToken {
143129
val request = Request.Builder()
144130
.url(oauthUrl)
145131
.post(FormBody.Builder()
@@ -153,15 +139,15 @@ class TwitchApiClient(
153139

154140
val response = httpClient.newCall(request).await()
155141
if (!response.isSuccessful) {
156-
throw UnexpectedResponseCodeException(200, oauthUrl)
142+
throw UnexpectedResponseCodeException(response.code, oauthUrl)
157143
}
158144

159145
val body = response.nonNullBody.string()
160146
val authResponse = objectMapper.readValue<AuthResponse>(body)
161147

162148
val token = HelixApiToken(
163149
value = authResponse.accessToken,
164-
ttl = Instant.now() + Duration.ofSeconds(authResponse.expiresIn) - Duration.ofSeconds(60)
150+
expiresAt = Instant.now() + Duration.ofSeconds(authResponse.expiresIn) - Duration.ofSeconds(60)
165151
)
166152
logger.info("New helix token was generated")
167153
return token

src/main/kotlin/failchat/twitch/TwitchGlobalEmoticonLoader.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import java.util.concurrent.CompletableFuture
99

1010
/** Uses official twitch API. */
1111
class TwitchGlobalEmoticonLoader(
12-
private val twitchClient: TwitchApiClient
12+
private val twitchClient: TokenAwareTwitchApiClient
1313
) : EmoticonBulkLoader<TwitchEmoticon> {
1414

1515
override val origin = Origin.TWITCH

0 commit comments

Comments
 (0)