From a8962690876dd24d9119e59b942a13fb668ae780 Mon Sep 17 00:00:00 2001 From: dalbodeule <11470513+dalbodeule@users.noreply.github.com> Date: Thu, 15 May 2025 04:57:17 +0900 Subject: [PATCH] [refactor] user and live stream handling logic Replaced ChzzkUserCache with event-based user fetching for cleaner architecture. Integrated new ChzzkUserFindEvent and ChzzkUserReceiveEvent to handle user data retrieval. Removed old utility methods and streamlined live stream status checks with updated APIs. --- .../chzzk_bot/chatbot/chzzk/ChzzkHandler.kt | 33 ++++---- .../mori/chzzk_bot/chatbot/chzzk/Connector.kt | 32 +++++++- .../mori/chzzk_bot/chatbot/discord/Discord.kt | 17 ++-- .../common/events/ChzzkUserFindEvent.kt | 7 ++ .../common/events/ChzzkUserReceiveEvent.kt | 11 +++ .../mori/chzzk_bot/common/utils/ChzzkApis.kt | 81 ------------------- .../chzzk_bot/webserver/routes/ApiRoutes.kt | 53 +++++++----- .../webserver/utils/ChzzkUserCache.kt | 50 ------------ 8 files changed, 105 insertions(+), 179 deletions(-) create mode 100644 common/src/main/kotlin/space/mori/chzzk_bot/common/events/ChzzkUserFindEvent.kt create mode 100644 common/src/main/kotlin/space/mori/chzzk_bot/common/events/ChzzkUserReceiveEvent.kt delete mode 100644 webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/utils/ChzzkUserCache.kt diff --git a/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/ChzzkHandler.kt b/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/ChzzkHandler.kt index 8ad413a..e4e15b7 100644 --- a/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/ChzzkHandler.kt +++ b/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/ChzzkHandler.kt @@ -1,12 +1,9 @@ package space.mori.chzzk_bot.chatbot.chzzk -import com.google.gson.Gson import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.future.await import kotlinx.coroutines.launch -import okhttp3.OkHttpClient import org.koin.java.KoinJavaComponent.inject import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -21,12 +18,12 @@ import space.mori.chzzk_bot.common.services.TimerConfigService import space.mori.chzzk_bot.common.services.UserService import space.mori.chzzk_bot.common.utils.* import xyz.r2turntrue.chzzk4j.ChzzkClient -import xyz.r2turntrue.chzzk4j.auth.ChzzkSimpleUserLoginAdapter import xyz.r2turntrue.chzzk4j.session.ChzzkSessionBuilder import xyz.r2turntrue.chzzk4j.session.ChzzkSessionSubscriptionType import xyz.r2turntrue.chzzk4j.session.ChzzkUserSession import xyz.r2turntrue.chzzk4j.session.event.SessionChatMessageEvent import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel +import xyz.r2turntrue.chzzk4j.types.channel.live.ChzzkLiveStatus import java.lang.Exception import java.net.SocketTimeoutException import java.time.LocalDateTime @@ -53,8 +50,8 @@ object ChzzkHandler { } handlers.forEach { handler -> - val streamInfo = getStreamInfo(handler.channel.channelId) - if (streamInfo.content?.status == "OPEN") handler.isActive(true, streamInfo) + val streamInfo = Connector.getLive(handler.channel.channelId) + if (streamInfo?.isOnline == true) handler.isActive(true, streamInfo) } dispatcher.subscribe(UserRegisterEvent::class) { @@ -113,15 +110,15 @@ object ChzzkHandler { handlers.forEach { if (!running) return@forEach try { - val streamInfo = getStreamInfo(it.channel.channelId) - if (streamInfo.content?.status == "OPEN" && !it.isActive) { + val streamInfo = Connector.getLive(it.channel.channelId) + if (streamInfo?.isOnline == true && !it.isActive) { try { it.isActive(true, streamInfo) } catch(e: Exception) { logger.info("Exception: ${e.stackTraceToString()}") } } - if (streamInfo.content?.status == "CLOSE" && it.isActive) it.isActive(false, streamInfo) + if (streamInfo?.isOnline == false && it.isActive) it.isActive(false, streamInfo) } catch (e: SocketTimeoutException) { logger.info("Thread 1 Timeout: ${it.channel.channelName} / ${e.stackTraceToString()}") } catch (e: Exception) { @@ -141,19 +138,19 @@ object ChzzkHandler { handlers.forEach { if (!running) return@forEach try { - val streamInfo = getStreamInfo(it.channel.channelId) - if (streamInfo.content?.status == "OPEN" && !it.isActive) { + val streamInfo = Connector.getLive(it.channel.channelId) + if (streamInfo?.isOnline == true && !it.isActive) { try { it.isActive(true, streamInfo) } catch(e: Exception) { logger.info("Exception: ${e.stackTraceToString()}") } } - if (streamInfo.content?.status == "CLOSE" && it.isActive) it.isActive(false, streamInfo) + if (streamInfo?.isOnline == false && it.isActive) it.isActive(false, streamInfo) } catch (e: SocketTimeoutException) { - logger.info("Thread 2 Timeout: ${it.channel.channelName} / ${e.stackTraceToString()}") + logger.info("Thread 1 Timeout: ${it.channel.channelName} / ${e.stackTraceToString()}") } catch (e: Exception) { - logger.info("Thread 2 Exception: ${it.channel.channelName} / ${e.stackTraceToString()}") + logger.info("Thread 1 Exception: ${it.channel.channelName} / ${e.stackTraceToString()}") } finally { Thread.sleep(5000) } @@ -204,8 +201,8 @@ class UserHandler( var streamStartTime: LocalDateTime?, ) { var messageHandler: MessageHandler - lateinit var client: ChzzkClient - lateinit var listener: ChzzkUserSession + var client: ChzzkClient + var listener: ChzzkUserSession private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java) private var _isActive: Boolean @@ -252,7 +249,7 @@ class UserHandler( internal val isActive: Boolean get() = _isActive - internal fun isActive(value: Boolean, status: IData) { + internal fun isActive(value: Boolean, status: ChzzkLiveStatus) { if(value) { CoroutineScope(Dispatchers.Default).launch { logger.info("${user.username} is live.") @@ -262,7 +259,7 @@ class UserHandler( logger.info("ChzzkChat connecting... ${channel.channelName} - ${channel.channelId}") listener.subscribeAsync(ChzzkSessionSubscriptionType.CHAT) - streamStartTime = status.content?.openDate?.let { convertChzzkDateToLocalDateTime(it) } + streamStartTime = LocalDateTime.now() if(!_isActive) { _isActive = true diff --git a/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/Connector.kt b/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/Connector.kt index 9e60cbc..e36a687 100644 --- a/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/Connector.kt +++ b/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/Connector.kt @@ -1,28 +1,56 @@ package space.mori.chzzk_bot.chatbot.chzzk import io.github.cdimascio.dotenv.dotenv +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.koin.java.KoinJavaComponent.inject import org.slf4j.LoggerFactory +import space.mori.chzzk_bot.common.events.ChzzkUserFindEvent +import space.mori.chzzk_bot.common.events.ChzzkUserReceiveEvent +import space.mori.chzzk_bot.common.events.CoroutinesEventBus import xyz.r2turntrue.chzzk4j.ChzzkClient import xyz.r2turntrue.chzzk4j.ChzzkClientBuilder -import xyz.r2turntrue.chzzk4j.auth.ChzzkOauthLoginAdapter +import xyz.r2turntrue.chzzk4j.auth.ChzzkLegacyLoginAdapter import xyz.r2turntrue.chzzk4j.auth.ChzzkSimpleUserLoginAdapter import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel +import xyz.r2turntrue.chzzk4j.types.channel.live.ChzzkLiveStatus +import kotlin.getValue val dotenv = dotenv { ignoreIfMissing = true } +@OptIn(DelicateCoroutinesApi::class) object Connector { + val adapter = ChzzkLegacyLoginAdapter(dotenv["NID_AUT"], dotenv["NID_SES"]) val client: ChzzkClient = ChzzkClientBuilder(dotenv["NAVER_CLIENT_ID"], dotenv["NAVER_CLIENT_SECRET"]) + .withLoginAdapter(adapter) .build() private val logger = LoggerFactory.getLogger(this::class.java) + private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java) fun getChannel(channelId: String): ChzzkChannel? = client.fetchChannel(channelId) + fun getLive(channelId: String): ChzzkLiveStatus? = client.fetchLiveStatus(channelId) init { logger.info("chzzk logged: ${client.isLoggedIn}") client.loginAsync().join() + + dispatcher.subscribe(ChzzkUserFindEvent::class) { event -> + GlobalScope.launch { + val user = getChannel(event.uid) + + dispatcher.post(ChzzkUserReceiveEvent( + find = user != null, + uid = user?.channelId, + nickname = user?.channelName, + isStreamOn = user?.isBroadcasting, + avatarUrl = user?.channelImageUrl + )) + } + } } fun getClient(accessToken: String, refreshToken: String): ChzzkClient { @@ -33,4 +61,6 @@ object Connector { return client } + + } \ No newline at end of file diff --git a/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/discord/Discord.kt b/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/discord/Discord.kt index e1e8ded..c8f83b9 100644 --- a/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/discord/Discord.kt +++ b/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/discord/Discord.kt @@ -12,11 +12,11 @@ import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEve import net.dv8tion.jda.api.hooks.ListenerAdapter import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder import org.slf4j.LoggerFactory -import space.mori.chzzk_bot.common.utils.IData -import space.mori.chzzk_bot.common.utils.IStreamInfo import space.mori.chzzk_bot.chatbot.discord.commands.* import space.mori.chzzk_bot.common.models.User +import xyz.r2turntrue.chzzk4j.types.channel.live.ChzzkLiveStatus import java.time.Instant +import kotlin.jvm.optionals.getOrNull val dotenv = dotenv { ignoreIfMissing = true @@ -33,20 +33,19 @@ class Discord: ListenerAdapter() { return bot.getGuildById(guildId)?.getTextChannelById(channelId) } - fun sendDiscord(user: User, status: IData) { - if(status.content == null) return + fun sendDiscord(user: User, status: ChzzkLiveStatus) { if(user.liveAlertMessage != null && user.liveAlertGuild != null && user.liveAlertChannel != null) { val channel = getChannel(user.liveAlertGuild ?: 0, user.liveAlertChannel ?: 0) ?: throw RuntimeException("${user.liveAlertChannel} is not valid.") val embed = EmbedBuilder() - embed.setTitle(status.content!!.liveTitle, "https://chzzk.naver.com/live/${user.token}") + embed.setTitle(status.title, "https://chzzk.naver.com/live/${user.token}") embed.setDescription("${user.username} 님이 방송을 시작했습니다.") embed.setTimestamp(Instant.now()) - embed.setAuthor(user.username, "https://chzzk.naver.com/live/${user.token}", status.content!!.channel.channelImageUrl) - embed.addField("카테고리", status.content!!.liveCategoryValue, true) - embed.addField("태그", status.content!!.tags.joinToString(", "), true) - embed.setImage(status.content!!.liveImageUrl.replace("{type}", "1080")) + embed.setAuthor(user.username, "https://chzzk.naver.com/live/${user.token}") + embed.addField("카테고리", status.categoryType.getOrNull() ?: "Unknown", true) + embed.addField("태그", status.tags.joinToString { "," }, true) + // embed.setImage(status.) channel.sendMessage( MessageCreateBuilder() diff --git a/common/src/main/kotlin/space/mori/chzzk_bot/common/events/ChzzkUserFindEvent.kt b/common/src/main/kotlin/space/mori/chzzk_bot/common/events/ChzzkUserFindEvent.kt new file mode 100644 index 0000000..934e3a2 --- /dev/null +++ b/common/src/main/kotlin/space/mori/chzzk_bot/common/events/ChzzkUserFindEvent.kt @@ -0,0 +1,7 @@ +package space.mori.chzzk_bot.common.events + +data class ChzzkUserFindEvent( + val uid: String +): Event { + val TAG = javaClass.simpleName +} \ No newline at end of file diff --git a/common/src/main/kotlin/space/mori/chzzk_bot/common/events/ChzzkUserReceiveEvent.kt b/common/src/main/kotlin/space/mori/chzzk_bot/common/events/ChzzkUserReceiveEvent.kt new file mode 100644 index 0000000..1c5fa39 --- /dev/null +++ b/common/src/main/kotlin/space/mori/chzzk_bot/common/events/ChzzkUserReceiveEvent.kt @@ -0,0 +1,11 @@ +package space.mori.chzzk_bot.common.events + +data class ChzzkUserReceiveEvent( + val find: Boolean = true, + val uid: String? = null, + val nickname: String? = null, + val isStreamOn: Boolean? = null, + val avatarUrl: String? = null, +): Event { + val TAG = javaClass.simpleName +} \ No newline at end of file diff --git a/common/src/main/kotlin/space/mori/chzzk_bot/common/utils/ChzzkApis.kt b/common/src/main/kotlin/space/mori/chzzk_bot/common/utils/ChzzkApis.kt index 42c9d61..04da4ab 100644 --- a/common/src/main/kotlin/space/mori/chzzk_bot/common/utils/ChzzkApis.kt +++ b/common/src/main/kotlin/space/mori/chzzk_bot/common/utils/ChzzkApis.kt @@ -52,49 +52,6 @@ data class NicknameColor( val colorCode: String = "" ) -// Stream info -data class IStreamInfo( - val liveId: Int = 0, - val liveTitle: String = "", - val status: String = "", - val liveImageUrl: String = "", - val defaultThumbnailImageUrl: String? = null, - val concurrentUserCount: Int = 0, - val accumulateCount: Int = 0, - val openDate: String = "", - val closeDate: String = "", - val adult: Boolean = false, - val clipActive: Boolean = false, - val tags: List = emptyList(), - val chatChannelId: String = "", - val categoryType: String = "", - val liveCategory: String = "", - val liveCategoryValue: String = "", - val chatActive: Boolean = true, - val chatAvailableGroup: String = "", - val paidPromotion: Boolean = false, - val chatAvailableCondition: String = "", - val minFollowerMinute: Int = 0, - val livePlaybackJson: String = "", - val p2pQuality: List = emptyList(), - val channel: Channel = Channel(), - val livePollingStatusJson: String = "", - val userAdultStatus: String? = null, - val chatDonationRankingExposure: Boolean = true, - val adParameter: AdParameter = AdParameter() -) - -data class Channel( - val channelId: String = "", - val channelName: String = "", - val channelImageUrl: String = "", - val verifiedMark: Boolean = false -) - -data class AdParameter( - val tag: String = "" -) - // OkHttpClient에 Interceptor 추가 val client = OkHttpClient.Builder() .addNetworkInterceptor { chain -> @@ -127,41 +84,3 @@ fun getFollowDate(chatID: String, userId: String) : IData { } } } - -fun getStreamInfo(userId: String) : IData { - val url = "https://api.chzzk.naver.com/service/v3/channels/${userId}/live-detail" - val request = Request.Builder() - .url(url) - .build() - - client.newCall(request).execute().use { response -> - try { - if(!response.isSuccessful) throw IOException("Unexpected code ${response.code}") - val body = response.body?.string() - val follow = gson.fromJson(body, object: TypeToken>() {}) - - return follow - } catch(e: Exception) { - throw e - } - } -} - -fun getUserInfo(userId: String): IData { - val url = "https://api.chzzk.naver.com/service/v1/channels/${userId}" - val request = Request.Builder() - .url(url) - .build() - - client.newCall(request).execute().use { response -> - try { - if(!response.isSuccessful) throw IOException("Unexpected code ${response.code}") - val body = response.body?.string() - val channel = gson.fromJson(body, object: TypeToken>() {}) - - return channel - } catch(e: Exception) { - throw e - } - } -} diff --git a/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/routes/ApiRoutes.kt b/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/routes/ApiRoutes.kt index 50ef6fd..4d40900 100644 --- a/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/routes/ApiRoutes.kt +++ b/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/routes/ApiRoutes.kt @@ -11,7 +11,10 @@ import space.mori.chzzk_bot.common.events.CoroutinesEventBus import space.mori.chzzk_bot.common.services.SongConfigService import space.mori.chzzk_bot.common.services.UserService import space.mori.chzzk_bot.webserver.UserSession -import space.mori.chzzk_bot.webserver.utils.ChzzkUserCache +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull +import space.mori.chzzk_bot.common.events.ChzzkUserFindEvent +import space.mori.chzzk_bot.common.events.ChzzkUserReceiveEvent @Serializable data class GetUserDTO( @@ -36,6 +39,16 @@ data class GetSessionDTO( fun Routing.apiRoutes() { val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java) + suspend fun getChzzkUserWithId(uid: String): ChzzkUserReceiveEvent? { + val completableDeferred = CompletableDeferred< ChzzkUserReceiveEvent>() + val user = withTimeoutOrNull(5000) { + dispatcher.post(ChzzkUserFindEvent(uid)) + completableDeferred.await() + } + + return user + } + route("/") { get { call.respondText("Hello World!", status = @@ -50,21 +63,21 @@ fun Routing.apiRoutes() { route("/user/{uid}") { get { - val uid = call.parameters["uid"] + val uid = call.parameters["uid"] if(uid == null) { call.respondText("Require UID", status = HttpStatusCode.NotFound) return@get } - val user = ChzzkUserCache.getCachedUser(uid) - if(user?.content == null) { + val user = getChzzkUserWithId(uid) + if (user?.find == false) { call.respondText("User not found", status = HttpStatusCode.NotFound) return@get } else { call.respond(HttpStatusCode.OK, GetUserDTO( - user.content!!.channel.channelId, - user.content!!.channel.channelName, - user.content!!.status == "OPEN", - user.content!!.channel.channelImageUrl + user?.uid ?: "", + user?.nickname ?: "", + user?.isStreamOn ?: false, + user?.avatarUrl ?: "" )) } } @@ -82,7 +95,7 @@ fun Routing.apiRoutes() { user = UserService.saveUser("임시닉네임", session.id) } val songConfig = SongConfigService.getConfig(user) - val status = ChzzkUserCache.getCachedUser(session.id) + val status = getChzzkUserWithId(user.token) val returnUsers = mutableListOf() if(status == null) { @@ -91,14 +104,14 @@ fun Routing.apiRoutes() { } if (user.username == "임시닉네임") { - status.content?.channel?.let { it1 -> UserService.updateUser(user, it1.channelId, it1.channelName) } + status.let { stats -> UserService.updateUser(user, stats.uid ?: "", stats.nickname ?: "") } } returnUsers.add(GetSessionDTO( - status.content?.channel?.channelId ?: user.username, - status.content?.channel?.channelName ?: user.token, - status.content?.status == "OPEN", - status.content?.channel?.channelImageUrl ?: "", + status.uid ?: user.token, + status.nickname ?: user.username, + status.isStreamOn == true, + status.avatarUrl ?: "", songConfig.queueLimit, songConfig.personalLimit, songConfig.streamerOnly, @@ -109,15 +122,15 @@ fun Routing.apiRoutes() { user.subordinates.toList() } returnUsers.addAll(subordinates.map { - val subStatus = ChzzkUserCache.getCachedUser(it.token) - return@map if (subStatus?.content == null) { + val subStatus = getChzzkUserWithId(user.token) + return@map if (subStatus == null) { null } else { GetSessionDTO( - subStatus.content!!.channel.channelId, - subStatus.content!!.channel.channelName, - subStatus.content!!.status == "OPEN", - subStatus.content!!.channel.channelImageUrl, + subStatus.uid ?: "", + subStatus.nickname ?: "", + subStatus.isStreamOn == true, + subStatus.avatarUrl ?: "", 0, 0, false, diff --git a/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/utils/ChzzkUserCache.kt b/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/utils/ChzzkUserCache.kt deleted file mode 100644 index d6e016e..0000000 --- a/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/utils/ChzzkUserCache.kt +++ /dev/null @@ -1,50 +0,0 @@ -package space.mori.chzzk_bot.webserver.utils - -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.slf4j.LoggerFactory -import space.mori.chzzk_bot.common.utils.IData -import space.mori.chzzk_bot.common.utils.IStreamInfo -import space.mori.chzzk_bot.common.utils.getStreamInfo -import space.mori.chzzk_bot.common.utils.getUserInfo -import java.time.Instant -import java.util.concurrent.ConcurrentHashMap - -object ChzzkUserCache { - private val cache = ConcurrentHashMap() - private const val EXP_SECONDS = 600L - private val mutex = Mutex() - private val logger = LoggerFactory.getLogger(this::class.java) - - suspend fun getCachedUser(id: String): IData? { - val now = Instant.now() - var user = cache[id] - - if(user == null || user.timestamp.plusSeconds(EXP_SECONDS).isBefore(now)) { - mutex.withLock { - if(user == null || user.timestamp.plusSeconds(EXP_SECONDS)?.isBefore(now) != false) { - var findUser = getStreamInfo(id) - if(findUser.content == null) { - val userInfo = getUserInfo(id) - - if(userInfo.content == null) return null - - findUser = IData(200, null, IStreamInfo( - channel = userInfo.content!! - )) - } - - user = CachedUser(findUser) - user.let { cache[id] = user } - } - } - } - - return cache[id]?.user - } -} - -data class CachedUser( - val user: IData, - val timestamp: Instant = Instant.now(), -) \ No newline at end of file