Merge pull request #26 from dalbodeule/develop

add Chisu playlist, etc...
This commit is contained in:
JinU Choi 2024-08-04 14:25:51 +09:00 committed by GitHub
commit cc81e6d722
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 431 additions and 17 deletions

1
.idea/.gitignore generated vendored
View File

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

View File

@ -4,15 +4,11 @@ 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.common.events.CoroutinesEventBus import space.mori.chzzk_bot.common.events.*
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.*
import space.mori.chzzk_bot.common.services.CounterService
import space.mori.chzzk_bot.common.services.TimerConfigService
import space.mori.chzzk_bot.common.services.UserService
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 xyz.r2turntrue.chzzk4j.chat.ChatMessage import xyz.r2turntrue.chzzk4j.chat.ChatMessage
import xyz.r2turntrue.chzzk4j.chat.ChzzkChat import xyz.r2turntrue.chzzk4j.chat.ChzzkChat
import java.time.LocalDateTime import java.time.LocalDateTime
@ -46,7 +42,13 @@ class MessageHandler(
val user = UserService.getUser(channel.channelId) val user = UserService.getUser(channel.channelId)
?: throw RuntimeException("User not found. it's bug? ${channel.channelName} - ${channel.channelId}") ?: throw RuntimeException("User not found. it's bug? ${channel.channelName} - ${channel.channelId}")
val commands = CommandService.getCommands(user) val commands = CommandService.getCommands(user)
val manageCommands = mapOf("!명령어추가" to this::manageAddCommand, "!명령어삭제" to this::manageRemoveCommand, "!명령어수정" to this::manageUpdateCommand, "!시간" to this::timerCommand) val manageCommands = mapOf(
"!명령어추가" to this::manageAddCommand,
"!명령어삭제" to this::manageRemoveCommand,
"!명령어수정" to this::manageUpdateCommand,
"!시간" to this::timerCommand,
"!노래추가" to this::songAddCommand
)
manageCommands.forEach { (commandName, command) -> manageCommands.forEach { (commandName, command) ->
this.commands[commandName] = command this.commands[commandName] = command
@ -186,6 +188,50 @@ class MessageHandler(
} }
} }
// songs
fun songAddCommand(msg: ChatMessage, user: User) {
val parts = msg.content.split(" ", limit = 3)
if (parts.size < 2) {
listener.sendChat("유튜브 URL을 입력해주세요!")
return
}
val url = parts[1]
val songs = SongListService.getSong(user)
if (songs.any { it.url == url }) {
listener.sendChat("같은 노래가 이미 신청되어 있습니다.")
return
}
val video = getYoutubeVideo(url)
if (video == null) {
listener.sendChat("유튜브에서 찾을 수 없어요!")
return
}
SongListService.saveSong(
user,
msg.userId,
video.url,
video.name,
video.author,
video.length
)
CoroutineScope(Dispatchers.Main).launch {
dispatcher.post(SongEvent(
user.token,
SongType.ADD,
msg.userId,
video.name,
video.author,
video.length
))
}
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,11 @@
package space.mori.chzzk_bot.chatbot.chzzk
import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.services.CommandService
import xyz.r2turntrue.chzzk4j.chat.ChatMessage
class SongModule {
companion object {
}
}

View File

@ -16,7 +16,7 @@ import space.mori.chzzk_bot.common.services.UserService
object AlertCommand : CommandInterface { object AlertCommand : CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java) private val logger = LoggerFactory.getLogger(this::class.java)
override val name: String = "alert" override val name: String = "alert"
override val command = Commands.slash(name, "명령어를 추가합니다.") override val command = Commands.slash(name, "방송알람 채널을 설정합니다. / 알람 취소도 이 명령어를 이용하세요!")
.addOptions(OptionData(OptionType.CHANNEL, "channel", "알림을 보낼 채널을 입력하세요.")) .addOptions(OptionData(OptionType.CHANNEL, "channel", "알림을 보낼 채널을 입력하세요."))
.addOptions(OptionData(OptionType.STRING, "content", "표시될 텍스트를 입력하세요. 비워두면 알람이 취소됩니다.")) .addOptions(OptionData(OptionType.STRING, "content", "표시될 텍스트를 입력하세요. 비워두면 알람이 취소됩니다."))

View File

@ -31,6 +31,12 @@ dependencies {
// https://mvnrepository.com/artifact/io.github.cdimascio/dotenv-kotlin // https://mvnrepository.com/artifact/io.github.cdimascio/dotenv-kotlin
implementation("io.github.cdimascio:dotenv-kotlin:6.4.1") implementation("io.github.cdimascio:dotenv-kotlin:6.4.1")
// https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// https://mvnrepository.com/artifact/com.google.code.gson/gson
implementation("com.google.code.gson:gson:2.11.0")
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
} }

View File

@ -32,7 +32,9 @@ object Connector {
PersonalCounters, PersonalCounters,
Managers, Managers,
TimerConfigs, TimerConfigs,
LiveStatuses LiveStatuses,
SongLists,
SongConfigs
) )
transaction { transaction {

View File

@ -0,0 +1,20 @@
package space.mori.chzzk_bot.common.events
enum class SongType(var value: Int) {
ADD(0),
REMOVE(1),
NEXT(2),
STREAM_OFF(50)
}
class SongEvent(
val uid: String,
val type: SongType,
val req_uid: String?,
val name: String?,
val author: String?,
val time: Int?,
): Event {
var TAG = javaClass.simpleName
}

View File

@ -0,0 +1,18 @@
package space.mori.chzzk_bot.common.models
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.ReferenceOption
object SongConfigs: IntIdTable("song_config") {
val user = reference("user", Users, onDelete = ReferenceOption.CASCADE)
val option = integer("option")
}
class SongConfig(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<TimerConfig>(TimerConfigs)
var user by User referencedOn TimerConfigs.user
var option by TimerConfigs.option
}

View File

@ -0,0 +1,30 @@
package space.mori.chzzk_bot.common.models
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.javatime.datetime
object SongLists: IntIdTable("song_list") {
val user = reference("user", Users)
val uid = varchar("uid", 64)
val url = varchar("url", 128)
val name = text("name")
val author = text("author")
val time = integer("time")
val created_at = datetime("created_at")
}
class SongList(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<SongList>(SongLists)
var url by SongLists.url
var name by SongLists.name
var author by SongLists.author
var time by SongLists.time
var created_at by SongLists.created_at
var user by User referencedOn SongLists.user
var uid by SongLists.uid
}

View File

@ -0,0 +1,53 @@
package space.mori.chzzk_bot.common.services
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.transactions.transaction
import space.mori.chzzk_bot.common.models.SongList
import space.mori.chzzk_bot.common.models.SongLists
import space.mori.chzzk_bot.common.models.User
object SongListService {
fun saveSong(user: User, uid: String, url: String, name: String, author: String, time: Int) {
return transaction {
SongList.new {
this.user = user
this.uid = uid
this.url = url
this.name = name
this.author = author
this.time = time
}
}
}
fun getSong(user: User, uid: String): List<SongList> {
return transaction {
SongList.find(
(SongLists.user eq user.id) and
(SongLists.uid eq uid)
).toList()
}
}
fun getSong(user: User): List<SongList> {
return transaction {
SongList.find(SongLists.user eq user.id).toList()
}
}
fun deleteSong(user: User, uid: String, name: String): SongList {
return transaction {
val songRow = SongList.find(
(SongLists.user eq user.id) and
(SongLists.uid eq uid) and
(SongLists.name eq name)
).firstOrNull()
songRow ?: throw RuntimeException("Song not found! ${user.username} / $uid / $name")
songRow.delete()
songRow
}
}
}

View File

@ -0,0 +1,91 @@
package space.mori.chzzk_bot.common.utils
import com.google.gson.Gson
import com.google.gson.JsonObject
import io.github.cdimascio.dotenv.dotenv
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
data class YoutubeVideo(
val url: String,
val name: String,
val author: String,
val length: Int
)
val regex = ".*(?:youtu.be/|v/|u/\\w/|embed/|watch\\?v=|&v=)([^#&?]*).*".toRegex()
val durationRegex = """PT(\d+H)?(\d+m)?(\d+S)?""".toRegex()
val client = OkHttpClient()
val gson = Gson()
val dotenv = dotenv {
ignoreIfMissing = true
}
fun getYoutubeVideoId(url: String): String? {
val matchResult = regex.find(url)
return matchResult?.groups?.get(1)?.value
}
fun parseDuration(duration: String): Int {
val matchResult = durationRegex.find(duration)
val (hours, minutes, seconds) = matchResult?.destructured ?: return 0
val hourInSec = hours.dropLast(1).toIntOrNull()?.times(3600) ?: 0
val minutesInSec = minutes.dropLast(1).toIntOrNull()?.times(60) ?: 0
val totalSeconds = seconds.dropLast(1).toIntOrNull() ?: 0
return hourInSec + minutesInSec + totalSeconds
}
fun getYoutubeVideo(url: String): YoutubeVideo? {
val videoId = getYoutubeVideoId(url)
val api = HttpUrl.Builder()
.scheme("https")
.host("www.googleapis.com")
.addPathSegment("youtube")
.addPathSegment("v3")
.addPathSegment("videos")
.addQueryParameter("id", videoId)
.addQueryParameter("key", dotenv["YOUTUBE_API_KEY"])
.addQueryParameter("part", "snippet")
.build()
val request = Request.Builder()
.url(api)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
val responseBody = response.body?.string()
val json = gson.fromJson(responseBody, JsonObject::class.java)
val items = json.getAsJsonArray("items")
if (items == null || items.size() == 0) return null
val item = items[0].asJsonObject
val snippet = item.getAsJsonObject("snippet")
val contentDetail = item.asJsonObject.getAsJsonObject("contentDetail")
val status = contentDetail.getAsJsonObject("status")
if (!status.get("embeddable").asBoolean) return null
val duration = contentDetail.get("duration").asString
val length = parseDuration(duration)
return YoutubeVideo(
"https://www.youtube.com/watch?v=$videoId",
snippet.get("title").asString,
snippet.get("channelTitle").asString,
length
)
}
}

View File

@ -1,6 +1,6 @@
kotlin.code.style=official kotlin.code.style=official
group = space.mori group = space.mori
version = 1.1.2 version = 1.2.0
org.gradle.jvmargs=-Dfile.encoding=UTF-8 org.gradle.jvmargs=-Dfile.encoding=UTF-8
org.gradle.console=plain org.gradle.console=plain

View File

@ -13,6 +13,7 @@ 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.wsSongRoutes
import space.mori.chzzk_bot.webserver.routes.wsTimerRoutes import space.mori.chzzk_bot.webserver.routes.wsTimerRoutes
import java.time.Duration import java.time.Duration
@ -38,9 +39,10 @@ val server = embeddedServer(Netty, port = 8080) {
routing { routing {
apiRoutes() apiRoutes()
wsTimerRoutes() wsTimerRoutes()
wsSongRoutes()
swaggerUI("swagger-ui/index.html", "openapi/documentation.yaml") { swaggerUI("swagger-ui/index.html", "openapi/documentation.yaml") {
options { options {
version = "1.1.0" version = "1.2.0"
} }
} }
} }

View File

@ -0,0 +1,29 @@
package space.mori.chzzk_bot.webserver.routes
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import space.mori.chzzk_bot.common.services.SongListService
import space.mori.chzzk_bot.common.services.UserService
fun Routing.songRoutes() {
route("/songs/{uid}") {
get {
val uid = call.parameters["uid"]
val user = uid?.let { it1 -> UserService.getUser(it1) }
if (user == null) {
call.respondText("No user found", status = HttpStatusCode.NotFound)
return@get
}
val songs = SongListService.getSong(user)
call.respond(songs)
}
}
route("/songs") {
get {
call.respondText("Require UID", status= HttpStatusCode.BadRequest)
}
}
}

View File

@ -0,0 +1,107 @@
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.UserService
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
fun Routing.wsSongRoutes() {
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
val status = ConcurrentHashMap<String, TimerType>()
val logger = LoggerFactory.getLogger(this.javaClass.name)
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("/song/{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
}
addSession(uid, this)
if(status[uid] == TimerType.STREAM_OFF) {
CoroutineScope(Dispatchers.Default).launch {
sendSerialized(SongResponse(
SongType.STREAM_OFF.value,
uid,
null,
null,
null,
null
))
}
}
try {
for (frame in incoming) {
when(frame) {
is Frame.Text -> {
}
is Frame.Ping -> send(Frame.Pong(frame.data))
else -> {
}
}
}
} catch(e: ClosedReceiveChannelException) {
logger.error("Error in WebSocket: ${e.message}")
} finally {
removeSession(uid, this)
}
}
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
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.req_uid,
it.name,
it.author,
it.time
))
}
}
}
}
@Serializable
data class SongResponse(
val type: Int,
val uid: String,
val reqUid: String?,
val name: String?,
val author: String?,
val time: Int?
)

View File

@ -9,18 +9,16 @@ import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.koin.java.KoinJavaComponent.inject import org.koin.java.KoinJavaComponent.inject
import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.common.events.* import space.mori.chzzk_bot.common.events.*
import space.mori.chzzk_bot.common.services.UserService import space.mori.chzzk_bot.common.services.UserService
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
val logger: Logger = LoggerFactory.getLogger("WSTimerRoutes")
fun Routing.wsTimerRoutes() { fun Routing.wsTimerRoutes() {
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>() val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
val status = ConcurrentHashMap<String, TimerType>() val status = ConcurrentHashMap<String, TimerType>()
val logger = LoggerFactory.getLogger(this.javaClass.name)
fun addSession(uid: String, session: WebSocketServerSession) { fun addSession(uid: String, session: WebSocketServerSession) {
sessions.computeIfAbsent(uid) { ConcurrentLinkedQueue() }.add(session) sessions.computeIfAbsent(uid) { ConcurrentLinkedQueue() }.add(session)
@ -49,7 +47,7 @@ fun Routing.wsTimerRoutes() {
if(status[uid] == TimerType.STREAM_OFF) { if(status[uid] == TimerType.STREAM_OFF) {
CoroutineScope(Dispatchers.Default).launch { CoroutineScope(Dispatchers.Default).launch {
sendSerialized(TimerResponse(TimerType.STREAM_OFF.value, "")) sendSerialized(TimerResponse(TimerType.STREAM_OFF.value, null))
} }
} }