Merge pull request #34 from dalbodeule/develop

add WSSongListRoutes.kt
This commit is contained in:
JinU Choi 2024-08-05 12:43:58 +09:00 committed by GitHub
commit 240503a4d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 323 additions and 16 deletions

3
.idea/.gitignore generated vendored
View File

@ -6,4 +6,5 @@
# Datasource local storage ignored files # Datasource local storage ignored files
/dataSources/ /dataSources/
/dataSources.local.xml /dataSources.local.xml
discord.xml discord.xml
inspectionProfiles/Project_Default.xml

View File

@ -4,10 +4,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject 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.events.*
import space.mori.chzzk_bot.common.models.User import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.services.* import space.mori.chzzk_bot.common.services.*
import space.mori.chzzk_bot.common.utils.getFollowDate 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.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
@ -15,6 +17,7 @@ import xyz.r2turntrue.chzzk4j.chat.ChzzkChat
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
import java.util.UUID
class MessageHandler( class MessageHandler(
@ -57,7 +60,8 @@ class MessageHandler(
"!명령어수정" to this::manageUpdateCommand, "!명령어수정" to this::manageUpdateCommand,
"!시간" to this::timerCommand, "!시간" to this::timerCommand,
"!노래추가" to this::songAddCommand, "!노래추가" to this::songAddCommand,
"!노래목록" to this::songListCommand "!노래목록" to this::songListCommand,
"!노래시작" to this::songStartCommand
) )
manageCommands.forEach { (commandName, command) -> manageCommands.forEach { (commandName, command) ->
@ -248,6 +252,26 @@ class MessageHandler(
listener.sendChat("리스트는 여기입니다. https://nabot.mori.space/songs/${user.token}") 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) { internal fun handle(msg: ChatMessage, user: User) {
if(msg.userId == ChzzkHandler.botUid) return if(msg.userId == ChzzkHandler.botUid) return

View File

@ -8,11 +8,19 @@ import org.jetbrains.exposed.sql.ReferenceOption
object SongConfigs: IntIdTable("song_config") { object SongConfigs: IntIdTable("song_config") {
val user = reference("user", Users, onDelete = ReferenceOption.CASCADE) 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) { 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 user by User referencedOn SongConfigs.user
var option by TimerConfigs.option 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?> { 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() val request = Request.Builder()
.url(url) .url(url)
.build() .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 { fun parseDuration(duration: String): Int {
println(duration)
val matchResult = durationRegex.find(duration) val matchResult = durationRegex.find(duration)
val (hours, minutes, seconds) = matchResult?.destructured ?: return 0 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 if (items == null || items.size() == 0) return null
println(json)
val item = items[0].asJsonObject val item = items[0].asJsonObject
val snippet = item.getAsJsonObject("snippet") val snippet = item.getAsJsonObject("snippet")
val contentDetail = item.getAsJsonObject("contentDetails") 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.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.*
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 java.time.Duration import java.time.Duration
val server = embeddedServer(Netty, port = 8080) { val server = embeddedServer(Netty, port = 8080) {
@ -42,6 +39,7 @@ val server = embeddedServer(Netty, port = 8080) {
apiSongRoutes() apiSongRoutes()
wsTimerRoutes() wsTimerRoutes()
wsSongRoutes() wsSongRoutes()
wsSongListRoutes()
swaggerUI("swagger-ui/index.html", "openapi/documentation.yaml") { swaggerUI("swagger-ui/index.html", "openapi/documentation.yaml") {
options { options {
version = "1.2.0" 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() { fun Routing.wsSongRoutes() {
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>() val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
val status = ConcurrentHashMap<String, TimerType>() val status = ConcurrentHashMap<String, SongType>()
val logger = LoggerFactory.getLogger(this.javaClass.name) val logger = LoggerFactory.getLogger(this.javaClass.name)
fun addSession(uid: String, session: WebSocketServerSession) { fun addSession(uid: String, session: WebSocketServerSession) {
@ -45,7 +45,7 @@ fun Routing.wsSongRoutes() {
addSession(uid, this) addSession(uid, this)
if(status[uid] == TimerType.STREAM_OFF) { if(status[uid] == SongType.STREAM_OFF) {
CoroutineScope(Dispatchers.Default).launch { CoroutineScope(Dispatchers.Default).launch {
sendSerialized(SongResponse( sendSerialized(SongResponse(
SongType.STREAM_OFF.value, 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 @Serializable