From 0a4e8193bb34d1ba00483fe9c269367807f65c32 Mon Sep 17 00:00:00 2001 From: dalbodeule <11470513+dalbodeule@users.noreply.github.com> Date: Mon, 5 Aug 2024 12:35:11 +0900 Subject: [PATCH] add WSSongListRoutes.kt - add Websocket backend. - add session start command. - some improve logics. --- .idea/.gitignore | 3 +- .../chzzk_bot/chatbot/chzzk/MessageHandler.kt | 26 ++- .../chzzk_bot/common/models/SongConfig.kt | 16 +- .../common/services/SongConfigService.kt | 87 +++++++++ .../mori/chzzk_bot/common/utils/ChzzkApis.kt | 2 +- .../chzzk_bot/common/utils/getRandomString.kt | 9 + .../mori/chzzk_bot/common/utils/getYoutube.kt | 3 - .../space/mori/chzzk_bot/webserver/Main.kt | 6 +- .../webserver/routes/WSSongListRoutes.kt | 167 ++++++++++++++++++ .../webserver/routes/WSSongRoutes.kt | 20 ++- 10 files changed, 323 insertions(+), 16 deletions(-) create mode 100644 common/src/main/kotlin/space/mori/chzzk_bot/common/services/SongConfigService.kt create mode 100644 common/src/main/kotlin/space/mori/chzzk_bot/common/utils/getRandomString.kt create mode 100644 webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/routes/WSSongListRoutes.kt diff --git a/.idea/.gitignore b/.idea/.gitignore index cd1cdb7..5779792 100644 --- a/.idea/.gitignore +++ b/.idea/.gitignore @@ -6,4 +6,5 @@ # Datasource local storage ignored files /dataSources/ /dataSources.local.xml -discord.xml \ No newline at end of file +discord.xml +inspectionProfiles/Project_Default.xml \ No newline at end of file diff --git a/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/MessageHandler.kt b/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/MessageHandler.kt index e336c6e..ea31ef6 100644 --- a/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/MessageHandler.kt +++ b/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/MessageHandler.kt @@ -4,10 +4,12 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.java.KoinJavaComponent.inject +import space.mori.chzzk_bot.chatbot.discord.Discord.Companion.bot import space.mori.chzzk_bot.common.events.* import space.mori.chzzk_bot.common.models.User import space.mori.chzzk_bot.common.services.* import space.mori.chzzk_bot.common.utils.getFollowDate +import space.mori.chzzk_bot.common.utils.getRandomString import space.mori.chzzk_bot.common.utils.getUptime import space.mori.chzzk_bot.common.utils.getYoutubeVideo import xyz.r2turntrue.chzzk4j.chat.ChatMessage @@ -15,6 +17,7 @@ import xyz.r2turntrue.chzzk4j.chat.ChzzkChat import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit +import java.util.UUID class MessageHandler( @@ -57,7 +60,8 @@ class MessageHandler( "!명령어수정" to this::manageUpdateCommand, "!시간" to this::timerCommand, "!노래추가" to this::songAddCommand, - "!노래목록" to this::songListCommand + "!노래목록" to this::songListCommand, + "!노래시작" to this::songStartCommand ) manageCommands.forEach { (commandName, command) -> @@ -248,6 +252,26 @@ class MessageHandler( listener.sendChat("리스트는 여기입니다. https://nabot.mori.space/songs/${user.token}") } + private fun songStartCommand(msg: ChatMessage, user: User) { + if (msg.profile?.userRoleCode == "common_user") { + listener.sendChat("매니저만 이 명령어를 사용할 수 있습니다.") + return + } + + val session = "${UUID.randomUUID()}${UUID.randomUUID()}".replace("-", "") + val password = getRandomString(8) + + SongConfigService.updateSession(user, session, password) + + bot.getUserById(user.token)?.let { it -> + val channel = it.openPrivateChannel() + channel.onSuccess { privateChannel -> + privateChannel.sendMessage("여기로 접속해주세요! https://nabot,mori.space/songlist/${session}.\n인증번호는 ||$password|| 입니다.").queue() + } + } + + } + internal fun handle(msg: ChatMessage, user: User) { if(msg.userId == ChzzkHandler.botUid) return diff --git a/common/src/main/kotlin/space/mori/chzzk_bot/common/models/SongConfig.kt b/common/src/main/kotlin/space/mori/chzzk_bot/common/models/SongConfig.kt index 58a89d5..ff51d80 100644 --- a/common/src/main/kotlin/space/mori/chzzk_bot/common/models/SongConfig.kt +++ b/common/src/main/kotlin/space/mori/chzzk_bot/common/models/SongConfig.kt @@ -8,11 +8,19 @@ import org.jetbrains.exposed.sql.ReferenceOption object SongConfigs: IntIdTable("song_config") { val user = reference("user", Users, onDelete = ReferenceOption.CASCADE) - val option = integer("option") + val token = varchar("token", 64).nullable() + val password = varchar("password", 8).nullable() + val streamerOnly = bool("streamer_only").default(false) + val queueLimit = integer("queue_limit").default(50) + val personalLimit = integer("personal_limit").default(5) } class SongConfig(id: EntityID) : IntEntity(id) { - companion object : IntEntityClass(TimerConfigs) + companion object : IntEntityClass(SongConfigs) - var user by User referencedOn TimerConfigs.user - var option by TimerConfigs.option + var user by User referencedOn SongConfigs.user + var token by SongConfigs.token + var password by SongConfigs.password + var streamerOnly by SongConfigs.streamerOnly + var queueLimit by SongConfigs.queueLimit + var personalLimit by SongConfigs.personalLimit } \ No newline at end of file diff --git a/common/src/main/kotlin/space/mori/chzzk_bot/common/services/SongConfigService.kt b/common/src/main/kotlin/space/mori/chzzk_bot/common/services/SongConfigService.kt new file mode 100644 index 0000000..a88b23d --- /dev/null +++ b/common/src/main/kotlin/space/mori/chzzk_bot/common/services/SongConfigService.kt @@ -0,0 +1,87 @@ +package space.mori.chzzk_bot.common.services + +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.transactions.transaction +import space.mori.chzzk_bot.common.models.SongConfig +import space.mori.chzzk_bot.common.models.SongConfigs +import space.mori.chzzk_bot.common.models.User + +object SongConfigService { + private fun initConfig(user: User): SongConfig { + return transaction { + SongConfig.new { + this.user = user + } + } + } + + fun getConfig(user: User): SongConfig { + return transaction { + var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull() + if (songConfig == null) { + songConfig = initConfig(user) + } + songConfig + } + + } + + fun getConfig(token: String): SongConfig? { + return transaction { + SongConfig.find(SongConfigs.token eq token).firstOrNull() + } + } + fun getUserByToken(token: String): User? { + return transaction { + val songConfig = SongConfig.find(SongConfigs.token eq token).firstOrNull() + if(songConfig == null) null + else UserService.getUser(songConfig.user.discord) + } + } + + fun updatePersonalLimit(user: User, limit: Int): SongConfig { + return transaction { + var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull() + if (songConfig == null) { + songConfig = initConfig(user) + } + songConfig.personalLimit = limit + songConfig + } + } + fun updateQueueLimit(user: User, limit: Int): SongConfig { + return transaction { + var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull() + if (songConfig == null) { + songConfig = initConfig(user) + } + songConfig.queueLimit = limit + songConfig + } + } + + fun updateSession(user: User, token: String?, password: String?): SongConfig { + return transaction { + var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull() + if (songConfig == null) { + songConfig = initConfig(user) + } + songConfig.token = token + songConfig.password = password + + songConfig + } + } + + fun updateStreamerOnly(user: User, config: Boolean): SongConfig { + return transaction { + var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull() + if (songConfig == null) { + songConfig = initConfig(user) + } + songConfig.streamerOnly = config + + songConfig + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/space/mori/chzzk_bot/common/utils/ChzzkApis.kt b/common/src/main/kotlin/space/mori/chzzk_bot/common/utils/ChzzkApis.kt index fdbf636..3a9f366 100644 --- a/common/src/main/kotlin/space/mori/chzzk_bot/common/utils/ChzzkApis.kt +++ b/common/src/main/kotlin/space/mori/chzzk_bot/common/utils/ChzzkApis.kt @@ -129,7 +129,7 @@ fun getFollowDate(chatID: String, userId: String) : IData { } fun getStreamInfo(userId: String) : IData { - val url = "https://api.chzzk.naver.com/service/v2/channels/${userId}/live-detail" + val url = "https://api.chzzk.naver.com/service/v3/channels/${userId}/live-detail" val request = Request.Builder() .url(url) .build() diff --git a/common/src/main/kotlin/space/mori/chzzk_bot/common/utils/getRandomString.kt b/common/src/main/kotlin/space/mori/chzzk_bot/common/utils/getRandomString.kt new file mode 100644 index 0000000..3c1432c --- /dev/null +++ b/common/src/main/kotlin/space/mori/chzzk_bot/common/utils/getRandomString.kt @@ -0,0 +1,9 @@ +package space.mori.chzzk_bot.common.utils + +fun getRandomString(length: Int): String { + val charPool = ('a'..'z') + ('0'..'9') + return (1..length) + .map { kotlin.random.Random.nextInt(0, charPool.size) } + .map(charPool::get) + .joinToString("") +} \ No newline at end of file diff --git a/common/src/main/kotlin/space/mori/chzzk_bot/common/utils/getYoutube.kt b/common/src/main/kotlin/space/mori/chzzk_bot/common/utils/getYoutube.kt index 8464e2f..ab5d303 100644 --- a/common/src/main/kotlin/space/mori/chzzk_bot/common/utils/getYoutube.kt +++ b/common/src/main/kotlin/space/mori/chzzk_bot/common/utils/getYoutube.kt @@ -28,7 +28,6 @@ fun getYoutubeVideoId(url: String): String? { } fun parseDuration(duration: String): Int { - println(duration) val matchResult = durationRegex.find(duration) val (hours, minutes, seconds) = matchResult?.destructured ?: return 0 @@ -67,8 +66,6 @@ fun getYoutubeVideo(url: String): YoutubeVideo? { if (items == null || items.size() == 0) return null - println(json) - val item = items[0].asJsonObject val snippet = item.getAsJsonObject("snippet") val contentDetail = item.getAsJsonObject("contentDetails") diff --git a/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/Main.kt b/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/Main.kt index e1885ec..4d56bda 100644 --- a/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/Main.kt +++ b/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/Main.kt @@ -12,10 +12,7 @@ import io.ktor.server.plugins.swagger.* import io.ktor.server.routing.* import io.ktor.server.websocket.* import kotlinx.serialization.json.Json -import space.mori.chzzk_bot.webserver.routes.apiRoutes -import space.mori.chzzk_bot.webserver.routes.apiSongRoutes -import space.mori.chzzk_bot.webserver.routes.wsSongRoutes -import space.mori.chzzk_bot.webserver.routes.wsTimerRoutes +import space.mori.chzzk_bot.webserver.routes.* import java.time.Duration val server = embeddedServer(Netty, port = 8080) { @@ -42,6 +39,7 @@ val server = embeddedServer(Netty, port = 8080) { apiSongRoutes() wsTimerRoutes() wsSongRoutes() + wsSongListRoutes() swaggerUI("swagger-ui/index.html", "openapi/documentation.yaml") { options { version = "1.2.0" diff --git a/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/routes/WSSongListRoutes.kt b/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/routes/WSSongListRoutes.kt new file mode 100644 index 0000000..7442893 --- /dev/null +++ b/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/routes/WSSongListRoutes.kt @@ -0,0 +1,167 @@ +package space.mori.chzzk_bot.webserver.routes + +import io.ktor.server.routing.* +import io.ktor.server.websocket.* +import io.ktor.websocket.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import org.koin.java.KoinJavaComponent.inject +import org.slf4j.LoggerFactory +import space.mori.chzzk_bot.common.events.* +import space.mori.chzzk_bot.common.services.SongConfigService +import space.mori.chzzk_bot.common.services.SongListService +import space.mori.chzzk_bot.common.utils.getYoutubeVideo +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedQueue + +fun Routing.wsSongListRoutes() { + val sessions = ConcurrentHashMap>() + val status = ConcurrentHashMap() + val logger = LoggerFactory.getLogger(this.javaClass.name) + + val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java) + + fun addSession(uid: String, session: WebSocketServerSession) { + sessions.computeIfAbsent(uid) { ConcurrentLinkedQueue() }.add(session) + } + + fun removeSession(uid: String, session: WebSocketServerSession) { + sessions[uid]?.remove(session) + if(sessions[uid]?.isEmpty() == true) { + sessions.remove(uid) + } + } + + webSocket("/songlist/{sid}") { + val sid = call.parameters["sid"] + val pw = call.request.headers["X-Auth-Token"] + val session = sid?.let { SongConfigService.getConfig(it) } + val user = sid?.let {SongConfigService.getUserByToken(sid) } + if (sid == null) { + close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid SID")) + return@webSocket + } + if (user == null || session == null || session.password != pw) { + close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid SID")) + return@webSocket + } + + addSession(sid, this) + + if(status[sid] == SongType.STREAM_OFF) { + CoroutineScope(Dispatchers.Default).launch { + sendSerialized(SongResponse( + SongType.STREAM_OFF.value, + user.token, + null, + null, + null, + null + )) + } + } + + try { + for (frame in incoming) { + when(frame) { + is Frame.Text -> { + val data = receiveDeserialized() + + if(data.maxQueue != null && data.maxQueue > 0) SongConfigService.updateQueueLimit(user, data.maxQueue) + if(data.maxUserLimit != null && data.maxUserLimit > 0) SongConfigService.updatePersonalLimit(user, data.maxUserLimit) + if(data.isStreamerOnly != null) SongConfigService.updateStreamerOnly(user, data.isStreamerOnly) + + if(data.url != null) { + val youtubeVideo = getYoutubeVideo(data.url) + + dispatcher.post( + SongEvent( + user.token, + SongType.ADD, + user.token, + user.username, + youtubeVideo?.name, + youtubeVideo?.author, + youtubeVideo?.length + ) + ) + } + if(data.remove != null && data.remove > 0) { + val songs = SongListService.getSong(user) + if(songs.size < data.remove) { + val song = songs[data.remove] + SongListService.deleteSong(user, song.uid, song.name) + + dispatcher.post( + SongEvent( + user.token, + SongType.REMOVE, + user.token, + user.username, + song.name, + song.author, + 0 + ) + ) + } + } + } + is Frame.Ping -> send(Frame.Pong(frame.data)) + else -> { + + } + } + } + } catch(e: ClosedReceiveChannelException) { + logger.error("Error in WebSocket: ${e.message}") + } finally { + removeSession(sid, this) + } + } + + dispatcher.subscribe(SongEvent::class) { + logger.debug("SongEvent: {} / {} {}", it.uid, it.type, it.name) + CoroutineScope(Dispatchers.Default).launch { + sessions[it.uid]?.forEach { ws -> + ws.sendSerialized(SongResponse( + it.type.value, + it.uid, + it.reqUid, + it.name, + it.author, + it.time + )) + } + } + } + dispatcher.subscribe(TimerEvent::class) { + if(it.type == TimerType.STREAM_OFF) { + CoroutineScope(Dispatchers.Default).launch { + sessions[it.uid]?.forEach { ws -> + ws.sendSerialized(SongResponse( + it.type.value, + it.uid, + null, + null, + null, + null, + )) + } + } + } + } +} + +@Serializable +data class SongRequest( + val type: Int, + val uid: String, + val url: String?, + val maxQueue: Int?, + val maxUserLimit: Int?, + val isStreamerOnly: Boolean?, + val remove: Int? +) \ No newline at end of file diff --git a/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/routes/WSSongRoutes.kt b/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/routes/WSSongRoutes.kt index 621f9b3..25b301e 100644 --- a/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/routes/WSSongRoutes.kt +++ b/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/routes/WSSongRoutes.kt @@ -17,7 +17,7 @@ import java.util.concurrent.ConcurrentLinkedQueue fun Routing.wsSongRoutes() { val sessions = ConcurrentHashMap>() - val status = ConcurrentHashMap() + val status = ConcurrentHashMap() val logger = LoggerFactory.getLogger(this.javaClass.name) fun addSession(uid: String, session: WebSocketServerSession) { @@ -45,7 +45,7 @@ fun Routing.wsSongRoutes() { addSession(uid, this) - if(status[uid] == TimerType.STREAM_OFF) { + if(status[uid] == SongType.STREAM_OFF) { CoroutineScope(Dispatchers.Default).launch { sendSerialized(SongResponse( SongType.STREAM_OFF.value, @@ -94,6 +94,22 @@ fun Routing.wsSongRoutes() { } } } + dispatcher.subscribe(TimerEvent::class) { + if(it.type == TimerType.STREAM_OFF) { + CoroutineScope(Dispatchers.Default).launch { + sessions[it.uid]?.forEach { ws -> + ws.sendSerialized(SongResponse( + it.type.value, + it.uid, + null, + null, + null, + null, + )) + } + } + } + } } @Serializable