add WSSongListRoutes.kt

- add Websocket backend.
- add session start command.
- some improve logics.
This commit is contained in:
dalbodeule 2024-08-05 12:35:11 +09:00
parent 47228394d5
commit 0a4e8193bb
No known key found for this signature in database
GPG Key ID: EFA860D069C9FA65
10 changed files with 323 additions and 16 deletions

1
.idea/.gitignore generated vendored
View File

@ -7,3 +7,4 @@
/dataSources/
/dataSources.local.xml
discord.xml
inspectionProfiles/Project_Default.xml

View File

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

View File

@ -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<Int>) : IntEntity(id) {
companion object : IntEntityClass<TimerConfig>(TimerConfigs)
companion object : IntEntityClass<SongConfig>(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
}

View File

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

View File

@ -129,7 +129,7 @@ fun getFollowDate(chatID: String, userId: String) : IData<IFollowContent?> {
}
fun getStreamInfo(userId: String) : IData<IStreamInfo?> {
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()

View File

@ -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("")
}

View File

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

View File

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

View File

@ -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<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
val status = ConcurrentHashMap<String, SongType>()
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<SongRequest>()
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?
)

View File

@ -17,7 +17,7 @@ import java.util.concurrent.ConcurrentLinkedQueue
fun Routing.wsSongRoutes() {
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
val status = ConcurrentHashMap<String, TimerType>()
val status = ConcurrentHashMap<String, SongType>()
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