Compare commits

..

No commits in common. "101db7d20c1c183a428b406c5ec3edd2cc1dddd1" and "8ab1dc585e101b0d0c2406e2e11da377b6cbe516" have entirely different histories.

15 changed files with 397 additions and 626 deletions

View File

@ -28,7 +28,7 @@ repositories {
dependencies { dependencies {
// https://mvnrepository.com/artifact/ch.qos.logback/logback-classic // https://mvnrepository.com/artifact/ch.qos.logback/logback-classic
implementation("ch.qos.logback:logback-classic:1.5.13") implementation("ch.qos.logback:logback-classic:1.5.12")
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core // https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")

View File

@ -16,10 +16,10 @@ dependencies {
} }
// https://mvnrepository.com/artifact/io.github.R2turnTrue/chzzk4j // https://mvnrepository.com/artifact/io.github.R2turnTrue/chzzk4j
implementation("io.github.R2turnTrue:chzzk4j:0.1.1") implementation("io.github.R2turnTrue:chzzk4j:0.0.12")
// https://mvnrepository.com/artifact/ch.qos.logback/logback-classic // https://mvnrepository.com/artifact/ch.qos.logback/logback-classic
implementation("ch.qos.logback:logback-classic:1.5.13") implementation("ch.qos.logback:logback-classic:1.5.12")
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core // https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")

View File

@ -1,31 +1,25 @@
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.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.future.await 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.chzzk
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.common.events.* import space.mori.chzzk_bot.common.events.*
import space.mori.chzzk_bot.common.models.User import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.services.LiveStatusService import space.mori.chzzk_bot.common.services.LiveStatusService
import space.mori.chzzk_bot.common.services.TimerConfigService 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.chat.ChatEventListener
import xyz.r2turntrue.chzzk4j.auth.ChzzkSimpleUserLoginAdapter import xyz.r2turntrue.chzzk4j.chat.ChatMessage
import xyz.r2turntrue.chzzk4j.session.ChzzkSessionBuilder import xyz.r2turntrue.chzzk4j.chat.ChzzkChat
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.ChzzkChannel
import java.lang.Exception import java.lang.Exception
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
@ -43,17 +37,18 @@ object ChzzkHandler {
} }
fun enable() { fun enable() {
botUid = chzzk.loggedUser.userId
UserService.getAllUsers().map { UserService.getAllUsers().map {
if(!it.isDisabled) if(!it.isDisabled)
try { try {
Connector.getChannel(it.token)?.let { token -> addUser(token, it) } chzzk.getChannel(it.token)?.let { token -> addUser(token, it) }
} catch(e: Exception) { } catch(e: Exception) {
logger.info("Exception: ${it.token}(${it.username}) not found. ${e.stackTraceToString()}") logger.info("Exception: ${it.token}(${it.username}) not found. ${e.stackTraceToString()}")
} }
} }
handlers.forEach { handler -> handlers.forEach { handler ->
val streamInfo = getStreamInfo(handler.channel.channelId) val streamInfo = getStreamInfo(handler.listener.channelId)
if (streamInfo.content?.status == "OPEN") handler.isActive(true, streamInfo) if (streamInfo.content?.status == "OPEN") handler.isActive(true, streamInfo)
} }
@ -204,8 +199,7 @@ class UserHandler(
var streamStartTime: LocalDateTime?, var streamStartTime: LocalDateTime?,
) { ) {
var messageHandler: MessageHandler var messageHandler: MessageHandler
lateinit var client: ChzzkClient var listener: ChzzkChat
lateinit var listener: ChzzkUserSession
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
@ -215,30 +209,35 @@ class UserHandler(
} }
init { init {
val user = UserService.getUser(channel.channelId) listener = chzzk.chat(channel.channelId)
.withAutoReconnect(true)
.withChatListener(object : ChatEventListener {
override fun onConnect(chat: ChzzkChat, isReconnecting: Boolean) {
logger.info("${channel.channelName} - ${channel.channelId} / reconnected: $isReconnecting")
}
if(user?.accessToken == null || user.refreshToken == null) { override fun onError(ex: Exception) {
throw RuntimeException("AccessToken or RefreshToken is not valid.") logger.info("ChzzkChat error. ${channel.channelName} - ${channel.channelId}")
} logger.info(ex.stackTraceToString())
}
val tokens = ChzzkClient.refreshAccessToken(user.refreshToken!!) override fun onChat(msg: ChatMessage) {
if(!_isActive) return
messageHandler.handle(msg, user)
}
client = Connector.getClient(tokens.first, tokens.second) override fun onConnectionClosed(code: Int, reason: String?, remote: Boolean, tryingToReconnect: Boolean) {
listener = ChzzkSessionBuilder(client).buildUserSession() logger.info("ChzzkChat closed. ${channel.channelName} - ${channel.channelId}")
logger.info("Reason: $reason / $tryingToReconnect")
UserService.setTokens(user, tokens.first, tokens.second) }
})
listener.createAndConnectAsync().join() .build()
listener.on(SessionChatMessageEvent::class.java) {
messageHandler.handle(it.message, user)
}
messageHandler = MessageHandler(this@UserHandler) messageHandler = MessageHandler(this@UserHandler)
} }
internal fun disable() { internal fun disable() {
listener.disconnectAsync().join() listener.closeAsync()
_isActive = false
} }
internal fun reloadCommand() { internal fun reloadCommand() {
@ -260,7 +259,7 @@ 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.connectAsync().await()
streamStartTime = status.content?.openDate?.let { convertChzzkDateToLocalDateTime(it) } streamStartTime = status.content?.openDate?.let { convertChzzkDateToLocalDateTime(it) }
@ -286,7 +285,7 @@ class UserHandler(
delay(5000L) delay(5000L)
try { try {
if(!user.isDisableStartupMsg) if(!user.isDisableStartupMsg)
sendChat("${user.username} 님! 오늘도 열심히 방송하세요!") listener.sendChat("${user.username} 님! 오늘도 열심히 방송하세요!")
Discord.sendDiscord(user, status) Discord.sendDiscord(user, status)
} catch(e: Exception) { } catch(e: Exception) {
logger.info("Stream on logic has some error: ${e.stackTraceToString()}") logger.info("Stream on logic has some error: ${e.stackTraceToString()}")
@ -296,7 +295,7 @@ class UserHandler(
} else { } else {
logger.info("${user.username} is offline.") logger.info("${user.username} is offline.")
streamStartTime = null streamStartTime = null
listener.disconnectAsync().join() listener.closeAsync()
_isActive = false _isActive = false
CoroutineScope(Dispatchers.Default).launch { CoroutineScope(Dispatchers.Default).launch {
@ -320,8 +319,4 @@ class UserHandler(
} }
} }
} }
}
internal fun sendChat(msg: String) {
client.sendChatToLoggedInChannel(msg)
}
}

View File

@ -2,10 +2,8 @@ package space.mori.chzzk_bot.chatbot.chzzk
import io.github.cdimascio.dotenv.dotenv import io.github.cdimascio.dotenv.dotenv
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import xyz.r2turntrue.chzzk4j.ChzzkClient import xyz.r2turntrue.chzzk4j.Chzzk
import xyz.r2turntrue.chzzk4j.ChzzkClientBuilder import xyz.r2turntrue.chzzk4j.ChzzkBuilder
import xyz.r2turntrue.chzzk4j.auth.ChzzkOauthLoginAdapter
import xyz.r2turntrue.chzzk4j.auth.ChzzkSimpleUserLoginAdapter
import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel
val dotenv = dotenv { val dotenv = dotenv {
@ -13,24 +11,14 @@ val dotenv = dotenv {
} }
object Connector { object Connector {
val client: ChzzkClient = ChzzkClientBuilder(dotenv["NAVER_CLIENT_ID"], dotenv["NAVER_CLIENT_SECRET"]) val chzzk: Chzzk = ChzzkBuilder()
.withAuthorization(dotenv["NID_AUT"], dotenv["NID_SES"])
.build() .build()
private val logger = LoggerFactory.getLogger(this::class.java) private val logger = LoggerFactory.getLogger(this::class.java)
fun getChannel(channelId: String): ChzzkChannel? = client.fetchChannel(channelId) fun getChannel(channelId: String): ChzzkChannel? = chzzk.getChannel(channelId)
init { init {
logger.info("chzzk logged: ${client.isLoggedIn}") logger.info("chzzk logged: ${chzzk.isLoggedIn} / ${chzzk.loggedUser?.nickname ?: "----"}")
client.loginAsync().join()
}
fun getClient(accessToken: String, refreshToken: String): ChzzkClient {
val adapter = ChzzkSimpleUserLoginAdapter(accessToken, refreshToken)
val client = ChzzkClientBuilder(dotenv["NAVER_CLIENT_ID"], dotenv["NAVER_CLIENT_SECRET"])
.withLoginAdapter(adapter)
.build()
return client
} }
} }

View File

@ -13,16 +13,15 @@ import space.mori.chzzk_bot.common.utils.getUptime
import space.mori.chzzk_bot.common.utils.getYoutubeVideo import space.mori.chzzk_bot.common.utils.getYoutubeVideo
import xyz.r2turntrue.chzzk4j.chat.ChatMessage import xyz.r2turntrue.chzzk4j.chat.ChatMessage
import xyz.r2turntrue.chzzk4j.chat.ChzzkChat import xyz.r2turntrue.chzzk4j.chat.ChzzkChat
import xyz.r2turntrue.chzzk4j.session.ChzzkUserSession
import xyz.r2turntrue.chzzk4j.session.message.SessionChatMessage
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
class MessageHandler( class MessageHandler(
private val handler: UserHandler private val handler: UserHandler
) { ) {
private val commands = mutableMapOf<String, (msg: SessionChatMessage, user: User) -> Unit>() private val commands = mutableMapOf<String, (msg: ChatMessage, user: User) -> Unit>()
private val counterPattern = Regex("<counter:([^>]+)>") private val counterPattern = Regex("<counter:([^>]+)>")
private val personalCounterPattern = Regex("<counter_personal:([^>]+)>") private val personalCounterPattern = Regex("<counter_personal:([^>]+)>")
@ -72,90 +71,85 @@ class MessageHandler(
this.commands.put(it.command.lowercase()) { msg, user -> this.commands.put(it.command.lowercase()) { msg, user ->
logger.debug("${channel.channelName} - ${it.command} - ${it.content}/${it.failContent}") logger.debug("${channel.channelName} - ${it.command} - ${it.content}/${it.failContent}")
val result = replaceCounters( val result = replaceCounters(Pair(it.content, it.failContent), user, msg, listener, msg.profile?.nickname ?: "")
Pair(it.content, it.failContent), listener.sendChat(result)
user,
msg,
msg.profile?.nickname ?: ""
)
handler.sendChat(result)
} }
} }
} }
private fun commandListCommand(msg: SessionChatMessage, user: User) { private fun commandListCommand(msg: ChatMessage, user: User) {
handler.sendChat("리스트는 여기입니다. https://nabot.mori.space/commands/${user.token}") listener.sendChat("리스트는 여기입니다. https://nabot.mori.space/commands/${user.token}")
} }
private fun manageAddCommand(msg: SessionChatMessage, user: User) { private fun manageAddCommand(msg: ChatMessage, user: User) {
if (msg.profile.badges.size == 0) { if (msg.profile?.userRoleCode == "common_user") {
handler.sendChat("매니저만 명령어를 추가할 수 있습니다.") listener.sendChat("매니저만 명령어를 추가할 수 있습니다.")
return return
} }
val parts = msg.content.split(" ", limit = 3) val parts = msg.content.split(" ", limit = 3)
if (parts.size < 3) { if (parts.size < 3) {
handler.sendChat("명령어 추가 형식은 '!명령어추가 명령어 내용'입니다.") listener.sendChat("명령어 추가 형식은 '!명령어추가 명령어 내용'입니다.")
return return
} }
if (commands.containsKey(parts[1])) { if (commands.containsKey(parts[1])) {
handler.sendChat("${parts[1]} 명령어는 이미 있는 명령어입니다.") listener.sendChat("${parts[1]} 명령어는 이미 있는 명령어입니다.")
return return
} }
val command = parts[1] val command = parts[1]
val content = parts[2] val content = parts[2]
CommandService.saveCommand(user, command, content, "") CommandService.saveCommand(user, command, content, "")
handler.sendChat("명령어 '$command' 추가되었습니다.") listener.sendChat("명령어 '$command' 추가되었습니다.")
} }
private fun manageUpdateCommand(msg: SessionChatMessage, user: User) { private fun manageUpdateCommand(msg: ChatMessage, user: User) {
if (msg.profile.badges.size == 0) { if (msg.profile?.userRoleCode == "common_user") {
handler.sendChat("매니저만 명령어를 추가할 수 있습니다.") listener.sendChat("매니저만 명령어를 추가할 수 있습니다.")
return return
} }
val parts = msg.content.split(" ", limit = 3) val parts = msg.content.split(" ", limit = 3)
if (parts.size < 3) { if (parts.size < 3) {
handler.sendChat("명령어 수정 형식은 '!명령어수정 명령어 내용'입니다.") listener.sendChat("명령어 수정 형식은 '!명령어수정 명령어 내용'입니다.")
return return
} }
if (!commands.containsKey(parts[1])) { if (!commands.containsKey(parts[1])) {
handler.sendChat("${parts[1]} 명령어는 없는 명령어입니다.") listener.sendChat("${parts[1]} 명령어는 없는 명령어입니다.")
return return
} }
val command = parts[1] val command = parts[1]
val content = parts[2] val content = parts[2]
CommandService.updateCommand(user, command, content, "") CommandService.updateCommand(user, command, content, "")
handler.sendChat("명령어 '$command' 수정되었습니다.") listener.sendChat("명령어 '$command' 수정되었습니다.")
ChzzkHandler.reloadCommand(channel) ChzzkHandler.reloadCommand(channel)
} }
private fun manageRemoveCommand(msg: SessionChatMessage, user: User) { private fun manageRemoveCommand(msg: ChatMessage, user: User) {
if (msg.profile.badges.size == 0) { if (msg.profile?.userRoleCode == "common_user") {
handler.sendChat("매니저만 명령어를 삭제할 수 있습니다.") listener.sendChat("매니저만 명령어를 삭제할 수 있습니다.")
return return
} }
val parts = msg.content.split(" ", limit = 2) val parts = msg.content.split(" ", limit = 2)
if (parts.size < 2) { if (parts.size < 2) {
handler.sendChat("명령어 삭제 형식은 '!명령어삭제 명령어'입니다.") listener.sendChat("명령어 삭제 형식은 '!명령어삭제 명령어'입니다.")
return return
} }
val command = parts[1] val command = parts[1]
CommandService.removeCommand(user, command) CommandService.removeCommand(user, command)
handler.sendChat("명령어 '$command' 삭제되었습니다.") listener.sendChat("명령어 '$command' 삭제되었습니다.")
ChzzkHandler.reloadCommand(channel) ChzzkHandler.reloadCommand(channel)
} }
private fun timerCommand(msg: SessionChatMessage, user: User) { private fun timerCommand(msg: ChatMessage, user: User) {
if (msg.profile.badges.size == 0) { if (msg.profile?.userRoleCode == "common_user") {
handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.") listener.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return return
} }
val parts = msg.content.split(" ", limit = 3) val parts = msg.content.split(" ", limit = 3)
if (parts.size < 2) { if (parts.size < 2) {
handler.sendChat("타이머 명령어 형식을 잘 찾아봐주세요!") listener.sendChat("타이머 명령어 형식을 잘 찾아봐주세요!")
return return
} }
@ -184,13 +178,13 @@ class MessageHandler(
when (parts[2]) { when (parts[2]) {
"업타임" -> { "업타임" -> {
TimerConfigService.saveOrUpdateConfig(user, TimerType.UPTIME) TimerConfigService.saveOrUpdateConfig(user, TimerType.UPTIME)
handler.sendChat("기본 타이머 설정이 업타임으로 바뀌었습니다.") listener.sendChat("기본 타이머 설정이 업타임으로 바뀌었습니다.")
} }
"삭제" -> { "삭제" -> {
TimerConfigService.saveOrUpdateConfig(user, TimerType.REMOVE) TimerConfigService.saveOrUpdateConfig(user, TimerType.REMOVE)
handler.sendChat("기본 타이머 설정이 삭제로 바뀌었습니다.") listener.sendChat("기본 타이머 설정이 삭제로 바뀌었습니다.")
} }
else -> handler.sendChat("!타이머 설정 (업타임/삭제) 형식으로 써주세요!") else -> listener.sendChat("!타이머 설정 (업타임/삭제) 형식으로 써주세요!")
} }
} }
else -> { else -> {
@ -204,9 +198,9 @@ class MessageHandler(
dispatcher.post(TimerEvent(user.token, TimerType.TIMER, timestamp.toString())) dispatcher.post(TimerEvent(user.token, TimerType.TIMER, timestamp.toString()))
} }
} catch (e: NumberFormatException) { } catch (e: NumberFormatException) {
handler.sendChat("!타이머/숫자 형식으로 적어주세요! 단위: 분") listener.sendChat("!타이머/숫자 형식으로 적어주세요! 단위: 분")
} catch (e: Exception) { } catch (e: Exception) {
handler.sendChat("타이머 설정 중 오류가 발생했습니다.") listener.sendChat("타이머 설정 중 오류가 발생했습니다.")
logger.error("Error processing timer command: ${e.message}", e) logger.error("Error processing timer command: ${e.message}", e)
} }
} }
@ -214,21 +208,21 @@ class MessageHandler(
} }
// songs // songs
private fun songAddCommand(msg: SessionChatMessage, user: User) { private fun songAddCommand(msg: ChatMessage, user: User) {
if(SongConfigService.getConfig(user).disabled) { if(SongConfigService.getConfig(user).disabled) {
return return
} }
val parts = msg.content.split(" ", limit = 2) val parts = msg.content.split(" ", limit = 2)
if (parts.size < 2) { if (parts.size < 2) {
handler.sendChat("유튜브 URL을 입력해주세요!") listener.sendChat("유튜브 URL을 입력해주세요!")
return return
} }
val config = SongConfigService.getConfig(user) val config = SongConfigService.getConfig(user)
if(config.streamerOnly && msg.profile.badges.size == 0) { if(config.streamerOnly && msg.profile?.userRoleCode == "common_user") {
handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.") listener.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return return
} }
@ -236,34 +230,34 @@ class MessageHandler(
val songs = SongListService.getSong(user) val songs = SongListService.getSong(user)
if(songs.size >= config.queueLimit) { if(songs.size >= config.queueLimit) {
handler.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!") listener.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!")
return return
} }
if(songs.filter { it.uid == msg.senderChannelId }.size >= config.personalLimit) { if(songs.filter { it.uid == msg.userId }.size >= config.personalLimit) {
handler.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!") listener.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!")
return return
} }
try { try {
val video = getYoutubeVideo(url) val video = getYoutubeVideo(url)
if (video == null) { if (video == null) {
handler.sendChat("유튜브에서 찾을 수 없어요!") listener.sendChat("유튜브에서 찾을 수 없어요!")
return return
} }
if (songs.any { it.url == video.url }) { if (songs.any { it.url == video.url }) {
handler.sendChat("같은 노래가 이미 신청되어 있습니다.") listener.sendChat("같은 노래가 이미 신청되어 있습니다.")
return return
} }
if (video.length > 600) { if (video.length > 600) {
handler.sendChat("10분이 넘는 노래는 신청할 수 없습니다.") listener.sendChat("10분이 넘는 노래는 신청할 수 없습니다.")
return return
} }
SongListService.saveSong( SongListService.saveSong(
user, user,
msg.senderChannelId, msg.userId,
video.url, video.url,
video.name, video.name,
video.author, video.author,
@ -275,31 +269,31 @@ class MessageHandler(
SongEvent( SongEvent(
user.token, user.token,
SongType.ADD, SongType.ADD,
msg.senderChannelId, msg.userId,
null, null,
video, video,
) )
) )
} }
handler.sendChat("노래가 추가되었습니다. ${video.name} - ${video.author}") listener.sendChat("노래가 추가되었습니다. ${video.name} - ${video.author}")
} catch(e: Exception) { } catch(e: Exception) {
handler.sendChat("유튜브 영상 주소로 다시 신청해주세요!") listener.sendChat("유튜브 영상 주소로 다시 신청해주세요!")
logger.info(e.stackTraceToString()) logger.info(e.stackTraceToString())
} }
} }
private fun songListCommand(msg: SessionChatMessage, user: User) { private fun songListCommand(msg: ChatMessage, user: User) {
if(SongConfigService.getConfig(user).disabled) { if(SongConfigService.getConfig(user).disabled) {
return return
} }
handler.sendChat("리스트는 여기입니다. https://nabot.mori.space/songs/${user.token}") listener.sendChat("리스트는 여기입니다. https://nabot.mori.space/songs/${user.token}")
} }
private fun songStartCommand(msg: SessionChatMessage, user: User) { private fun songStartCommand(msg: ChatMessage, user: User) {
if (msg.profile?.badges?.size == 0) { if (msg.profile?.userRoleCode == "common_user") {
handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.") listener.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return return
} }
@ -312,28 +306,28 @@ class MessageHandler(
} }
} }
} else { } else {
handler.sendChat("나봇 홈페이지의 노래목록 페이지를 이용해주세요! 디스코드 연동을 하시면 DM으로 바로 전송됩니다.") listener.sendChat("나봇 홈페이지의 노래목록 페이지를 이용해주세요! 디스코드 연동을 하시면 DM으로 바로 전송됩니다.")
} }
} }
internal fun handle(msg: SessionChatMessage, user: User) { internal fun handle(msg: ChatMessage, user: User) {
if(msg.senderChannelId == ChzzkHandler.botUid) return if(msg.userId == ChzzkHandler.botUid) return
val commandKey = msg.content.split(' ')[0] val commandKey = msg.content.split(' ')[0]
commands[commandKey.lowercase()]?.let { it(msg, user) } commands[commandKey.lowercase()]?.let { it(msg, user) }
} }
private fun replaceCounters(chat: Pair<String, String>, user: User, msg: SessionChatMessage, userName: String): String { private fun replaceCounters(chat: Pair<String, String>, user: User, msg: ChatMessage, listener: ChzzkChat, userName: String): String {
var result = chat.first var result = chat.first
var isFail = false var isFail = false
// Replace dailyCounterPattern // Replace dailyCounterPattern
result = dailyCounterPattern.replace(result) { matchResult -> result = dailyCounterPattern.replace(result) { matchResult ->
val name = matchResult.groupValues[1] val name = matchResult.groupValues[1]
val dailyCounter = CounterService.getDailyCounterValue(name, msg.senderChannelId, user) val dailyCounter = CounterService.getDailyCounterValue(name, msg.userId, user)
if (dailyCounter.second) { if (dailyCounter.second) {
CounterService.updateDailyCounterValue(name, msg.senderChannelId, 1, user).first.toString() CounterService.updateDailyCounterValue(name, msg.userId, 1, user).first.toString()
} else { } else {
isFail = true isFail = true
dailyCounter.first.toString() dailyCounter.first.toString()
@ -345,7 +339,7 @@ class MessageHandler(
result = chat.second result = chat.second
result = dailyCounterPattern.replace(result) { matchResult -> result = dailyCounterPattern.replace(result) { matchResult ->
val name = matchResult.groupValues[1] val name = matchResult.groupValues[1]
val dailyCounter = CounterService.getDailyCounterValue(name, msg.senderChannelId, user) val dailyCounter = CounterService.getDailyCounterValue(name, msg.userId, user)
dailyCounter.first.toString() dailyCounter.first.toString()
} }
} }
@ -353,7 +347,7 @@ 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 = getFollowDate(listener.chatId, msg.userId)
.content?.streamingProperty?.following?.followDate .content?.streamingProperty?.following?.followDate
val period = followingDate?.let { val period = followingDate?.let {
@ -389,7 +383,7 @@ class MessageHandler(
// Replace personalCounterPattern // Replace personalCounterPattern
result = personalCounterPattern.replace(result) { matchResult -> result = personalCounterPattern.replace(result) { matchResult ->
val name = matchResult.groupValues[1] val name = matchResult.groupValues[1]
CounterService.updatePersonalCounterValue(name, msg.senderChannelId, 1, user).toString() CounterService.updatePersonalCounterValue(name, msg.userId, 1, user).toString()
} }
// Replace namePattern // Replace namePattern
@ -397,4 +391,5 @@ class MessageHandler(
return result return result
} }
}
}

View File

@ -1,55 +0,0 @@
package space.mori.chzzk_bot.chatbot.utils
import com.google.gson.Gson
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import space.mori.chzzk_bot.common.utils.client
import xyz.r2turntrue.chzzk4j.ChzzkClient
import java.io.IOException
val client = OkHttpClient.Builder()
.addNetworkInterceptor { chain ->
chain.proceed(
chain.request()
.newBuilder()
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
.build()
)
}
.build()
val gson = Gson()
data class RefreshTokenResponse(
val accessToken: String,
val refreshToken: String,
val expiresIn: Int,
val tokenType: String = "Bearer",
val scope: String
)
fun ChzzkClient.refreshAccessToken(refreshToken: String): Pair<String, String> {
val url = "https://openapi.chzzk.naver.com/auth/v1/token"
val request = Request.Builder()
.url(url)
.header("Content-Type", "application/json")
.post(gson.toJson(mapOf(
"grantType" to "refresh_token",
"refreshToken" to refreshToken,
"clientId" to this.apiClientId,
"clientSecret" to this.apiSecret
)).toRequestBody())
.build()
client.newCall(request).execute().use { response ->
try {
if(!response.isSuccessful) throw IOException("Unexpected code ${response.code}")
val body = response.body?.string()
val data = gson.fromJson(body, RefreshTokenResponse::class.java)
return Pair(data.accessToken, data.refreshToken)
} catch(e: Exception) {
throw e
}
}
}

View File

@ -23,7 +23,7 @@ dependencies {
api("com.zaxxer:HikariCP:6.1.0") api("com.zaxxer:HikariCP:6.1.0")
// https://mvnrepository.com/artifact/ch.qos.logback/logback-classic // https://mvnrepository.com/artifact/ch.qos.logback/logback-classic
implementation("ch.qos.logback:logback-classic:1.5.13") implementation("ch.qos.logback:logback-classic:1.5.12")
// https://mvnrepository.com/artifact/org.mariadb.jdbc/mariadb-java-client // https://mvnrepository.com/artifact/org.mariadb.jdbc/mariadb-java-client
implementation("org.mariadb.jdbc:mariadb-java-client:3.5.0") implementation("org.mariadb.jdbc:mariadb-java-client:3.5.0")

View File

@ -5,8 +5,7 @@ enum class TimerType(var value: Int) {
TIMER(1), TIMER(1),
REMOVE(2), REMOVE(2),
STREAM_OFF(50), STREAM_OFF(50)
ACK(51)
} }
class TimerEvent( class TimerEvent(

View File

@ -15,8 +15,6 @@ object Users: IntIdTable("users") {
val liveAlertMessage = text("live_alert_message").nullable() val liveAlertMessage = text("live_alert_message").nullable()
val isDisableStartupMsg = bool("is_disable_startup_msg").default(false) val isDisableStartupMsg = bool("is_disable_startup_msg").default(false)
val isDisabled = bool("is_disabled").default(false) val isDisabled = bool("is_disabled").default(false)
val accessToken = varchar("access_token", 255).nullable()
val refreshToken = varchar("refresh_token", 255).nullable()
} }
class User(id: EntityID<Int>) : IntEntity(id) { class User(id: EntityID<Int>) : IntEntity(id) {
@ -31,9 +29,6 @@ class User(id: EntityID<Int>) : IntEntity(id) {
var isDisableStartupMsg by Users.isDisableStartupMsg var isDisableStartupMsg by Users.isDisableStartupMsg
var isDisabled by Users.isDisabled var isDisabled by Users.isDisabled
var accessToken by Users.accessToken
var refreshToken by Users.refreshToken
// 유저가 가진 매니저들 // 유저가 가진 매니저들
var managers by User.via(UserManagers.user, UserManagers.manager) var managers by User.via(UserManagers.user, UserManagers.manager)

View File

@ -97,19 +97,4 @@ object UserService {
user user
} }
} }
fun setAccessToken(user: User, accessToken: String): User {
return transaction {
user.accessToken = accessToken
user
}
}
fun setRefreshToken(user: User, accessToken: String, refreshToken: String): User {
return transaction {
user.accessToken = accessToken
user.refreshToken = refreshToken
user
}
}
} }

View File

@ -10,7 +10,7 @@ repositories {
mavenCentral() mavenCentral()
} }
val ktorVersion = "3.1.3" val ktorVersion = "3.0.1"
dependencies { dependencies {
implementation("io.ktor:ktor-server-core:$ktorVersion") implementation("io.ktor:ktor-server-core:$ktorVersion")

View File

@ -25,6 +25,7 @@ import kotlinx.serialization.json.Json
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 wsSongListRoutes
import java.math.BigInteger import java.math.BigInteger
import java.security.SecureRandom import java.security.SecureRandom
import java.time.Duration import java.time.Duration
@ -191,7 +192,6 @@ 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)
call.sessions.set( call.sessions.set(
UserSession( UserSession(
session.state, session.state,
@ -199,7 +199,6 @@ val server = embeddedServer(Netty, port = 8080, ) {
listOf() listOf()
) )
) )
user?.let { UserService.setRefreshToken(it, tokenResponse.content.accessToken, tokenResponse.content.refreshToken ?: "") }
call.respondRedirect(getFrontendURL("")) call.respondRedirect(getFrontendURL(""))
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@ -1,54 +1,115 @@
package space.mori.chzzk_bot.webserver.routes
import io.ktor.client.plugins.websocket.WebSocketException
import io.ktor.server.application.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.sessions.* import io.ktor.server.sessions.*
import io.ktor.server.websocket.* import io.ktor.server.websocket.*
import io.ktor.util.logging.Logger
import io.ktor.websocket.* import io.ktor.websocket.*
import kotlinx.coroutines.* import io.ktor.websocket.Frame.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.launch
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 org.koin.java.KoinJavaComponent.inject
import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.common.events.* import space.mori.chzzk_bot.common.events.*
import space.mori.chzzk_bot.common.models.SongList import space.mori.chzzk_bot.common.models.SongList
import space.mori.chzzk_bot.common.models.SongLists.uid
import space.mori.chzzk_bot.common.models.User import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.services.SongConfigService
import space.mori.chzzk_bot.common.services.SongListService import space.mori.chzzk_bot.common.services.SongListService
import space.mori.chzzk_bot.common.services.UserService import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.common.utils.YoutubeVideo import space.mori.chzzk_bot.common.utils.YoutubeVideo
import space.mori.chzzk_bot.common.utils.getYoutubeVideo import space.mori.chzzk_bot.common.utils.getYoutubeVideo
import space.mori.chzzk_bot.webserver.UserSession import space.mori.chzzk_bot.webserver.UserSession
import space.mori.chzzk_bot.webserver.routes.SongResponse
import space.mori.chzzk_bot.webserver.routes.toSerializable
import space.mori.chzzk_bot.webserver.utils.CurrentSong import space.mori.chzzk_bot.webserver.utils.CurrentSong
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
fun Routing.wsSongListRoutes() { fun Routing.wsSongListRoutes() {
val sessions = ConcurrentHashMap<String, WebSocketServerSession>()
val status = ConcurrentHashMap<String, SongType>()
val logger = LoggerFactory.getLogger("WSSongListRoutes") val logger = LoggerFactory.getLogger("WSSongListRoutes")
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java) val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
val songListScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// Manage all active sessions fun addSession(uid: String, session: WebSocketServerSession) {
val sessionHandlers = ConcurrentHashMap<String, SessionHandler>() if (sessions[uid] != null) {
CoroutineScope(Dispatchers.Default).launch {
// Handle application shutdown sessions[uid]?.close(
environment.monitor.subscribe(ApplicationStopped) { CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Duplicated sessions.")
sessionHandlers.values.forEach { )
songListScope.launch {
it.close(CloseReason(CloseReason.Codes.NORMAL, "Server shutting down"))
} }
} }
sessions[uid] = session
}
fun removeSession(uid: String) {
sessions.remove(uid)
}
suspend fun waitForAck(ws: WebSocketServerSession, expectedType: Int): Boolean {
val timeout = 5000L // 5 seconds timeout
val startTime = System.currentTimeMillis()
while (System.currentTimeMillis() - startTime < timeout) {
for (frame in ws.incoming) {
if (frame is Text) {
val message = frame.readText()
if(message == "ping") {
return true
}
val data = Json.decodeFromString<SongRequest>(message)
if (data.type == SongType.ACK.value) {
return true // ACK received
}
}
}
delay(100) // Check every 100 ms
}
return false // Timeout
}
suspend fun sendWithRetry(uid: String, res: SongResponse, maxRetries: Int = 5, delayMillis: Long = 3000L) {
var attempt = 0
var sentSuccessfully = false
while (attempt < maxRetries && !sentSuccessfully) {
val ws = sessions[uid]
try {
if(ws == null) {
delay(delayMillis)
continue
}
// Attempt to send the message
ws.sendSerialized(res)
logger.debug("Message sent successfully to $uid on attempt $attempt")
// Wait for ACK
val ackReceived = waitForAck(ws, res.type)
if (ackReceived == true) {
sentSuccessfully = true
} else {
logger.warn("ACK not received for message to $uid on attempt $attempt.")
}
} catch (e: Exception) {
attempt++
logger.warn("Failed to send message to $uid on attempt $attempt. Retrying in $delayMillis ms.")
logger.warn(e.stackTraceToString())
} finally {
// Wait before retrying
delay(delayMillis)
}
}
if (!sentSuccessfully) {
logger.error("Failed to send message to $uid after $maxRetries attempts.")
}
} }
// WebSocket endpoint
webSocket("/songlist") { webSocket("/songlist") {
val session = call.sessions.get<UserSession>() val session = call.sessions.get<UserSession>()
val user: User? = session?.id?.let { UserService.getUser(it) } val user = session?.id?.let { UserService.getUser(it) }
if (user == null) { if (user == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid SID")) close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid SID"))
return@webSocket return@webSocket
@ -56,291 +117,175 @@ fun Routing.wsSongListRoutes() {
val uid = user.token val uid = user.token
// Ensure only one session per user addSession(uid, this)
sessionHandlers[uid]?.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Another session is already active."))
val handler = SessionHandler(uid, this, dispatcher, logger) if (status[uid] == SongType.STREAM_OFF) {
sessionHandlers[uid] = handler CoroutineScope(Dispatchers.Default).launch {
sendSerialized(SongResponse(
SongType.STREAM_OFF.value,
uid,
null,
null,
null,
))
}
removeSession(uid)
}
// Initialize session
handler.initialize()
// Listen for incoming frames
try { try {
for (frame in incoming) { for (frame in incoming) {
when (frame) { when (frame) {
is Frame.Text -> handler.handleTextFrame(frame.readText()) is Text -> {
is Frame.Ping -> send(Frame.Pong(frame.data)) if (frame.readText().trim() == "ping") {
else -> Unit send("pong")
} else {
val data = frame.readText().let { Json.decodeFromString<SongRequest>(it) }
// Handle song requests
handleSongRequest(data, user, dispatcher, logger)
}
}
is Ping -> send(Pong(frame.data))
else -> ""
} }
} }
} catch (e: ClosedReceiveChannelException) { } catch (e: ClosedReceiveChannelException) {
logger.info("Session closed: ${e.message}") logger.error("Error in WebSocket: ${e.message}")
} catch (e: IOException) {
logger.error("IO error: ${e.message}")
} catch (e: Exception) {
logger.error("Unexpected error: ${e.message}")
} finally { } finally {
sessionHandlers.remove(uid) removeSession(uid)
handler.close(CloseReason(CloseReason.Codes.NORMAL, "Session ended"))
} }
} }
// Subscribe to SongEvents dispatcher.subscribe(SongEvent::class) {
dispatcher.subscribe(SongEvent::class) { event -> logger.debug("SongEvent: {} / {} {}", it.uid, it.type, it.current?.name)
val handler = sessionHandlers[event.uid] CoroutineScope(Dispatchers.Default).launch {
songListScope.launch { val user = UserService.getUser(it.uid)
handler?.sendSongResponse(event) if (user != null) {
sendWithRetry(
user.token, SongResponse(
it.type.value,
it.uid,
it.reqUid,
it.current?.toSerializable(),
it.next?.toSerializable(),
it.delUrl
)
)
}
} }
} }
// Subscribe to TimerEvents dispatcher.subscribe(TimerEvent::class) {
dispatcher.subscribe(TimerEvent::class) { event -> if (it.type == TimerType.STREAM_OFF) {
if (event.type == TimerType.STREAM_OFF) { CoroutineScope(Dispatchers.Default).launch {
val handler = sessionHandlers[event.uid] val user = UserService.getUser(it.uid)
songListScope.launch { if (user != null) {
handler?.sendTimerOff() sendWithRetry(
user.token, SongResponse(
it.type.value,
it.uid,
null,
null,
null,
)
)
}
} }
} }
} }
} }
class SessionHandler( suspend fun handleSongRequest(
private val uid: String, data: SongRequest,
private val session: WebSocketServerSession, user: User,
private val dispatcher: CoroutinesEventBus, dispatcher: CoroutinesEventBus,
private val logger: Logger logger: Logger
) { ) {
private val ackMap = ConcurrentHashMap<String, CompletableDeferred<Boolean>>() if (data.maxQueue != null && data.maxQueue > 0) SongConfigService.updateQueueLimit(user, data.maxQueue)
private val sessionMutex = Mutex() if (data.maxUserLimit != null && data.maxUserLimit > 0) SongConfigService.updatePersonalLimit(user, data.maxUserLimit)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) if (data.isStreamerOnly != null) SongConfigService.updateStreamerOnly(user, data.isStreamerOnly)
if (data.isDisabled != null) SongConfigService.updateDisabled(user, data.isDisabled)
suspend fun initialize() { when (data.type) {
// Send initial status if needed, SongType.ADD.value -> {
// For example, send STREAM_OFF if applicable data.url?.let { url ->
// This can be extended based on your requirements try {
} val youtubeVideo = getYoutubeVideo(url)
if (youtubeVideo != null) {
suspend fun handleTextFrame(text: String) { CoroutineScope(Dispatchers.Default).launch {
if (text.trim() == "ping") { SongListService.saveSong(
session.send("pong") user,
return user.token,
} url,
youtubeVideo.name,
val data = try { youtubeVideo.author,
Json.decodeFromString<SongRequest>(text) youtubeVideo.length,
} catch (e: Exception) { user.username
logger.warn("Failed to decode SongRequest: ${e.message}") )
return dispatcher.post(
} SongEvent(
user.token,
when (data.type) { SongType.ADD,
SongType.ACK.value -> handleAck(data.uid) user.token,
else -> handleSongRequest(data) CurrentSong.getSong(user),
} youtubeVideo
} )
)
private fun handleAck(requestUid: String) { }
ackMap[requestUid]?.complete(true) }
ackMap.remove(requestUid) } catch (e: Exception) {
} logger.debug("SongType.ADD Error: $uid $e")
private fun handleSongRequest(data: SongRequest) {
scope.launch {
SongRequestProcessor.process(data, uid, dispatcher, this@SessionHandler, logger)
}
}
suspend fun sendSongResponse(event: SongEvent) {
val response = SongResponse(
type = event.type.value,
uid = event.uid,
reqUid = event.reqUid,
current = event.current?.toSerializable(),
next = event.next?.toSerializable(),
delUrl = event.delUrl
)
sendWithRetry(response)
}
suspend fun sendTimerOff() {
val response = SongResponse(
type = TimerType.STREAM_OFF.value,
uid = uid,
reqUid = null,
current = null,
next = null,
delUrl = null
)
sendWithRetry(response)
}
private suspend fun sendWithRetry(res: SongResponse, maxRetries: Int = 5, delayMillis: Long = 3000L) {
var attempt = 0
while (attempt < maxRetries) {
try {
session.sendSerialized(res)
val ackDeferred = CompletableDeferred<Boolean>()
ackMap[res.uid] = ackDeferred
val ackReceived = withTimeoutOrNull(5000L) { ackDeferred.await() } ?: false
if (ackReceived) {
logger.debug("ACK received for message to $uid on attempt $attempt.")
return
} else {
logger.warn("ACK not received for message to $uid on attempt $attempt.")
} }
} catch (e: IOException) { }
logger.warn("Failed to send message to $uid on attempt $attempt: ${e.message}") }
if (e is WebSocketException) { SongType.REMOVE.value -> {
close(CloseReason(CloseReason.Codes.PROTOCOL_ERROR, "WebSocket error")) data.url?.let { url ->
return val songs = SongListService.getSong(user)
val exactSong = songs.firstOrNull { it.url == url }
if (exactSong != null) {
SongListService.deleteSong(user, exactSong.uid, exactSong.name)
} }
} catch (e: CancellationException) { dispatcher.post(
throw e SongEvent(
} catch (e: Exception) { user.token,
logger.warn("Unexpected error while sending message to $uid on attempt $attempt: ${e.message}") SongType.REMOVE,
} null,
null,
attempt++ null,
delay(delayMillis) url
} )
)
logger.error("Failed to send message to $uid after $maxRetries attempts.")
}
suspend fun close(reason: CloseReason) {
try {
session.close(reason)
} catch (e: Exception) {
logger.warn("Error closing session: ${e.message}")
}
}
}
object SongRequestProcessor {
private val songMutex = Mutex()
suspend fun process(
data: SongRequest,
uid: String,
dispatcher: CoroutinesEventBus,
handler: SessionHandler,
logger: Logger
) {
val user = UserService.getUser(uid) ?: return
when (data.type) {
SongType.ADD.value -> handleAdd(data, user, dispatcher, handler, logger)
SongType.REMOVE.value -> handleRemove(data, user, dispatcher, logger)
SongType.NEXT.value -> handleNext(user, dispatcher, logger)
else -> {
// Handle other types if necessary
} }
} }
} SongType.NEXT.value -> {
private suspend fun handleAdd(
data: SongRequest,
user: User,
dispatcher: CoroutinesEventBus,
handler: SessionHandler,
logger: Logger
) {
val url = data.url ?: return
val youtubeVideo = getYoutubeVideo(url) ?: run {
logger.warn("Failed to fetch YouTube video for URL: $url")
return
}
songMutex.withLock {
SongListService.saveSong(
user,
user.token,
url,
youtubeVideo.name,
youtubeVideo.author,
youtubeVideo.length,
user.username
)
}
dispatcher.post(
SongEvent(
uid = user.token,
type = SongType.ADD,
reqUid = user.token,
current = CurrentSong.getSong(user),
next = youtubeVideo
)
)
}
private suspend fun handleRemove(
data: SongRequest,
user: User,
dispatcher: CoroutinesEventBus,
logger: Logger
) {
val url = data.url ?: return
songMutex.withLock {
val songs = SongListService.getSong(user)
val exactSong = songs.firstOrNull { it.url == url }
if (exactSong != null) {
SongListService.deleteSong(user, exactSong.uid, exactSong.name)
}
}
dispatcher.post(
SongEvent(
uid = user.token,
type = SongType.REMOVE,
delUrl = url,
reqUid = null,
current = null,
next = null,
)
)
}
private suspend fun handleNext(
user: User,
dispatcher: CoroutinesEventBus,
logger: Logger
) {
var song: SongList? = null
var youtubeVideo: YoutubeVideo? = null
songMutex.withLock {
val songList = SongListService.getSong(user) val songList = SongListService.getSong(user)
var song: SongList? = null
var youtubeVideo: YoutubeVideo? = null
if (songList.isNotEmpty()) { if (songList.isNotEmpty()) {
song = songList[0] song = songList[0]
SongListService.deleteSong(user, song.uid, song.name) SongListService.deleteSong(user, song.uid, song.name)
} }
}
song?.let { song?.let {
youtubeVideo = YoutubeVideo( youtubeVideo = YoutubeVideo(
it.url, song.url,
it.name, song.name,
it.author, song.author,
it.time song.time
)
}
dispatcher.post(
SongEvent(
user.token,
SongType.NEXT,
song?.uid,
youtubeVideo
)
) )
CurrentSong.setSong(user, youtubeVideo)
} }
dispatcher.post(
SongEvent(
uid = user.token,
type = SongType.NEXT,
current = null,
next = youtubeVideo,
reqUid = null,
delUrl = null
)
)
CurrentSong.setSong(user, youtubeVideo)
} }
} }
@ -348,10 +293,10 @@ object SongRequestProcessor {
data class SongRequest( data class SongRequest(
val type: Int, val type: Int,
val uid: String, val uid: String,
val url: String? = null, val url: String?,
val maxQueue: Int? = null, val maxQueue: Int?,
val maxUserLimit: Int? = null, val maxUserLimit: Int?,
val isStreamerOnly: Boolean? = null, val isStreamerOnly: Boolean?,
val remove: Int? = null, val remove: Int?,
val isDisabled: Boolean? = null val isDisabled: Boolean?,
) )

View File

@ -1,20 +1,14 @@
package space.mori.chzzk_bot.webserver.routes package space.mori.chzzk_bot.webserver.routes
import io.ktor.server.application.ApplicationStopped
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.websocket.* import io.ktor.server.websocket.*
import io.ktor.websocket.* import io.ktor.websocket.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.koin.java.KoinJavaComponent.inject import org.koin.java.KoinJavaComponent.inject
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.common.events.* import space.mori.chzzk_bot.common.events.*
@ -23,20 +17,15 @@ import space.mori.chzzk_bot.common.utils.YoutubeVideo
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
val songScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun Routing.wsSongRoutes() { fun Routing.wsSongRoutes() {
environment.monitor.subscribe(ApplicationStopped) {
songScope.cancel()
}
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>() val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
val status = ConcurrentHashMap<String, SongType>() val status = ConcurrentHashMap<String, SongType>()
val logger = LoggerFactory.getLogger("WSSongRoutes") val logger = LoggerFactory.getLogger("WSSongRoutes")
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
val ackMap = ConcurrentHashMap<String, ConcurrentHashMap<WebSocketServerSession, CompletableDeferred<Boolean>>>()
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) {
@ -53,35 +42,27 @@ fun Routing.wsSongRoutes() {
var attempt = 0 var attempt = 0
while (attempt < maxRetries) { while (attempt < maxRetries) {
try { try {
session.sendSerialized(message) session.sendSerialized(message) // 메시지 전송 시도
val ackDeferred = CompletableDeferred<Boolean>() return true // 성공하면 true 반환
ackMap.computeIfAbsent(message.uid) { ConcurrentHashMap() }[session] = ackDeferred
val ackReceived = withTimeoutOrNull(delayMillis) { ackDeferred.await() } ?: false
if (ackReceived) {
ackMap[message.uid]?.remove(session)
return true
} else {
attempt++
logger.warn("ACK not received for message to ${message.uid} on attempt $attempt.")
}
} catch (e: Exception) { } catch (e: Exception) {
attempt++ attempt++
logger.info("Failed to send message on attempt $attempt. Retrying in $delayMillis ms.") logger.info("Failed to send message on attempt $attempt. Retrying in $delayMillis ms.")
e.printStackTrace() e.printStackTrace()
delay(delayMillis) delay(delayMillis) // 재시도 전 대기
} }
} }
return false return false // 재시도 실패 시 false 반환
} }
fun broadcastMessage(userId: String, message: SongResponse) { fun broadcastMessage(userId: String, message: SongResponse) {
val userSessions = sessions[userId] val userSessions = sessions[userId]
userSessions?.forEach { session -> userSessions?.forEach { session ->
songScope.launch { CoroutineScope(Dispatchers.Default).launch {
val success = sendWithRetry(session, message) val success = sendWithRetry(session, message)
if (!success) { if (!success) {
logger.info("Removing session for user $userId due to repeated failures.") println("Removing session for user $userId due to repeated failures.")
removeSession(userId, session) userSessions.remove(session) // 실패 시 세션 제거
} }
} }
} }
@ -90,13 +71,19 @@ fun Routing.wsSongRoutes() {
webSocket("/song/{uid}") { webSocket("/song/{uid}") {
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) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID")) close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID"))
return@webSocket return@webSocket
} }
if (user == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID"))
return@webSocket
}
addSession(uid, this) addSession(uid, this)
if(status[uid] == SongType.STREAM_OFF) { if(status[uid] == SongType.STREAM_OFF) {
songScope.launch { CoroutineScope(Dispatchers.Default).launch {
sendSerialized(SongResponse( sendSerialized(SongResponse(
SongType.STREAM_OFF.value, SongType.STREAM_OFF.value,
uid, uid,
@ -106,36 +93,33 @@ fun Routing.wsSongRoutes() {
)) ))
} }
} }
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() if(frame.readText().trim() == "ping") {
if(text == "ping") {
send("pong") send("pong")
} else {
val data = Json.decodeFromString<SongRequest>(text)
if (data.type == SongType.ACK.value) {
ackMap[data.uid]?.get(this)?.complete(true)
ackMap[data.uid]?.remove(this)
}
} }
} }
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("Error in WebSocket: ${e.message}")
} finally { } finally {
removeSession(uid, this) removeSession(uid, this)
ackMap[uid]?.remove(this)
} }
} }
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
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 { CoroutineScope(Dispatchers.Default).launch {
broadcastMessage(it.uid, SongResponse( broadcastMessage(it.uid, SongResponse(
it.type.value, it.type.value,
it.uid, it.uid,
@ -148,7 +132,7 @@ fun Routing.wsSongRoutes() {
} }
dispatcher.subscribe(TimerEvent::class) { dispatcher.subscribe(TimerEvent::class) {
if(it.type == TimerType.STREAM_OFF) { if(it.type == TimerType.STREAM_OFF) {
songScope.launch { CoroutineScope(Dispatchers.Default).launch {
broadcastMessage(it.uid, SongResponse( broadcastMessage(it.uid, SongResponse(
it.type.value, it.type.value,
it.uid, it.uid,
@ -160,6 +144,7 @@ fun Routing.wsSongRoutes() {
} }
} }
} }
@Serializable @Serializable
data class SerializableYoutubeVideo( data class SerializableYoutubeVideo(
val url: String, val url: String,
@ -167,7 +152,9 @@ data class SerializableYoutubeVideo(
val author: String, val author: String,
val length: Int val length: Int
) )
fun YoutubeVideo.toSerializable() = SerializableYoutubeVideo(url, name, author, length) fun YoutubeVideo.toSerializable() = SerializableYoutubeVideo(url, name, author, length)
@Serializable @Serializable
data class SongResponse( data class SongResponse(
val type: Int, val type: Int,

View File

@ -1,20 +1,13 @@
package space.mori.chzzk_bot.webserver.routes package space.mori.chzzk_bot.webserver.routes
import io.ktor.server.application.ApplicationStopped
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.websocket.* import io.ktor.server.websocket.*
import io.ktor.websocket.* import io.ktor.websocket.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.koin.java.KoinJavaComponent.inject import org.koin.java.KoinJavaComponent.inject
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.common.events.* import space.mori.chzzk_bot.common.events.*
@ -24,19 +17,14 @@ import space.mori.chzzk_bot.webserver.utils.CurrentTimer
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
val timerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun Routing.wsTimerRoutes() { fun Routing.wsTimerRoutes() {
environment.monitor.subscribe(ApplicationStopped) {
timerScope.cancel()
}
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>() val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
val logger = LoggerFactory.getLogger("WSTimerRoutes") val logger = LoggerFactory.getLogger("WSTimerRoutes")
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
val ackMap = ConcurrentHashMap<String, ConcurrentHashMap<WebSocketServerSession, CompletableDeferred<Boolean>>>()
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) {
@ -44,132 +32,82 @@ fun Routing.wsTimerRoutes() {
} }
} }
suspend fun sendWithRetry(
session: WebSocketServerSession,
message: TimerResponse,
maxRetries: Int = 3,
delayMillis: Long = 2000L
): Boolean {
var attempt = 0
while (attempt < maxRetries) {
try {
session.sendSerialized(message)
val ackDeferred = CompletableDeferred<Boolean>()
ackMap.computeIfAbsent(message.uid) { ConcurrentHashMap() }[session] = ackDeferred
val ackReceived = withTimeoutOrNull(delayMillis) { ackDeferred.await() } ?: false
if (ackReceived) {
ackMap[message.uid]?.remove(session)
return true
} else {
attempt++
logger.warn("ACK not received for message to ${message.uid} on attempt $attempt.")
}
} catch (e: Exception) {
attempt++
logger.info("Failed to send message on attempt $attempt. Retrying in $delayMillis ms.")
e.printStackTrace()
delay(delayMillis)
}
}
return false
}
fun broadcastMessage(uid: String, message: TimerResponse) {
val userSessions = sessions[uid]
userSessions?.forEach { session ->
timerScope.launch {
val success = sendWithRetry(session, message.copy(uid = uid))
if (!success) {
logger.info("Removing session for user $uid due to repeated failures.")
removeSession(uid, session)
}
}
}
}
webSocket("/timer/{uid}") { webSocket("/timer/{uid}") {
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) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID")) close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID"))
return@webSocket return@webSocket
} }
if (user == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID"))
return@webSocket
}
addSession(uid, this) addSession(uid, this)
val timer = CurrentTimer.getTimer(user) val timer = CurrentTimer.getTimer(user)
if (timer?.type == TimerType.STREAM_OFF) { if(timer?.type == TimerType.STREAM_OFF) {
timerScope.launch { CoroutineScope(Dispatchers.Default).launch {
sendSerialized(TimerResponse(TimerType.STREAM_OFF.value, null, uid)) sendSerialized(TimerResponse(TimerType.STREAM_OFF.value, null))
} }
} else { } else {
timerScope.launch { CoroutineScope(Dispatchers.Default).launch {
if(timer?.type == TimerType.STREAM_OFF) { if (timer == null) {
sendSerialized(TimerResponse(TimerType.STREAM_OFF.value, null, uid)) sendSerialized(
TimerResponse(
TimerConfigService.getConfig(user)?.option ?: TimerType.REMOVE.value,
null
)
)
} else { } else {
if (timer == null) { sendSerialized(
sendSerialized( TimerResponse(
TimerResponse( timer.type.value,
TimerConfigService.getConfig(user)?.option ?: TimerType.REMOVE.value, timer.time
null,
uid
)
) )
} else { )
sendSerialized(
TimerResponse(
timer.type.value,
timer.time,
uid
)
)
}
} }
} }
} }
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() if(frame.readText().trim() == "ping") {
if(text == "ping") {
send("pong") send("pong")
} else {
val data = Json.decodeFromString<TimerRequest>(text)
if (data.type == TimerType.ACK.value) {
ackMap[data.uid]?.get(this)?.complete(true)
ackMap[data.uid]?.remove(this)
}
} }
} }
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("Error in WebSocket: ${e.message}")
} finally { } finally {
removeSession(uid, this) removeSession(uid, this)
ackMap[uid]?.remove(this)
} }
} }
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
dispatcher.subscribe(TimerEvent::class) { dispatcher.subscribe(TimerEvent::class) {
logger.debug("TimerEvent: {} / {}", it.uid, it.type) logger.debug("TimerEvent: {} / {}", it.uid, it.type)
val user = UserService.getUser(it.uid) val user = UserService.getUser(it.uid)
CurrentTimer.setTimer(user!!, it) CurrentTimer.setTimer(user!!, it)
timerScope.launch { CoroutineScope(Dispatchers.Default).launch {
broadcastMessage(it.uid, TimerResponse(it.type.value, it.time ?: "", it.uid)) sessions[it.uid]?.forEach { ws ->
ws.sendSerialized(TimerResponse(it.type.value, it.time ?: ""))
}
} }
} }
} }
@Serializable @Serializable
data class TimerResponse( data class TimerResponse(
val type: Int, val type: Int,
val time: String?, val time: String?
val uid: String
)
@Serializable
data class TimerRequest(
val type: Int,
val uid: String
) )