30 Commits

Author SHA1 Message Date
JinU Choi
eccbc94269 Merge pull request #136 from dalbodeule/debug
[hotfix] token claim fixed. (2x)
2025-06-04 16:30:02 +09:00
dalbodeule
77eecaca34 [hotfix] token claim fixed. (2x) 2025-06-04 16:27:32 +09:00
JinU Choi
08576cb35a Merge pull request #135 from dalbodeule/debug
[hotfix] token claim fixed.
2025-06-04 16:21:02 +09:00
dalbodeule
90230c4691 [hotfix] token claim fixed. 2025-06-04 16:18:02 +09:00
dalbodeule
b2f449bf65 [hotfix] ChzzkClient.refreshAccessToken added. 2025-06-04 15:48:28 +09:00
dalbodeule
5e3a350e15 Merge branch 'develop'
# Conflicts:
#	chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/ChzzkHandler.kt
#	chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/utils/accessTokenRefresh.kt
2025-06-04 15:46:43 +09:00
dalbodeule
8a0a507e5b [feature] some logic fixed. 2025-06-04 15:42:31 +09:00
dalbodeule
1c4b818a85 Revert "Merge pull request #133 from dalbodeule/develop"
This reverts commit 83b5eaf345, reversing
changes made to a99f3b342a.
2025-05-27 13:18:52 +09:00
JinU Choi
83b5eaf345 Merge pull request #133 from dalbodeule/develop
[feature] accessToken refresh logic fix.
2025-05-27 13:13:24 +09:00
dalbodeule
b0be81df20 [feature] accessToken refresh logic fix. 2025-05-27 13:11:17 +09:00
JinU Choi
a99f3b342a Merge pull request #132 from dalbodeule/develop
[feature] manager detect logic fixed.
2025-05-20 11:21:51 +09:00
dalbodeule
a9d3ad436b [feature] manager detect logic fixed. 2025-05-20 11:17:36 +09:00
JinU Choi
53757476a7 Merge pull request #131 from dalbodeule/debug
[feature] timer debugs. (2x)
2025-05-18 09:36:27 +09:00
dalbodeule
27810c0b7f [feature] timer debugs. (2x) 2025-05-18 09:31:36 +09:00
JinU Choi
7257100adc Merge pull request #130 from dalbodeule/develop
[feature] timer debugs.
2025-05-18 09:27:14 +09:00
dalbodeule
f29370a31f [feature] timer debugs. 2025-05-18 09:24:11 +09:00
JinU Choi
2c0c887ba1 Merge pull request #129 from dalbodeule/develop
[feature] song list websocket service fixed.
2025-05-18 08:56:41 +09:00
dalbodeule
5223cbe2b2 [feature] song list websocket service fixed. 2025-05-18 08:55:01 +09:00
JinU Choi
11f9895198 Merge pull request #128 from dalbodeule/develop
[feature] thumbnail, etc. fixed
2025-05-18 08:17:15 +09:00
dalbodeule
a18b83fcc8 [feature] thumbnail, etc. fixed 2025-05-18 08:14:46 +09:00
JinU Choi
30d5edc5fe Merge pull request #127 from dalbodeule/develop
[feature] text size limited 100, 100ms delay added.
2025-05-17 14:47:18 +09:00
dalbodeule
0709b8f526 [feature] text size limited 100, 100ms delay added. 2025-05-17 14:37:39 +09:00
dalbodeule
1465716e72 [hotfix] hotfix on register and activate logics. 2025-05-16 00:54:43 +09:00
dalbodeule
d0292e0aa6 [hotfix] hotfix on alert embed tags. 2025-05-16 00:42:28 +09:00
dalbodeule
b2ffd18126 [hotfix] hotfix on lateinit botuid is not initialized 2025-05-16 00:38:26 +09:00
dalbodeule
5fa04a6725 [hotfix] hotfix on manage users. 2025-05-16 00:29:50 +09:00
dalbodeule
f65c446bed [hotfix] hotfix on some codes. 2025-05-16 00:25:28 +09:00
JinU Choi
729a88a2b3 Merge pull request #126 from dalbodeule/develop
[refactor] user and live stream handling logic
2025-05-16 00:01:07 +09:00
dalbodeule
a896269087 [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.
2025-05-15 04:57:17 +09:00
JinU Choi
d92ad1cc51 Merge pull request #125 from dalbodeule/develop
[feature] chzzk api applied.
2025-05-14 15:51:36 +09:00
12 changed files with 316 additions and 243 deletions

View File

@@ -1,16 +1,14 @@
package space.mori.chzzk_bot.chatbot.chzzk package space.mori.chzzk_bot.chatbot.chzzk
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.future.await
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.koin.java.KoinJavaComponent.inject import org.koin.java.KoinJavaComponent.inject
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.Connector.client as ChzzkClient
import space.mori.chzzk_bot.chatbot.chzzk.Connector.getChannel import space.mori.chzzk_bot.chatbot.chzzk.Connector.getChannel
import space.mori.chzzk_bot.chatbot.discord.Discord import space.mori.chzzk_bot.chatbot.discord.Discord
import space.mori.chzzk_bot.chatbot.utils.refreshAccessToken import space.mori.chzzk_bot.chatbot.utils.refreshAccessToken
@@ -21,15 +19,16 @@ import space.mori.chzzk_bot.common.services.TimerConfigService
import space.mori.chzzk_bot.common.services.UserService import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.common.utils.* import space.mori.chzzk_bot.common.utils.*
import xyz.r2turntrue.chzzk4j.ChzzkClient import xyz.r2turntrue.chzzk4j.ChzzkClient
import xyz.r2turntrue.chzzk4j.auth.ChzzkSimpleUserLoginAdapter
import xyz.r2turntrue.chzzk4j.session.ChzzkSessionBuilder import xyz.r2turntrue.chzzk4j.session.ChzzkSessionBuilder
import xyz.r2turntrue.chzzk4j.session.ChzzkSessionSubscriptionType import xyz.r2turntrue.chzzk4j.session.ChzzkSessionSubscriptionType
import xyz.r2turntrue.chzzk4j.session.ChzzkUserSession import xyz.r2turntrue.chzzk4j.session.ChzzkUserSession
import xyz.r2turntrue.chzzk4j.session.event.SessionChatMessageEvent import xyz.r2turntrue.chzzk4j.session.event.SessionChatMessageEvent
import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel
import xyz.r2turntrue.chzzk4j.types.channel.live.ChzzkLiveDetail
import java.lang.Exception import java.lang.Exception
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.time.LocalDateTime import java.time.LocalDateTime
import java.nio.charset.Charset
object ChzzkHandler { object ChzzkHandler {
private val handlers = mutableListOf<UserHandler>() private val handlers = mutableListOf<UserHandler>()
@@ -39,10 +38,11 @@ object ChzzkHandler {
private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java) private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
fun addUser(chzzkChannel: ChzzkChannel, user: User) { fun addUser(chzzkChannel: ChzzkChannel, user: User) {
handlers.add(UserHandler(chzzkChannel, logger, user, streamStartTime = null)) handlers.add(UserHandler(chzzkChannel, logger, user, streamStartTime = LocalDateTime.now()))
} }
fun enable() { fun enable() {
botUid = Connector.client.fetchLoggedUser().userId
UserService.getAllUsers().map { UserService.getAllUsers().map {
if(!it.isDisabled) if(!it.isDisabled)
try { try {
@@ -53,8 +53,8 @@ object ChzzkHandler {
} }
handlers.forEach { handler -> handlers.forEach { handler ->
val streamInfo = getStreamInfo(handler.channel.channelId) val streamInfo = Connector.getLive(handler.channel.channelId)
if (streamInfo.content?.status == "OPEN") handler.isActive(true, streamInfo) if (streamInfo?.isOnline == true) handler.isActive(true, streamInfo)
} }
dispatcher.subscribe(UserRegisterEvent::class) { dispatcher.subscribe(UserRegisterEvent::class) {
@@ -113,15 +113,15 @@ object ChzzkHandler {
handlers.forEach { handlers.forEach {
if (!running) return@forEach if (!running) return@forEach
try { try {
val streamInfo = getStreamInfo(it.channel.channelId) val streamInfo = Connector.getLive(it.channel.channelId)
if (streamInfo.content?.status == "OPEN" && !it.isActive) { if (streamInfo?.isOnline == true && !it.isActive) {
try { try {
it.isActive(true, streamInfo) it.isActive(true, streamInfo)
} catch(e: Exception) { } catch(e: Exception) {
logger.info("Exception: ${e.stackTraceToString()}") 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) { } catch (e: SocketTimeoutException) {
logger.info("Thread 1 Timeout: ${it.channel.channelName} / ${e.stackTraceToString()}") logger.info("Thread 1 Timeout: ${it.channel.channelName} / ${e.stackTraceToString()}")
} catch (e: Exception) { } catch (e: Exception) {
@@ -141,19 +141,19 @@ object ChzzkHandler {
handlers.forEach { handlers.forEach {
if (!running) return@forEach if (!running) return@forEach
try { try {
val streamInfo = getStreamInfo(it.channel.channelId) val streamInfo = Connector.getLive(it.channel.channelId)
if (streamInfo.content?.status == "OPEN" && !it.isActive) { if (streamInfo?.isOnline == true && !it.isActive) {
try { try {
it.isActive(true, streamInfo) it.isActive(true, streamInfo)
} catch(e: Exception) { } catch(e: Exception) {
logger.info("Exception: ${e.stackTraceToString()}") 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) { } 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) { } catch (e: Exception) {
logger.info("Thread 2 Exception: ${it.channel.channelName} / ${e.stackTraceToString()}") logger.info("Thread 1 Exception: ${it.channel.channelName} / ${e.stackTraceToString()}")
} finally { } finally {
Thread.sleep(5000) Thread.sleep(5000)
} }
@@ -197,6 +197,7 @@ object ChzzkHandler {
} }
} }
@OptIn(DelicateCoroutinesApi::class)
class UserHandler( class UserHandler(
val channel: ChzzkChannel, val channel: ChzzkChannel,
val logger: Logger, val logger: Logger,
@@ -204,8 +205,9 @@ class UserHandler(
var streamStartTime: LocalDateTime?, var streamStartTime: LocalDateTime?,
) { ) {
var messageHandler: MessageHandler var messageHandler: MessageHandler
lateinit var client: ChzzkClient var client: ChzzkClient
lateinit var listener: ChzzkUserSession var listener: ChzzkUserSession
var chatChannelId: String?
private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java) private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
private var _isActive: Boolean private var _isActive: Boolean
@@ -220,20 +222,47 @@ class UserHandler(
if(user?.accessToken == null || user.refreshToken == null) { if(user?.accessToken == null || user.refreshToken == null) {
throw RuntimeException("AccessToken or RefreshToken is not valid.") throw RuntimeException("AccessToken or RefreshToken is not valid.")
} }
try {
val tokens = ChzzkClient.refreshAccessToken(user.refreshToken!!) val tokens = user.refreshToken?.let { token -> Connector.client.refreshAccessToken(token) }
if(tokens == null) {
throw RuntimeException("AccessToken is not valid.")
}
client = Connector.getClient(tokens.first, tokens.second) client = Connector.getClient(tokens.first, tokens.second)
listener = ChzzkSessionBuilder(client).buildUserSession()
UserService.setRefreshToken(user, tokens.first, tokens.second) UserService.setRefreshToken(user, tokens.first, tokens.second)
chatChannelId = getChzzkChannelId(channel.channelId)
client.loginAsync().join()
listener = ChzzkSessionBuilder(client).buildUserSession()
listener.createAndConnectAsync().join() listener.createAndConnectAsync().join()
messageHandler = MessageHandler(this@UserHandler)
listener.on(SessionChatMessageEvent::class.java) { listener.on(SessionChatMessageEvent::class.java) {
messageHandler.handle(it.message, user) messageHandler.handle(it.message, user)
} }
messageHandler = MessageHandler(this@UserHandler)
GlobalScope.launch {
val timer = TimerConfigService.getConfig(user)
if (timer?.option == TimerType.UPTIME.value)
dispatcher.post(
TimerEvent(
channel.channelId,
TimerType.UPTIME,
getUptime(streamStartTime!!)
)
)
else dispatcher.post(
TimerEvent(
channel.channelId,
TimerType.entries.firstOrNull { it.value == timer?.option } ?: TimerType.REMOVE,
null
)
)
}
} catch(e: Exception) {
logger.error("Exception(${user.username}): ${e.stackTraceToString()}")
throw RuntimeException("Exception: ${e.stackTraceToString()}")
}
} }
internal fun disable() { internal fun disable() {
@@ -252,7 +281,7 @@ class UserHandler(
internal val isActive: Boolean internal val isActive: Boolean
get() = _isActive get() = _isActive
internal fun isActive(value: Boolean, status: IData<IStreamInfo?>) { internal fun isActive(value: Boolean, status: ChzzkLiveDetail) {
if(value) { if(value) {
CoroutineScope(Dispatchers.Default).launch { CoroutineScope(Dispatchers.Default).launch {
logger.info("${user.username} is live.") logger.info("${user.username} is live.")
@@ -260,9 +289,9 @@ class UserHandler(
reloadUser(UserService.getUser(user.id.value)!!) reloadUser(UserService.getUser(user.id.value)!!)
logger.info("ChzzkChat connecting... ${channel.channelName} - ${channel.channelId}") logger.info("ChzzkChat connecting... ${channel.channelName} - ${channel.channelId}")
listener.subscribeAsync(ChzzkSessionSubscriptionType.CHAT) listener.subscribeAsync(ChzzkSessionSubscriptionType.CHAT).join()
streamStartTime = status.content?.openDate?.let { convertChzzkDateToLocalDateTime(it) } streamStartTime = LocalDateTime.now()
if(!_isActive) { if(!_isActive) {
_isActive = true _isActive = true
@@ -321,7 +350,21 @@ class UserHandler(
} }
} }
private fun String.limitUtf8Length(maxBytes: Int): String {
val bytes = this.toByteArray(Charset.forName("UTF-8"))
if (bytes.size <= maxBytes) return this
var truncatedString = this
while (truncatedString.toByteArray(Charset.forName("UTF-8")).size > maxBytes) {
truncatedString = truncatedString.substring(0, truncatedString.length - 1)
}
return truncatedString
}
@OptIn(DelicateCoroutinesApi::class)
internal fun sendChat(msg: String) { internal fun sendChat(msg: String) {
client.sendChatToLoggedInChannel(msg) GlobalScope.launch {
delay(100L)
client.sendChatToLoggedInChannel(msg.limitUtf8Length(100))
}
} }
} }

View File

@@ -1,28 +1,56 @@
package space.mori.chzzk_bot.chatbot.chzzk package space.mori.chzzk_bot.chatbot.chzzk
import io.github.cdimascio.dotenv.dotenv 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 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.ChzzkClient
import xyz.r2turntrue.chzzk4j.ChzzkClientBuilder 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.auth.ChzzkSimpleUserLoginAdapter
import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel
import xyz.r2turntrue.chzzk4j.types.channel.live.ChzzkLiveDetail
import kotlin.getValue
val dotenv = dotenv { val dotenv = dotenv {
ignoreIfMissing = true ignoreIfMissing = true
} }
@OptIn(DelicateCoroutinesApi::class)
object Connector { object Connector {
val adapter = ChzzkLegacyLoginAdapter(dotenv["NID_AUT"], dotenv["NID_SES"])
val client: ChzzkClient = ChzzkClientBuilder(dotenv["NAVER_CLIENT_ID"], dotenv["NAVER_CLIENT_SECRET"]) val client: ChzzkClient = ChzzkClientBuilder(dotenv["NAVER_CLIENT_ID"], dotenv["NAVER_CLIENT_SECRET"])
.withLoginAdapter(adapter)
.build() .build()
private val logger = LoggerFactory.getLogger(this::class.java) 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 getChannel(channelId: String): ChzzkChannel? = client.fetchChannel(channelId)
fun getLive(channelId: String): ChzzkLiveDetail? = client.fetchLiveDetail(channelId)
init { init {
logger.info("chzzk logged: ${client.isLoggedIn}") logger.info("chzzk logged: ${client.isLoggedIn}")
client.loginAsync().join() 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 { fun getClient(accessToken: String, refreshToken: String): ChzzkClient {
@@ -33,4 +61,6 @@ object Connector {
return client return client
} }
} }

View File

@@ -88,7 +88,7 @@ class MessageHandler(
} }
private fun manageAddCommand(msg: SessionChatMessage, user: User) { private fun manageAddCommand(msg: SessionChatMessage, user: User) {
if (msg.profile.badges.size == 0) { if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 명령어를 추가할 수 있습니다.") handler.sendChat("매니저만 명령어를 추가할 수 있습니다.")
return return
} }
@@ -109,7 +109,7 @@ class MessageHandler(
} }
private fun manageUpdateCommand(msg: SessionChatMessage, user: User) { private fun manageUpdateCommand(msg: SessionChatMessage, user: User) {
if (msg.profile.badges.size == 0) { if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 명령어를 추가할 수 있습니다.") handler.sendChat("매니저만 명령어를 추가할 수 있습니다.")
return return
} }
@@ -131,7 +131,7 @@ class MessageHandler(
} }
private fun manageRemoveCommand(msg: SessionChatMessage, user: User) { private fun manageRemoveCommand(msg: SessionChatMessage, user: User) {
if (msg.profile.badges.size == 0) { if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 명령어를 삭제할 수 있습니다.") handler.sendChat("매니저만 명령어를 삭제할 수 있습니다.")
return return
} }
@@ -148,7 +148,7 @@ class MessageHandler(
} }
private fun timerCommand(msg: SessionChatMessage, user: User) { private fun timerCommand(msg: SessionChatMessage, user: User) {
if (msg.profile.badges.size == 0) { if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.") handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return return
} }
@@ -227,7 +227,7 @@ class MessageHandler(
val config = SongConfigService.getConfig(user) val config = SongConfigService.getConfig(user)
if(config.streamerOnly && msg.profile.badges.size == 0) { if(config.streamerOnly && msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.") handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return return
} }
@@ -298,7 +298,7 @@ class MessageHandler(
} }
private fun songStartCommand(msg: SessionChatMessage, user: User) { private fun songStartCommand(msg: SessionChatMessage, user: User) {
if (msg.profile?.badges?.size == 0) { if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.") handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return return
} }
@@ -353,15 +353,15 @@ class MessageHandler(
// Replace followPattern // Replace followPattern
result = followPattern.replace(result) { _ -> result = followPattern.replace(result) { _ ->
try { try {
val followingDate = getFollowDate(channel.channelId, msg.senderChannelId) val followingDate = handler.chatChannelId?.let { getFollowDate(it, msg.senderChannelId) }
.content?.streamingProperty?.following?.followDate ?.content?.streamingProperty?.following?.followDate ?: LocalDateTime.now().minusDays(1).toString()
val period = followingDate?.let { val period = followingDate.let {
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
val pastDate = LocalDateTime.parse(it, formatter) val pastDate = LocalDateTime.parse(it, formatter)
val today = LocalDateTime.now() val today = LocalDateTime.now()
ChronoUnit.DAYS.between(pastDate, today) ChronoUnit.DAYS.between(pastDate, today)
} ?: 0 } + 1
period.toString() period.toString()
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -12,11 +12,12 @@ import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEve
import net.dv8tion.jda.api.hooks.ListenerAdapter import net.dv8tion.jda.api.hooks.ListenerAdapter
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder
import org.slf4j.LoggerFactory 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.chatbot.discord.commands.*
import space.mori.chzzk_bot.common.models.User import space.mori.chzzk_bot.common.models.User
import xyz.r2turntrue.chzzk4j.types.channel.live.ChzzkLiveDetail
import xyz.r2turntrue.chzzk4j.types.channel.live.Resolution
import java.time.Instant import java.time.Instant
import kotlin.jvm.optionals.getOrNull
val dotenv = dotenv { val dotenv = dotenv {
ignoreIfMissing = true ignoreIfMissing = true
@@ -33,20 +34,26 @@ class Discord: ListenerAdapter() {
return bot.getGuildById(guildId)?.getTextChannelById(channelId) return bot.getGuildById(guildId)?.getTextChannelById(channelId)
} }
fun sendDiscord(user: User, status: IData<IStreamInfo?>) { fun sendDiscord(user: User, status: ChzzkLiveDetail) {
if(status.content == null) return
if(user.liveAlertMessage != null && user.liveAlertGuild != null && user.liveAlertChannel != null) { if(user.liveAlertMessage != null && user.liveAlertGuild != null && user.liveAlertChannel != null) {
val channel = getChannel(user.liveAlertGuild ?: 0, user.liveAlertChannel ?: 0) val channel = getChannel(user.liveAlertGuild ?: 0, user.liveAlertChannel ?: 0)
?: throw RuntimeException("${user.liveAlertChannel} is not valid.") ?: throw RuntimeException("${user.liveAlertChannel} is not valid.")
val embed = EmbedBuilder() 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.setDescription("${user.username} 님이 방송을 시작했습니다.")
embed.setTimestamp(Instant.now()) embed.setTimestamp(Instant.now())
embed.setAuthor(user.username, "https://chzzk.naver.com/live/${user.token}", status.content!!.channel.channelImageUrl) embed.setAuthor(user.username, "https://chzzk.naver.com/live/${user.token}")
embed.addField("카테고리", status.content!!.liveCategoryValue, true) embed.addField("카테고리", status.liveCategoryValue, true)
embed.addField("태그", status.content!!.tags.joinToString(", "), true) embed.addField("태그", status.tags.joinToString(", ") { it.trim() }, true)
embed.setImage(status.content!!.liveImageUrl.replace("{type}", "1080")) status.defaultThumbnailImageUrl.getOrNull()?.let { embed.setImage(it) }
?: Resolution.entries.reversed().forEach {
val thumbnail = status.getLiveImageUrl(it)
if (thumbnail != null) {
embed.setImage(thumbnail)
return@forEach
}
}
channel.sendMessage( channel.sendMessage(
MessageCreateBuilder() MessageCreateBuilder()

View File

@@ -1,9 +1,12 @@
package space.mori.chzzk_bot.chatbot.utils package space.mori.chzzk_bot.chatbot.utils
import com.google.gson.Gson import com.google.gson.Gson
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import space.mori.chzzk_bot.chatbot.chzzk.dotenv
import space.mori.chzzk_bot.common.utils.client import space.mori.chzzk_bot.common.utils.client
import xyz.r2turntrue.chzzk4j.ChzzkClient import xyz.r2turntrue.chzzk4j.ChzzkClient
import java.io.IOException import java.io.IOException
@@ -36,9 +39,9 @@ fun ChzzkClient.refreshAccessToken(refreshToken: String): Pair<String, String> {
.post(gson.toJson(mapOf( .post(gson.toJson(mapOf(
"grantType" to "refresh_token", "grantType" to "refresh_token",
"refreshToken" to refreshToken, "refreshToken" to refreshToken,
"clientId" to this.apiClientId, "clientId" to dotenv["NAVER_CLIENT_ID"],
"clientSecret" to this.apiSecret "clientSecret" to dotenv["NAVER_CLIENT_SECRET"]
)).toRequestBody()) )).toRequestBody("application/json; charset=utf-8".toMediaType()))
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->

View File

@@ -0,0 +1,7 @@
package space.mori.chzzk_bot.common.events
data class ChzzkUserFindEvent(
val uid: String
): Event {
val TAG = javaClass.simpleName
}

View File

@@ -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
}

View File

@@ -52,47 +52,18 @@ data class NicknameColor(
val colorCode: String = "" val colorCode: String = ""
) )
// Stream info data class LiveStatus(
data class IStreamInfo( val liveTitle: String,
val liveId: Int = 0, val status: String,
val liveTitle: String = "", val concurrentUserCount: Int,
val status: String = "", val accumulateCount: Int,
val liveImageUrl: String = "", val paidPromotion: Boolean,
val defaultThumbnailImageUrl: String? = null, val adult: Boolean,
val concurrentUserCount: Int = 0, val krOnlyViewing: Boolean,
val accumulateCount: Int = 0, val openDate: String,
val openDate: String = "", val closeDate: String?,
val closeDate: String = "", val clipActive: Boolean,
val adult: Boolean = false, val chatChannelId: String
val clipActive: Boolean = false,
val tags: List<String> = 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<Any> = 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 추가 // OkHttpClient에 Interceptor 추가
@@ -128,38 +99,21 @@ fun getFollowDate(chatID: String, userId: String) : IData<IFollowContent?> {
} }
} }
fun getStreamInfo(userId: String) : IData<IStreamInfo?> { fun getChzzkChannelId(channelId: String): String? {
val url = "https://api.chzzk.naver.com/service/v3/channels/${userId}/live-detail" val url = "https://api.chzzk.naver.com/polling/v3/channels/$channelId/live-status?includePlayerRecommendContent=false"
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.header("Content-Type", "application/json")
.get()
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
try { try {
if(!response.isSuccessful) throw IOException("Unexpected code ${response.code}") if(!response.isSuccessful) throw IOException("Unexpected code ${response.code}")
val body = response.body?.string() val body = response.body?.string()
val follow = gson.fromJson(body, object: TypeToken<IData<IStreamInfo?>>() {}) val data = gson.fromJson(body, object: TypeToken<IData<LiveStatus?>>() {})
return follow return data.content?.chatChannelId
} catch(e: Exception) {
throw e
}
}
}
fun getUserInfo(userId: String): IData<Channel?> {
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<IData<Channel?>>() {})
return channel
} catch(e: Exception) { } catch(e: Exception) {
throw e throw e
} }

View File

@@ -22,12 +22,16 @@ import io.ktor.server.websocket.*
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.koin.java.KoinJavaComponent.inject
import space.mori.chzzk_bot.common.events.CoroutinesEventBus
import space.mori.chzzk_bot.common.events.UserRegisterEvent
import space.mori.chzzk_bot.common.services.UserService import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.webserver.routes.* import space.mori.chzzk_bot.webserver.routes.*
import space.mori.chzzk_bot.webserver.utils.DiscordRatelimits import space.mori.chzzk_bot.webserver.utils.DiscordRatelimits
import java.math.BigInteger import java.math.BigInteger
import java.security.SecureRandom import java.security.SecureRandom
import java.time.Duration import java.time.Duration
import kotlin.getValue
import kotlin.time.toKotlinDuration import kotlin.time.toKotlinDuration
val dotenv = dotenv { val dotenv = dotenv {
@@ -81,6 +85,8 @@ val server = embeddedServer(Netty, port = 8080, ) {
} }
} }
routing { routing {
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
route("/auth") { route("/auth") {
// discord login // discord login
authenticate("auth-oauth-discord") { authenticate("auth-oauth-discord") {
@@ -175,7 +181,7 @@ val server = embeddedServer(Netty, port = 8080, ) {
clientSecret = dotenv["NAVER_CLIENT_SECRET"] clientSecret = dotenv["NAVER_CLIENT_SECRET"]
) )
val response = applicationHttpClient.post("https://chzzk.naver.com/auth/v1/token") { val response = applicationHttpClient.post("https://openapi.chzzk.naver.com/auth/v1/token") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(tokenRequest) setBody(tokenRequest)
} }
@@ -191,7 +197,12 @@ val server = embeddedServer(Netty, port = 8080, ) {
val userInfo = getChzzkUser(tokenResponse.content.accessToken) val userInfo = getChzzkUser(tokenResponse.content.accessToken)
if(userInfo.content != null) { if(userInfo.content != null) {
val user = UserService.getUser(userInfo.content.channelId) var user = UserService.getUser(userInfo.content.channelId)
if(user == null) {
user = UserService.saveUser(userInfo.content.channelName , userInfo.content.channelId)
}
call.sessions.set( call.sessions.set(
UserSession( UserSession(
session.state, session.state,
@@ -199,7 +210,13 @@ val server = embeddedServer(Netty, port = 8080, ) {
listOf() listOf()
) )
) )
user?.let { UserService.setRefreshToken(it, tokenResponse.content.accessToken, tokenResponse.content.refreshToken ?: "") } UserService.setRefreshToken(user,
tokenResponse.content.accessToken,
tokenResponse.content.refreshToken ?: ""
)
dispatcher.post(UserRegisterEvent(user.token))
call.respondRedirect(getFrontendURL("")) call.respondRedirect(getFrontendURL(""))
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -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.SongConfigService
import space.mori.chzzk_bot.common.services.UserService import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.webserver.UserSession 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 @Serializable
data class GetUserDTO( data class GetUserDTO(
@@ -36,6 +39,20 @@ data class GetSessionDTO(
fun Routing.apiRoutes() { fun Routing.apiRoutes() {
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java) val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
suspend fun getChzzkUserWithId(uid: String): ChzzkUserReceiveEvent? {
val completableDeferred = CompletableDeferred<ChzzkUserReceiveEvent>()
dispatcher.subscribe(ChzzkUserReceiveEvent::class) { event ->
if (event.uid == uid) {
completableDeferred.complete(event)
}
}
val user = withTimeoutOrNull(5000) {
dispatcher.post(ChzzkUserFindEvent(uid))
completableDeferred.await()
}
return user
}
route("/") { route("/") {
get { get {
call.respondText("Hello World!", status = call.respondText("Hello World!", status =
@@ -55,16 +72,16 @@ fun Routing.apiRoutes() {
call.respondText("Require UID", status = HttpStatusCode.NotFound) call.respondText("Require UID", status = HttpStatusCode.NotFound)
return@get return@get
} }
val user = ChzzkUserCache.getCachedUser(uid) val user = getChzzkUserWithId(uid)
if(user?.content == null) { if (user?.find == false) {
call.respondText("User not found", status = HttpStatusCode.NotFound) call.respondText("User not found", status = HttpStatusCode.NotFound)
return@get return@get
} else { } else {
call.respond(HttpStatusCode.OK, GetUserDTO( call.respond(HttpStatusCode.OK, GetUserDTO(
user.content!!.channel.channelId, user?.uid ?: "",
user.content!!.channel.channelName, user?.nickname ?: "",
user.content!!.status == "OPEN", user?.isStreamOn ?: false,
user.content!!.channel.channelImageUrl user?.avatarUrl ?: ""
)) ))
} }
} }
@@ -82,7 +99,7 @@ fun Routing.apiRoutes() {
user = UserService.saveUser("임시닉네임", session.id) user = UserService.saveUser("임시닉네임", session.id)
} }
val songConfig = SongConfigService.getConfig(user) val songConfig = SongConfigService.getConfig(user)
val status = ChzzkUserCache.getCachedUser(session.id) val status = getChzzkUserWithId(user.token)
val returnUsers = mutableListOf<GetSessionDTO>() val returnUsers = mutableListOf<GetSessionDTO>()
if(status == null) { if(status == null) {
@@ -91,14 +108,14 @@ fun Routing.apiRoutes() {
} }
if (user.username == "임시닉네임") { 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( returnUsers.add(GetSessionDTO(
status.content?.channel?.channelId ?: user.username, status.uid ?: user.token,
status.content?.channel?.channelName ?: user.token, status.nickname ?: user.username,
status.content?.status == "OPEN", status.isStreamOn == true,
status.content?.channel?.channelImageUrl ?: "", status.avatarUrl ?: "",
songConfig.queueLimit, songConfig.queueLimit,
songConfig.personalLimit, songConfig.personalLimit,
songConfig.streamerOnly, songConfig.streamerOnly,
@@ -109,15 +126,15 @@ fun Routing.apiRoutes() {
user.subordinates.toList() user.subordinates.toList()
} }
returnUsers.addAll(subordinates.map { returnUsers.addAll(subordinates.map {
val subStatus = ChzzkUserCache.getCachedUser(it.token) val subStatus = getChzzkUserWithId(it.token)
return@map if (subStatus?.content == null) { return@map if (subStatus == null) {
null null
} else { } else {
GetSessionDTO( GetSessionDTO(
subStatus.content!!.channel.channelId, subStatus.uid ?: "",
subStatus.content!!.channel.channelName, subStatus.nickname ?: "",
subStatus.content!!.status == "OPEN", subStatus.isStreamOn == true,
subStatus.content!!.channel.channelImageUrl, subStatus.avatarUrl ?: "",
0, 0,
0, 0,
false, false,

View File

@@ -37,9 +37,10 @@ fun Routing.wsSongRoutes() {
fun addSession(uid: String, session: WebSocketServerSession) { fun addSession(uid: String, session: WebSocketServerSession) {
sessions.computeIfAbsent(uid) { ConcurrentLinkedQueue() }.add(session) sessions.computeIfAbsent(uid) { ConcurrentLinkedQueue() }.add(session)
} }
fun removeSession(uid: String, session: WebSocketServerSession) { fun removeSession(uid: String, session: WebSocketServerSession) {
sessions[uid]?.remove(session) sessions[uid]?.remove(session)
if(sessions[uid]?.isEmpty() == true) { if (sessions[uid]?.isEmpty() == true) {
sessions.remove(uid) sessions.remove(uid)
} }
} }
@@ -88,30 +89,50 @@ fun Routing.wsSongRoutes() {
} }
webSocket("/song/{uid}") { webSocket("/song/{uid}") {
logger.info("WebSocket connection attempt received")
val uid = call.parameters["uid"] val uid = call.parameters["uid"]
val user = uid?.let { UserService.getUser(it) } val user = uid?.let { UserService.getUser(it) }
if (uid == null || user == null) { if (uid == null || user == null) {
logger.warn("Invalid UID: $uid")
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID")) close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID"))
return@webSocket return@webSocket
} }
try {
addSession(uid, this) addSession(uid, this)
if(status[uid] == SongType.STREAM_OFF) { logger.info("WebSocket connection established for user: $uid")
// Start heartbeat
val heartbeatJob = songScope.launch {
while (true) {
try {
send(Frame.Ping(ByteArray(0)))
delay(30000) // 30 seconds
} catch (e: Exception) {
logger.error("Heartbeat failed for user $uid", e)
break
}
}
}
if (status[uid] == SongType.STREAM_OFF) {
songScope.launch { songScope.launch {
sendSerialized(SongResponse( sendSerialized(
SongResponse(
SongType.STREAM_OFF.value, SongType.STREAM_OFF.value,
uid, uid,
null, null,
null, null,
null, null,
)) )
)
} }
} }
try { try {
for (frame in incoming) { for (frame in incoming) {
when(frame) { when (frame) {
is Frame.Text -> { is Frame.Text -> {
val text = frame.readText().trim() val text = frame.readText().trim()
if(text == "ping") { if (text == "ping") {
send("pong") send("pong")
} else { } else {
val data = Json.decodeFromString<SongRequest>(text) val data = Json.decodeFromString<SongRequest>(text)
@@ -121,45 +142,58 @@ fun Routing.wsSongRoutes() {
} }
} }
} }
is Frame.Ping -> send(Frame.Pong(frame.data)) is Frame.Ping -> send(Frame.Pong(frame.data))
else -> {} else -> {}
} }
} }
} catch(e: ClosedReceiveChannelException) { } catch (e: ClosedReceiveChannelException) {
logger.error("Error in WebSocket: ${e.message}") logger.error("WebSocket connection closed for user $uid: ${e.message}")
} catch (e: Exception) {
logger.error("Unexpected error in WebSocket for user $uid", e)
} finally { } finally {
logger.info("Cleaning up WebSocket connection for user $uid")
removeSession(uid, this) removeSession(uid, this)
ackMap[uid]?.remove(this) ackMap[uid]?.remove(this)
heartbeatJob.cancel()
}
} catch(e: Exception) {
logger.error("Unexpected error in WebSocket for user $uid", e)
} }
} }
dispatcher.subscribe(SongEvent::class) { dispatcher.subscribe(SongEvent::class) {
logger.debug("SongEvent: {} / {} {}", it.uid, it.type, it.current?.name) logger.debug("SongEvent: {} / {} {}", it.uid, it.type, it.current?.name)
songScope.launch { songScope.launch {
broadcastMessage(it.uid, SongResponse( broadcastMessage(
it.uid, SongResponse(
it.type.value, it.type.value,
it.uid, it.uid,
it.reqUid, it.reqUid,
it.current?.toSerializable(), it.current?.toSerializable(),
it.next?.toSerializable(), it.next?.toSerializable(),
it.delUrl it.delUrl
)) )
)
} }
} }
dispatcher.subscribe(TimerEvent::class) { dispatcher.subscribe(TimerEvent::class) {
if(it.type == TimerType.STREAM_OFF) { if (it.type == TimerType.STREAM_OFF) {
songScope.launch { songScope.launch {
broadcastMessage(it.uid, SongResponse( broadcastMessage(
it.uid, SongResponse(
it.type.value, it.type.value,
it.uid, it.uid,
null, null,
null, null,
null, null,
)) )
)
} }
} }
} }
} }
@Serializable @Serializable
data class SerializableYoutubeVideo( data class SerializableYoutubeVideo(
val url: String, val url: String,

View File

@@ -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<String, CachedUser>()
private const val EXP_SECONDS = 600L
private val mutex = Mutex()
private val logger = LoggerFactory.getLogger(this::class.java)
suspend fun getCachedUser(id: String): IData<IStreamInfo?>? {
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<IStreamInfo?>,
val timestamp: Instant = Instant.now(),
)