Merge pull request #14 from dalbodeule/develop

add WebSocket timers
This commit is contained in:
JinU Choi 2024-07-30 22:42:22 +09:00 committed by GitHub
commit 85ad7fe5ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 179 additions and 11 deletions

View File

@ -12,6 +12,7 @@ import xyz.r2turntrue.chzzk4j.chat.ChzzkChat
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
import java.time.LocalDateTime
object ChzzkHandler { object ChzzkHandler {
private val handlers = mutableListOf<UserHandler>() private val handlers = mutableListOf<UserHandler>()
@ -19,7 +20,7 @@ object ChzzkHandler {
@Volatile private var running: Boolean = false @Volatile private var running: Boolean = false
fun addUser(chzzkChannel: ChzzkChannel, user: User) { fun addUser(chzzkChannel: ChzzkChannel, user: User) {
handlers.add(UserHandler(chzzkChannel, logger, user)) handlers.add(UserHandler(chzzkChannel, logger, user, streamStartTime = null))
} }
fun enable() { fun enable() {
@ -82,18 +83,19 @@ object ChzzkHandler {
class UserHandler( class UserHandler(
val channel: ChzzkChannel, val channel: ChzzkChannel,
private val logger: Logger, val logger: Logger,
private var user: User, private var user: User,
private var _isActive: Boolean = false private var _isActive: Boolean = false,
var streamStartTime: LocalDateTime?,
) { ) {
private lateinit var messageHandler: MessageHandler private lateinit var messageHandler: MessageHandler
private var listener: ChzzkChat = chzzk.chat(channel.channelId) var listener: ChzzkChat = chzzk.chat(channel.channelId)
.withAutoReconnect(true) .withAutoReconnect(true)
.withChatListener(object : ChatEventListener { .withChatListener(object : ChatEventListener {
override fun onConnect(chat: ChzzkChat, isReconnecting: Boolean) { override fun onConnect(chat: ChzzkChat, isReconnecting: Boolean) {
logger.info("ChzzkChat connected. ${channel.channelName} - ${channel.channelId} / reconnected: $isReconnecting") logger.info("ChzzkChat connected. ${channel.channelName} - ${channel.channelId} / reconnected: $isReconnecting")
messageHandler = MessageHandler(channel, logger, chat) messageHandler = MessageHandler(this@UserHandler)
} }
override fun onError(ex: Exception) { override fun onError(ex: Exception) {
@ -137,11 +139,13 @@ class UserHandler(
listener.connectBlocking() listener.connectBlocking()
Discord.sendDiscord(user, status) Discord.sendDiscord(user, status)
streamStartTime = LocalDateTime.now()
listener.sendChat("${user.username} 님의 방송이 감지되었습니다.") listener.sendChat("${user.username} 님의 방송이 감지되었습니다.")
} else { } else {
logger.info("${user.username} is offline.") logger.info("${user.username} is offline.")
streamStartTime = null
listener.closeAsync() listener.closeAsync()
} }
} }

View File

@ -1,22 +1,21 @@
package space.mori.chzzk_bot.chatbot.chzzk 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.models.User
import space.mori.chzzk_bot.common.services.CommandService import space.mori.chzzk_bot.common.services.CommandService
import space.mori.chzzk_bot.common.services.CounterService import space.mori.chzzk_bot.common.services.CounterService
import space.mori.chzzk_bot.common.services.UserService import space.mori.chzzk_bot.common.services.UserService
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.types.channel.ChzzkChannel
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 channel: ChzzkChannel, private val handler: UserHandler
private val logger: Logger,
private val listener: ChzzkChat
) { ) {
private val commands = mutableMapOf<String, (msg: ChatMessage, user: User) -> Unit>() private val commands = mutableMapOf<String, (msg: ChatMessage, user: User) -> Unit>()
@ -27,6 +26,10 @@ class MessageHandler(
private val followPattern = Regex("<following>") private val followPattern = Regex("<following>")
private val daysPattern = """<days:(\d{4})-(\d{2})-(\d{2})>""".toRegex() private val daysPattern = """<days:(\d{4})-(\d{2})-(\d{2})>""".toRegex()
private val channel = handler.channel
private val logger = handler.logger
private val listener = handler.listener
init { init {
reloadCommand() reloadCommand()
} }
@ -109,6 +112,50 @@ class MessageHandler(
listener.sendChat("명령어 '$command' 삭제되었습니다.") 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) { internal fun handle(msg: ChatMessage, user: User) {
val commandKey = msg.content.split(' ')[0] val commandKey = msg.content.split(' ')[0]

View File

@ -0,0 +1,19 @@
package space.mori.chzzk_bot.common.events
interface Event
interface EventHandler<E: Event> {
suspend fun handle(event: E)
}
object EventDispatcher {
private val handlers = mutableMapOf<Class<out Event>, MutableList<EventHandler<out Event>>>()
fun <E : Event> register(eventClass: Class<E>, handler: EventHandler<E>) {
handlers.computeIfAbsent(eventClass) { mutableListOf() }.add(handler)
}
suspend fun <E : Event> dispatch(event: E) {
handlers[event::class.java]?.forEach { (it as EventHandler<E>).handle(event) }
}
}

View File

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

View File

@ -1,6 +1,7 @@
package space.mori.chzzk_bot.webserver package space.mori.chzzk_bot.webserver
import io.ktor.http.* import io.ktor.http.*
import io.ktor.serialization.kotlinx.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.engine.* import io.ktor.server.engine.*
@ -12,9 +13,18 @@ import io.ktor.server.routing.*
import io.ktor.server.websocket.* import io.ktor.server.websocket.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import space.mori.chzzk_bot.webserver.routes.apiRoutes 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) { 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) { install(ContentNegotiation) {
json(Json { json(Json {
prettyPrint = true prettyPrint = true
@ -27,6 +37,7 @@ val server = embeddedServer(Netty, port = 8080) {
} }
routing { routing {
apiRoutes() apiRoutes()
wsTimerRoutes()
swaggerUI("swagger-ui/index.html", "openapi/documentation.yaml") { swaggerUI("swagger-ui/index.html", "openapi/documentation.yaml") {
options { options {
version = "1.1.0" version = "1.1.0"

View File

@ -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<String, WebSocketServerSession>()
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<TimerEvent> {
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?
)