From a9ee40e936ecbdfe71222d48f96f667e1771fbd0 Mon Sep 17 00:00:00 2001 From: dalbodeule <11470513+dalbodeule@users.noreply.github.com> Date: Tue, 30 Jul 2024 22:40:07 +0900 Subject: [PATCH] add WebSocket timers - EventDispatcher, TimerEvent add. --- .../chzzk_bot/chatbot/chzzk/ChzzkHandler.kt | 14 ++-- .../chzzk_bot/chatbot/chzzk/MessageHandler.kt | 57 ++++++++++++-- .../mori/chzzk_bot/common/events/EventBase.kt | 19 +++++ .../chzzk_bot/common/events/TimerEvents.kt | 11 +++ .../space/mori/chzzk_bot/webserver/Main.kt | 13 +++- .../webserver/routes/WSTimerRoutes.kt | 76 +++++++++++++++++++ 6 files changed, 179 insertions(+), 11 deletions(-) create mode 100644 common/src/main/kotlin/space/mori/chzzk_bot/common/events/EventBase.kt create mode 100644 common/src/main/kotlin/space/mori/chzzk_bot/common/events/TimerEvents.kt create mode 100644 webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/routes/WSTimerRoutes.kt diff --git a/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/ChzzkHandler.kt b/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/ChzzkHandler.kt index 40e6a59..4b996f9 100644 --- a/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/ChzzkHandler.kt +++ b/chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/ChzzkHandler.kt @@ -12,6 +12,7 @@ import xyz.r2turntrue.chzzk4j.chat.ChzzkChat import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel import java.lang.Exception import java.net.SocketTimeoutException +import java.time.LocalDateTime object ChzzkHandler { private val handlers = mutableListOf() @@ -19,7 +20,7 @@ object ChzzkHandler { @Volatile private var running: Boolean = false fun addUser(chzzkChannel: ChzzkChannel, user: User) { - handlers.add(UserHandler(chzzkChannel, logger, user)) + handlers.add(UserHandler(chzzkChannel, logger, user, streamStartTime = null)) } fun enable() { @@ -82,18 +83,19 @@ object ChzzkHandler { class UserHandler( val channel: ChzzkChannel, - private val logger: Logger, + val logger: Logger, private var user: User, - private var _isActive: Boolean = false + private var _isActive: Boolean = false, + var streamStartTime: LocalDateTime?, ) { private lateinit var messageHandler: MessageHandler - private var listener: ChzzkChat = chzzk.chat(channel.channelId) + var listener: ChzzkChat = chzzk.chat(channel.channelId) .withAutoReconnect(true) .withChatListener(object : ChatEventListener { override fun onConnect(chat: ChzzkChat, isReconnecting: Boolean) { logger.info("ChzzkChat connected. ${channel.channelName} - ${channel.channelId} / reconnected: $isReconnecting") - messageHandler = MessageHandler(channel, logger, chat) + messageHandler = MessageHandler(this@UserHandler) } override fun onError(ex: Exception) { @@ -137,11 +139,13 @@ class UserHandler( listener.connectBlocking() Discord.sendDiscord(user, status) + streamStartTime = LocalDateTime.now() listener.sendChat("${user.username} 님의 방송이 감지되었습니다.") } else { logger.info("${user.username} is offline.") + streamStartTime = null listener.closeAsync() } } 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 dac4335..0e830cb 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 @@ -1,22 +1,21 @@ package space.mori.chzzk_bot.chatbot.chzzk -import org.slf4j.Logger +import space.mori.chzzk_bot.common.events.EventDispatcher +import space.mori.chzzk_bot.common.events.TimerEvent +import space.mori.chzzk_bot.common.events.TimerType import space.mori.chzzk_bot.common.models.User import space.mori.chzzk_bot.common.services.CommandService import space.mori.chzzk_bot.common.services.CounterService import space.mori.chzzk_bot.common.services.UserService import xyz.r2turntrue.chzzk4j.chat.ChatMessage import xyz.r2turntrue.chzzk4j.chat.ChzzkChat -import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit class MessageHandler( - private val channel: ChzzkChannel, - private val logger: Logger, - private val listener: ChzzkChat + private val handler: UserHandler ) { private val commands = mutableMapOf Unit>() @@ -27,6 +26,10 @@ class MessageHandler( private val followPattern = Regex("") private val daysPattern = """""".toRegex() + private val channel = handler.channel + private val logger = handler.logger + private val listener = handler.listener + init { reloadCommand() } @@ -109,6 +112,50 @@ class MessageHandler( listener.sendChat("명령어 '$command' 삭제되었습니다.") } + private suspend fun timerCommand(msg: ChatMessage, user: User) { + if (msg.profile?.userRoleCode == "common_user") { + listener.sendChat("매니저만 이 명령어를 사용할 수 있습니다.") + return + } + + val parts = msg.content.split("/", limit = 2) + if (parts.size < 2) { + listener.sendChat("타이머 명령어 형식을 잘 찾아봐주세요!") + return + } + + val command = parts[1] + val dispatcher = EventDispatcher + when(command) { + "업타임" -> { + val currentTime = LocalDateTime.now() + val streamOnTime = handler.streamStartTime + + val hours = ChronoUnit.HOURS.between(currentTime, streamOnTime) + val minutes = ChronoUnit.MINUTES.between(currentTime.plusHours(hours), streamOnTime) + val seconds = ChronoUnit.MINUTES.between(currentTime.plusHours(hours).plusMinutes(minutes), streamOnTime) + + dispatcher.dispatch(TimerEvent( + user.token, + TimerType.TIMER, + String.format("%02d:%02d:%02d", hours, minutes, seconds) + )) + } + "삭제" -> dispatcher.dispatch(TimerEvent(user.token, TimerType.REMOVE, "")) + else -> { + try { + val time = command.toInt() + val currentTime = LocalDateTime.now() + val timestamp = ChronoUnit.MINUTES.addTo(currentTime, time.toLong()) + + dispatcher.dispatch(TimerEvent(user.token, TimerType.TIMER, timestamp.toString())) + } catch(_: Exception) { + listener.sendChat("!타이머/숫자 형식으로 적어주세요! 단위: 분") + } + } + } + } + internal fun handle(msg: ChatMessage, user: User) { val commandKey = msg.content.split(' ')[0] diff --git a/common/src/main/kotlin/space/mori/chzzk_bot/common/events/EventBase.kt b/common/src/main/kotlin/space/mori/chzzk_bot/common/events/EventBase.kt new file mode 100644 index 0000000..6b602da --- /dev/null +++ b/common/src/main/kotlin/space/mori/chzzk_bot/common/events/EventBase.kt @@ -0,0 +1,19 @@ +package space.mori.chzzk_bot.common.events + +interface Event + +interface EventHandler { + suspend fun handle(event: E) +} + +object EventDispatcher { + private val handlers = mutableMapOf, MutableList>>() + + fun register(eventClass: Class, handler: EventHandler) { + handlers.computeIfAbsent(eventClass) { mutableListOf() }.add(handler) + } + + suspend fun dispatch(event: E) { + handlers[event::class.java]?.forEach { (it as EventHandler).handle(event) } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/space/mori/chzzk_bot/common/events/TimerEvents.kt b/common/src/main/kotlin/space/mori/chzzk_bot/common/events/TimerEvents.kt new file mode 100644 index 0000000..00533f0 --- /dev/null +++ b/common/src/main/kotlin/space/mori/chzzk_bot/common/events/TimerEvents.kt @@ -0,0 +1,11 @@ +package space.mori.chzzk_bot.common.events + +enum class TimerType { + UPTIME, TIMER, REMOVE +} + +class TimerEvent( + val uid: String, + val type: TimerType, + val time: String? +): Event \ No newline at end of file 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 10195e2..28aa444 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 @@ -1,6 +1,7 @@ package space.mori.chzzk_bot.webserver import io.ktor.http.* +import io.ktor.serialization.kotlinx.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.engine.* @@ -12,9 +13,18 @@ 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.wsTimerRoutes +import java.time.Duration val server = embeddedServer(Netty, port = 8080) { - install(WebSockets) + install(WebSockets) { + pingPeriod = Duration.ofSeconds(15) + timeout = Duration.ofSeconds(15) + maxFrameSize = Long.MAX_VALUE + masking = false + contentConverter = KotlinxWebsocketSerializationConverter(Json) + } + install(ContentNegotiation) { json(Json { prettyPrint = true @@ -27,6 +37,7 @@ val server = embeddedServer(Netty, port = 8080) { } routing { apiRoutes() + wsTimerRoutes() swaggerUI("swagger-ui/index.html", "openapi/documentation.yaml") { options { version = "1.1.0" diff --git a/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/routes/WSTimerRoutes.kt b/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/routes/WSTimerRoutes.kt new file mode 100644 index 0000000..3d5c1e1 --- /dev/null +++ b/webserver/src/main/kotlin/space/mori/chzzk_bot/webserver/routes/WSTimerRoutes.kt @@ -0,0 +1,76 @@ +package space.mori.chzzk_bot.webserver.routes + +import io.ktor.server.routing.* +import io.ktor.server.websocket.* +import io.ktor.websocket.* +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.serialization.Serializable +import space.mori.chzzk_bot.common.events.Event +import space.mori.chzzk_bot.common.events.TimerType +import space.mori.chzzk_bot.common.services.UserService +import space.mori.chzzk_bot.common.events.EventDispatcher +import space.mori.chzzk_bot.common.events.EventHandler +import java.util.concurrent.ConcurrentHashMap + +fun Routing.wsTimerRoutes() { + val sessions = ConcurrentHashMap() + + webSocket("/timer/{uid}") { + val uid = call.parameters["uid"] + val user = uid?.let { UserService.getUser(it) } + if (uid == null) { + close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID")) + return@webSocket + } + if (user == null) { + close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID")) + return@webSocket + } + + sessions[uid] = this + + try { + for (frame in incoming) { + when(frame) { + is Frame.Text -> { + + } + is Frame.Ping -> send(Frame.Pong(frame.data)) + else -> { + + } + } + } + } catch(_: ClosedReceiveChannelException) { + + } finally { + sessions.remove(uid) + } + } + + run { + val dispatcher = EventDispatcher + + dispatcher.register(TimerEvent::class.java, object : EventHandler { + override suspend fun handle(event: TimerEvent) { + sessions[event.uid]?.sendSerialized(TimerResponse(event.type, event.time ?: "")) + } + }) + } +} + +enum class TimerType { + UPTIME, TIMER +} + +class TimerEvent( + val uid: String, + val type: TimerType, + val time: String? +): Event + +@Serializable +data class TimerResponse( + val type: TimerType, + val time: String? +) \ No newline at end of file