82 Commits
1.1.0 ... 1.2.0

Author SHA1 Message Date
JinU Choi
2f5c4293c0 Merge pull request #45 from dalbodeule/develop
RegisterCommand some fix
2024-08-08 10:12:49 +09:00
dalbodeule
0317c80ccf RegisterCommand some fix
- add regex match logics. it can to register with chzzk channel/studio URL
2024-08-08 09:42:37 +09:00
JinU Choi
8d50d1c3aa Merge pull request #44 from dalbodeule/develop
some logic changed.
2024-08-07 13:18:05 +09:00
dalbodeule
de30e632e5 some logic changed. 2024-08-07 13:12:38 +09:00
JinU Choi
5d0fe98c66 Merge pull request #43 from dalbodeule/develop
add try-catch in stream on discords.
2024-08-07 08:59:19 +09:00
dalbodeule
608ba49a71 add try-catch in stream on discords. 2024-08-07 08:55:50 +09:00
dalbodeule
5a46e62a61 some fix - final
- debug searchYoutube fun (8x)
2024-08-06 21:12:44 +09:00
dalbodeule
833bfbd46d some fix
- debug searchYoutube fun (7x)
2024-08-06 21:09:30 +09:00
dalbodeule
76ffebc157 some fix
- debug searchYoutube fun (6x)
2024-08-06 21:05:39 +09:00
dalbodeule
e8bee6ff23 some fix
- debug searchYoutube fun (5x)
2024-08-06 20:57:21 +09:00
dalbodeule
60523f992b some fix
- debug searchYoutube fun (4x)
2024-08-06 20:52:42 +09:00
dalbodeule
a10579ea6b some fix
- debug searchYoutube fun (3x)
2024-08-06 20:49:47 +09:00
dalbodeule
5dcbbdeb80 some fix
- debug searchYoutube fun (2x)
2024-08-06 20:44:46 +09:00
dalbodeule
bedd5406a0 some fix
- debug searchYoutube fun
2024-08-06 20:37:34 +09:00
dalbodeule
a50a3f21a3 some fix
- (tmp add) logger.info
2024-08-06 20:29:41 +09:00
JinU Choi
c9ad739d05 Merge pull request #42 from dalbodeule/develop
some fix
2024-08-06 20:17:33 +09:00
dalbodeule
d329b8bdb3 some fix
- add searchYoutube function.
- add try-catch blocks.
2024-08-06 20:14:34 +09:00
JinU Choi
816c82e57b Merge pull request #41 from dalbodeule/develop
some fix
2024-08-05 21:19:59 +09:00
dalbodeule
debbd61aa7 some fix
- "!노래추가" command append limits.
2024-08-05 21:16:41 +09:00
JinU Choi
3e3a283a16 Merge pull request #40 from dalbodeule/develop
some fix
2024-08-05 20:59:30 +09:00
dalbodeule
c5f8ce7528 some fix
- "!명령어추가", "!명령어수정", "!명령어삭제" command fix. (is able reload)
- on /session/{sid} endpoint, add streamer configs
2024-08-05 20:55:18 +09:00
JinU Choi
7df2b68a5f Merge pull request #39 from dalbodeule/develop
some fix
2024-08-05 20:46:52 +09:00
dalbodeule
778fe8df34 some fix
- !노래시작 command URL typo.
- SongEvent(SongType.REMOVE) debug in WSSongListRoutes
2024-08-05 20:45:34 +09:00
JinU Choi
4c5fa5742f Merge pull request #38 from dalbodeule/develop
some fix WSSongListRoutes.kt
2024-08-05 18:33:48 +09:00
dalbodeule
4f589780b8 some fix WSSongListRoutes.kt
- add url parameters
2024-08-05 18:06:46 +09:00
JinU Choi
d5dc7a61c9 Merge pull request #37 from dalbodeule/develop
some fix WSSongListRoutes.kt
2024-08-05 16:25:46 +09:00
dalbodeule
9b046d38b9 some fix WSSongListRoutes.kt
- add PacketType Handler.
- add SongType.NEXT handler.
2024-08-05 16:23:46 +09:00
JinU Choi
8e2c0243bb Merge pull request #36 from dalbodeule/develop
some fix WSSongListRoutes.kt
2024-08-05 15:02:11 +09:00
dalbodeule
75da1c1576 some fix WSSongListRoutes.kt
- delete websocket key
- else updated.
2024-08-05 14:38:16 +09:00
JinU Choi
fdd8eeda23 Merge pull request #35 from dalbodeule/develop
some improve WSApis
2024-08-05 13:30:07 +09:00
dalbodeule
cc23ac03e7 some improve WSApis
- with WSSongListRoutes.kt, data receive logics.
- else WSAPI's logger name changed.
- MessageHandler.kt "!노래시작" command improved.
2024-08-05 13:25:28 +09:00
JinU Choi
240503a4d5 Merge pull request #34 from dalbodeule/develop
add WSSongListRoutes.kt
2024-08-05 12:43:58 +09:00
dalbodeule
0a4e8193bb add WSSongListRoutes.kt
- add Websocket backend.
- add session start command.
- some improve logics.
2024-08-05 12:35:11 +09:00
JinU Choi
59b90f2bcf Merge pull request #33 from dalbodeule/develop
Add command, update document.
2024-08-04 21:57:28 +09:00
dalbodeule
47228394d5 Add command, update document.
- add !노래목록 command
- Document add (timer, playlist commands)
2024-08-04 21:54:55 +09:00
JinU Choi
04f6b14daa Merge pull request #32 from dalbodeule/develop
Fix /user/{uid} endoints.
2024-08-04 20:32:40 +09:00
dalbodeule
6da0662e2a Fix /user/{uid} endoints.
- ChzzkApis.kt moved to common
- ChzzkApis.kt response data is nullable data.
- if require {uid}'s data, getStreamInfo function called.
2024-08-04 20:27:54 +09:00
JinU Choi
5c93476c34 Merge pull request #31 from dalbodeule/develop
koin not started bug fix
2024-08-04 19:02:12 +09:00
dalbodeule
514ab14c3c koin not started bug fix 2024-08-04 18:59:40 +09:00
JinU Choi
13ce148fc5 Merge pull request #30 from dalbodeule/develop
add /user/{uid} endoints.
2024-08-04 18:55:25 +09:00
dalbodeule
4025cbceec add /user/{uid} call on web module.
- fix some bug with use Pair
2024-08-04 18:51:41 +09:00
dalbodeule
b77a3d02c5 add /user/{uid} call on web module.
- add GetUserEvents.
- add /user/{uid} backends.
2024-08-04 18:47:13 +09:00
dalbodeule
4d63022130 some debugs on ChzzkHandler 2024-08-04 17:25:47 +09:00
JinU Choi
aad10a0f44 Merge pull request #29 from dalbodeule/develop
some debugs on WSTimerRoutes.kt again
2024-08-04 16:11:06 +09:00
dalbodeule
5a050f9d5c some debugs on WSTimerRoutes.kt again 2024-08-04 16:07:51 +09:00
JinU Choi
2f73204c6a Merge pull request #28 from dalbodeule/develop
some debugs on WSTimerRoutes.kt
2024-08-04 15:50:30 +09:00
dalbodeule
37b5fda691 some debugs on WSTimerRoutes.kt 2024-08-04 15:47:10 +09:00
JinU Choi
4f31d87b3b Merge pull request #27 from dalbodeule/develop
some debugs on Chisu playlist
2024-08-04 15:32:54 +09:00
dalbodeule
590c1203bd some debugs on Chisu playlist 2024-08-04 15:30:00 +09:00
JinU Choi
cc81e6d722 Merge pull request #26 from dalbodeule/develop
add Chisu playlist, etc...
2024-08-04 14:25:51 +09:00
dalbodeule
f7953778e1 some debugs on AddCommand
- add spaces.
2024-08-04 14:10:37 +09:00
dalbodeule
b803aeca1d add Chisu playlist functions
- add SongLists, SongConfigs to creation table lists.
2024-08-04 14:09:48 +09:00
dalbodeule
91573a4048 add Chisu playlist functions
- add Websocket
- add API
- version up to 1.2.0
2024-08-04 14:09:11 +09:00
dalbodeule
dc81bb09f2 add Chisu playlist functions
- add Playlist, Playlist settings configs.
- add "!노래추가" command
2024-08-04 13:50:05 +09:00
JinU Choi
60319bc6fa Merge pull request #25 from dalbodeule/develop
some change on LiveStatus logics
2024-08-03 23:47:23 +09:00
dalbodeule
1be1b69425 some change on LiveStatus logics
- add LiveStatus table
- in ChzzkHandler.kt (UserHandler), _isActive variable to bind LiveStatusService
2024-08-03 23:44:10 +09:00
JinU Choi
5b19331890 Merge pull request #24 from dalbodeule/develop
some changes on Connector, RegisterCommand
2024-08-03 21:46:24 +09:00
dalbodeule
d323bf28db some changes on Connector, RegisterCommand 2024-08-03 21:43:02 +09:00
JinU Choi
b3da9db627 Merge pull request #23 from dalbodeule/develop
add TimerConfig.kt, TimerConfigService
2024-08-03 13:59:55 +09:00
dalbodeule
f7c68a56bc add TimerConfig.kt, TimerConfigService 2024-08-03 13:57:03 +09:00
JinU Choi
39237a35c9 Merge pull request #22 from dalbodeule/develop
Chzzk uptime is in status.
2024-08-03 09:30:01 +09:00
dalbodeule
55f6f5f94d Chzzk uptime is in status.
- status:IData<StreamInfo> 's openDate is real uptime.
2024-08-03 09:25:28 +09:00
JinU Choi
65fb2ac3e3 Merge pull request #21 from dalbodeule/develop
in WSTimerRoutes.kt, send data two or many session on one uid.
2024-08-02 15:44:35 +09:00
dalbodeule
e4a2d28b3c in WSTimerRoutes.kt, send data two or many session on one uid. 2024-08-02 15:42:07 +09:00
JinU Choi
01778b801a Merge pull request #20 from dalbodeule/develop
add TimerType.STREAM_OFF status.
2024-08-02 15:08:37 +09:00
dalbodeule
8e9382cb3a add TimerType.STREAM_OFF status.
- if connected on stream off status, TimerType.STREAM_OFF message sent.
- if stream on, default type is TimerType.UPTIME
2024-08-02 14:52:28 +09:00
JinU Choi
070df6b68b Merge pull request #19 from dalbodeule/develop
some debug on WSTimerRoutes.kt
2024-08-01 21:26:35 +09:00
dalbodeule
65d491cc8e some debug on WSTimerRoutes.kt 2024-08-01 21:24:20 +09:00
JinU Choi
df0c301079 Merge pull request #18 from dalbodeule/develop
some debug on TimerEvents
2024-08-01 20:37:11 +09:00
dalbodeule
6b533bcee9 some debug on TimerEvents 2024-08-01 20:29:01 +09:00
JinU Choi
2989816a1a Merge pull request #17 from dalbodeule/develop
debug MessageHandler.kt
2024-07-31 23:45:27 +09:00
dalbodeule
75b983a02b debug MessageHandler.kt 2024-07-31 23:43:55 +09:00
JinU Choi
b92b11bf06 Merge pull request #16 from dalbodeule/develop
CoroutinesEventBus add.
2024-07-31 16:21:14 +09:00
dalbodeule
180dbc85bf CoroutinesEventBus add.
- koin dependency injection add
- EventDispatcher fix
2024-07-31 16:14:58 +09:00
JinU Choi
53cefe5813 Merge pull request #15 from dalbodeule/develop
some change on TimerEvents.
2024-07-30 23:01:57 +09:00
dalbodeule
99ec9ba7a0 some change on TimerEvents. 2024-07-30 23:00:29 +09:00
JinU Choi
85ad7fe5ad Merge pull request #14 from dalbodeule/develop
add WebSocket timers
2024-07-30 22:42:22 +09:00
dalbodeule
a9ee40e936 add WebSocket timers
- EventDispatcher, TimerEvent add.
2024-07-30 22:40:07 +09:00
JinU Choi
da13e8b834 Merge pull request #13 from dalbodeule/develop
debugs some codes.
2024-07-30 16:25:49 +09:00
dalbodeule
65ff475d0a debugs some codes. 2024-07-30 16:24:18 +09:00
JinU Choi
e8baa393cf Merge pull request #12 from dalbodeule/develop
debug with eagerLoading
2024-07-30 15:50:11 +09:00
dalbodeule
8365fa1767 debug with eagerLoading
- debug with eagerLoading.

Exposed is lazyLoading. But, This program requires eagerLoading. So, I make eagerLoading methods.
2024-07-30 15:48:39 +09:00
44 changed files with 1732 additions and 92 deletions

2
.idea/.gitignore generated vendored
View File

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

View File

@@ -32,6 +32,15 @@
- [x] !명령어추가 \[명령어] \[내용] - [x] !명령어추가 \[명령어] \[내용]
- [x] !명령어수정 \[명령어] \[내용] - [x] !명령어수정 \[명령어] \[내용]
- [x] !명령어삭제 \[명령어] - [x] !명령어삭제 \[명령어]
### 타이머 명령어 (on Chzzk chat, 매니저/스트리머 전용)
- [x] !시간 \[숫자: 분]
- [x] !시간 업타임
- [x] !시간 삭제
### 플레이리스트 명령어 (on Chzzk chat)
- [x] !노래추가 \[유튜브 주소]
- [x] !노래목록
- [ ] !노래삭제 \[번호]
- [ ] !노래설정 \[내용] \[켜기/끄기]
### Envs ### Envs
- DISCORD_TOKEN - DISCORD_TOKEN

View File

@@ -27,11 +27,6 @@ repositories {
} }
dependencies { dependencies {
// https://mvnrepository.com/artifact/net.dv8tion/JDA
implementation("net.dv8tion:JDA:5.0.1") {
exclude(module = "opus-java")
}
// https://mvnrepository.com/artifact/ch.qos.logback/logback-classic // https://mvnrepository.com/artifact/ch.qos.logback/logback-classic
implementation("ch.qos.logback:logback-classic:1.5.6") implementation("ch.qos.logback:logback-classic:1.5.6")
@@ -46,6 +41,9 @@ 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/io.insert-koin/koin-core
implementation("io.insert-koin:koin-core:4.0.0-RC1")
kotlin("stdlib") kotlin("stdlib")
listOf(project(":common"), project(":chatbot"), project(":webserver")).forEach { listOf(project(":common"), project(":chatbot"), project(":webserver")).forEach {

View File

@@ -11,7 +11,7 @@ repositories {
dependencies { dependencies {
// https://mvnrepository.com/artifact/net.dv8tion/JDA // https://mvnrepository.com/artifact/net.dv8tion/JDA
implementation("net.dv8tion:JDA:5.0.1") { api("net.dv8tion:JDA:5.0.1") {
exclude(module = "opus-java") exclude(module = "opus-java")
} }
@@ -35,6 +35,9 @@ dependencies {
// https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp // https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
// https://mvnrepository.com/artifact/io.insert-koin/koin-core
implementation("io.insert-koin:koin-core:4.0.0-RC1")
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
listOf(project(":common")).forEach { listOf(project(":common")).forEach {

View File

@@ -1,31 +1,48 @@
package space.mori.chzzk_bot.chatbot.chzzk package space.mori.chzzk_bot.chatbot.chzzk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.Connector.chzzk import space.mori.chzzk_bot.chatbot.chzzk.Connector.chzzk
import space.mori.chzzk_bot.chatbot.discord.Discord import space.mori.chzzk_bot.chatbot.discord.Discord
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.LiveStatusService
import space.mori.chzzk_bot.common.services.TimerConfigService
import space.mori.chzzk_bot.common.services.UserService import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.common.utils.*
import xyz.r2turntrue.chzzk4j.chat.ChatEventListener import xyz.r2turntrue.chzzk4j.chat.ChatEventListener
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 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>()
private val logger = LoggerFactory.getLogger(this::class.java) private val logger = LoggerFactory.getLogger(this::class.java)
lateinit var botUid: String
@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() {
botUid = chzzk.loggedUser.userId
UserService.getAllUsers().map { UserService.getAllUsers().map {
chzzk.getChannel(it.token)?.let { token -> addUser(token, it) } chzzk.getChannel(it.token)?.let { token -> addUser(token, it) }
} }
handlers.forEach { handler ->
val streamInfo = getStreamInfo(handler.listener.channelId)
if (streamInfo.content?.status == "OPEN") handler.isActive(true, streamInfo)
}
} }
fun disable() { fun disable() {
@@ -58,8 +75,8 @@ object ChzzkHandler {
if (!running) return@forEach if (!running) return@forEach
try { try {
val streamInfo = getStreamInfo(it.channel.channelId) val streamInfo = getStreamInfo(it.channel.channelId)
if (streamInfo.content.status == "OPEN" && !it.isActive) it.isActive(true, streamInfo) if (streamInfo.content?.status == "OPEN" && !it.isActive) it.isActive(true, streamInfo)
if (streamInfo.content.status == "CLOSE" && it.isActive) it.isActive(false, streamInfo) if (streamInfo.content?.status == "CLOSE" && it.isActive) it.isActive(false, streamInfo)
} catch(e: SocketTimeoutException) { } catch(e: SocketTimeoutException) {
logger.info("Timeout: ${it.channel.channelName} / ${e.stackTraceToString()}") logger.info("Timeout: ${it.channel.channelName} / ${e.stackTraceToString()}")
} catch (e: Exception) { } catch (e: Exception) {
@@ -82,18 +99,24 @@ 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 var streamStartTime: LocalDateTime?,
) { ) {
private lateinit var messageHandler: MessageHandler private lateinit var messageHandler: MessageHandler
private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
private var _isActive: Boolean
get() = LiveStatusService.getLiveStatus(user)?.status ?: false
set(value) {
LiveStatusService.updateOrCreate(user, value)
}
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) {
@@ -128,21 +151,69 @@ class UserHandler(
internal val isActive: Boolean internal val isActive: Boolean
get() = _isActive get() = _isActive
internal fun isActive(value: Boolean, status: IData<IStreamInfo>) { internal fun isActive(value: Boolean, status: IData<IStreamInfo?>) {
_isActive = value
if(value) { if(value) {
logger.info("${user.username} is live.") logger.info("${user.username} is live.")
logger.info("ChzzkChat connecting... ${channel.channelName} - ${channel.channelId}") logger.info("ChzzkChat connecting... ${channel.channelName} - ${channel.channelId}")
listener.connectBlocking() listener.connectBlocking()
Discord.sendDiscord(user, status) streamStartTime = status.content?.openDate?.let { convertChzzkDateToLocalDateTime(it) }
listener.sendChat("${user.username} 님의 방송이 감지되었습니다.") CoroutineScope(Dispatchers.Default).launch {
if(!_isActive) {
when(TimerConfigService.getConfig(UserService.getUser(channel.channelId)!!)?.option) {
TimerType.UPTIME.value -> dispatcher.post(
TimerEvent(
channel.channelId,
TimerType.UPTIME,
getUptime(streamStartTime!!)
)
)
else -> dispatcher.post(
TimerEvent(
channel.channelId,
TimerType.REMOVE,
""
)
)
}
delay(5000L)
try {
listener.sendChat("${user.username} 님! 오늘도 열심히 방송하세요!")
Discord.sendDiscord(user, status)
} catch(e: Exception) {
logger.info("Stream on logic has some error: ${e.stackTraceToString()}")
}
}
}
} else { } else {
logger.info("${user.username} is offline.") logger.info("${user.username} is offline.")
streamStartTime = null
listener.closeAsync() listener.closeAsync()
CoroutineScope(Dispatchers.Default).launch {
val events = listOf(
TimerEvent(
channel.channelId,
TimerType.STREAM_OFF,
null
),
SongEvent(
channel.channelId,
SongType.STREAM_OFF,
null,
null,
null,
null,
null,
null
)
)
events.forEach { dispatcher.post(it) }
}
} }
_isActive = value
} }
} }

View File

@@ -1,22 +1,26 @@
package space.mori.chzzk_bot.chatbot.chzzk package space.mori.chzzk_bot.chatbot.chzzk
import org.slf4j.Logger 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.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.utils.getFollowDate
import space.mori.chzzk_bot.common.services.UserService 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 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
import java.util.UUID
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,15 +31,37 @@ 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
private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
init { init {
reloadCommand() reloadCommand()
dispatcher.subscribe(SongEvent::class) {
if(it.type == SongType.STREAM_OFF) {
val user = UserService.getUser(channel.channelId)
if(! user?.let { usr -> SongListService.getSong(usr) }.isNullOrEmpty()) {
SongListService.deleteUser(user!!)
}
}
}
} }
internal fun reloadCommand() { internal fun reloadCommand() {
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) val manageCommands = mapOf(
"!명령어추가" to this::manageAddCommand,
"!명령어삭제" to this::manageRemoveCommand,
"!명령어수정" to this::manageUpdateCommand,
"!시간" to this::timerCommand,
"!노래추가" to this::songAddCommand,
"!노래목록" to this::songListCommand,
"!노래시작" to this::songStartCommand
)
manageCommands.forEach { (commandName, command) -> manageCommands.forEach { (commandName, command) ->
this.commands[commandName] = command this.commands[commandName] = command
@@ -91,6 +117,7 @@ class MessageHandler(
val content = parts[2] val content = parts[2]
CommandService.updateCommand(user, command, content, "") CommandService.updateCommand(user, command, content, "")
listener.sendChat("명령어 '$command' 수정되었습니다.") listener.sendChat("명령어 '$command' 수정되었습니다.")
ChzzkHandler.reloadCommand(channel)
} }
private fun manageRemoveCommand(msg: ChatMessage, user: User) { private fun manageRemoveCommand(msg: ChatMessage, user: User) {
@@ -107,11 +134,172 @@ class MessageHandler(
val command = parts[1] val command = parts[1]
CommandService.removeCommand(user, command) CommandService.removeCommand(user, command)
listener.sendChat("명령어 '$command' 삭제되었습니다.") listener.sendChat("명령어 '$command' 삭제되었습니다.")
ChzzkHandler.reloadCommand(channel)
}
private fun timerCommand(msg: ChatMessage, user: User) {
if (msg.profile?.userRoleCode == "common_user") {
listener.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return
}
val parts = msg.content.split(" ", limit = 3)
if (parts.size < 2) {
listener.sendChat("타이머 명령어 형식을 잘 찾아봐주세요!")
return
}
val command = parts[1]
when (parts[1]) {
"업타임" -> {
logger.debug("${user.token} / 업타임")
CoroutineScope(Dispatchers.Default).launch {
dispatcher.post(
TimerEvent(
user.token,
TimerType.UPTIME,
getUptime(handler.streamStartTime!!)
)
)
}
}
"삭제" -> {
logger.debug("${user.token} / 삭제")
CoroutineScope(Dispatchers.Default).launch {
dispatcher.post(TimerEvent(user.token, TimerType.REMOVE, ""))
}
}
"설정" -> {
when (parts[2]) {
"업타임" -> {
TimerConfigService.saveOrUpdateConfig(user, TimerType.UPTIME)
listener.sendChat("기본 타이머 설정이 업타임으로 바뀌었습니다.")
}
"삭제" -> {
TimerConfigService.saveOrUpdateConfig(user, TimerType.REMOVE)
listener.sendChat("기본 타이머 설정이 삭제로 바뀌었습니다.")
}
else -> listener.sendChat("!타이머 설정 (업타임/삭제) 형식으로 써주세요!")
}
}
else -> {
logger.debug("${user.token} / 그외")
try {
val time = command.toInt()
val currentTime = LocalDateTime.now()
val timestamp = currentTime.plus(time.toLong(), ChronoUnit.MINUTES)
CoroutineScope(Dispatchers.Default).launch {
dispatcher.post(TimerEvent(user.token, TimerType.TIMER, timestamp.toString()))
}
} catch (e: NumberFormatException) {
listener.sendChat("!타이머/숫자 형식으로 적어주세요! 단위: 분")
} catch (e: Exception) {
listener.sendChat("타이머 설정 중 오류가 발생했습니다.")
logger.error("Error processing timer command: ${e.message}", e)
}
}
}
}
// songs
private fun songAddCommand(msg: ChatMessage, user: User) {
val parts = msg.content.split(" ", limit = 3)
if (parts.size < 2) {
listener.sendChat("유튜브 URL을 입력해주세요!")
return
}
val config = SongConfigService.getConfig(user)
if(config.streamerOnly && msg.profile?.userRoleCode == "common_user") {
listener.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return
}
val url = parts[1]
val songs = SongListService.getSong(user)
if(songs.size >= config.queueLimit) {
listener.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!")
return
}
if(songs.filter { it.uid == msg.userId }.size >= config.personalLimit) {
listener.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!")
return
}
try {
val video = getYoutubeVideo(url)
if (video == null) {
listener.sendChat("유튜브에서 찾을 수 없어요!")
return
}
if (songs.any { it.url == video.url }) {
listener.sendChat("같은 노래가 이미 신청되어 있습니다.")
return
}
SongListService.saveSong(
user,
msg.userId,
video.url,
video.name,
video.author,
video.length,
msg.profile?.nickname ?: ""
)
CoroutineScope(Dispatchers.Default).launch {
dispatcher.post(
SongEvent(
user.token,
SongType.ADD,
msg.userId,
msg.profile?.nickname ?: "",
video.name,
video.author,
video.length,
video.url
)
)
}
listener.sendChat("노래가 추가되었습니다.")
} catch(e: Exception) {
listener.sendChat("유튜브 영상 주소로 다시 신청해주세요!")
logger.info(e.stackTraceToString())
}
}
private fun songListCommand(msg: ChatMessage, user: User) {
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("-", "")
SongConfigService.updateSession(user, session)
bot.retrieveUserById(user.discord).queue { discordUser ->
discordUser?.openPrivateChannel()?.queue { channel ->
channel.sendMessage("여기로 접속해주세요! ||https://nabot.mori.space/songlist/${session}||.\n주소가 노출될 경우 방송을 다시 켜셔야 합니다!")
.queue()
}
}
} }
internal fun handle(msg: ChatMessage, user: User) { internal fun handle(msg: ChatMessage, user: User) {
val commandKey = msg.content.split(' ')[0] if(msg.userId == ChzzkHandler.botUid) return
val commandKey = msg.content.split(' ')[0]
commands[commandKey.lowercase()]?.let { it(msg, user) } commands[commandKey.lowercase()]?.let { it(msg, user) }
} }
@@ -143,10 +331,10 @@ class MessageHandler(
} }
// Replace followPattern // Replace followPattern
result = followPattern.replace(result) { matchResult -> result = followPattern.replace(result) { _ ->
try { try {
val followingDate = getFollowDate(listener.chatId, msg.userId) val followingDate = getFollowDate(listener.chatId, msg.userId)
.content.streamingProperty.following?.followDate .content?.streamingProperty?.following?.followDate
val period = followingDate?.let { val period = followingDate?.let {
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

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

@@ -6,14 +6,15 @@ import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.JDABuilder import net.dv8tion.jda.api.JDABuilder
import net.dv8tion.jda.api.entities.Activity import net.dv8tion.jda.api.entities.Activity
import net.dv8tion.jda.api.entities.Guild import net.dv8tion.jda.api.entities.Guild
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel
import net.dv8tion.jda.api.events.guild.GuildJoinEvent import net.dv8tion.jda.api.events.guild.GuildJoinEvent
import net.dv8tion.jda.api.events.guild.member.GuildMemberRemoveEvent import net.dv8tion.jda.api.events.guild.member.GuildMemberRemoveEvent
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.hooks.ListenerAdapter import net.dv8tion.jda.api.hooks.ListenerAdapter
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.IData import space.mori.chzzk_bot.common.utils.IData
import space.mori.chzzk_bot.chatbot.chzzk.IStreamInfo import space.mori.chzzk_bot.common.utils.IStreamInfo
import space.mori.chzzk_bot.chatbot.discord.commands.* import space.mori.chzzk_bot.chatbot.discord.commands.*
import space.mori.chzzk_bot.common.models.User import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.services.ManagerService import space.mori.chzzk_bot.common.services.ManagerService
@@ -30,22 +31,21 @@ class Discord: ListenerAdapter() {
companion object { companion object {
lateinit var bot: JDA lateinit var bot: JDA
internal fun getChannel(guildId: Long, channelId: Long) = internal fun getChannel(guildId: Long, channelId: Long): TextChannel? = bot.getGuildById(guildId)?.getTextChannelById(channelId)
bot.getGuildById(guildId)?.getTextChannelById(channelId)
fun sendDiscord(user: User, status: IData<IStreamInfo>) { fun sendDiscord(user: User, status: IData<IStreamInfo?>) {
if(status.content == null) return
if(user.liveAlertMessage != "" && user.liveAlertGuild != null && user.liveAlertChannel != null) { if(user.liveAlertMessage != "" && user.liveAlertGuild != null && user.liveAlertChannel != null) {
val channel = getChannel(user.liveAlertGuild!!, user.liveAlertChannel!!) ?: throw RuntimeException("${user.liveAlertChannel} is not valid.") val channel = getChannel(user.liveAlertGuild!!, user.liveAlertChannel!!) ?: throw RuntimeException("${user.liveAlertChannel} is not valid.")
val embed = EmbedBuilder() val embed = EmbedBuilder()
embed.setTitle(status.content.liveTitle, "https://chzzk.naver.com/live/${user.token}") embed.setTitle(status.content!!.liveTitle, "https://chzzk.naver.com/live/${user.token}")
embed.setDescription("${user.username} 님이 방송을 시작했습니다.") embed.setDescription("${user.username} 님이 방송을 시작했습니다.")
embed.setUrl(status.content.channel.channelImageUrl)
embed.setTimestamp(Instant.now()) embed.setTimestamp(Instant.now())
embed.setAuthor(user.username, "https://chzzk.naver.com/live/${user.token}", status.content.channel.channelImageUrl) embed.setAuthor(user.username, "https://chzzk.naver.com/live/${user.token}", status.content!!.channel.channelImageUrl)
embed.addField("카테고리", status.content.liveCategoryValue, true) embed.addField("카테고리", status.content!!.liveCategoryValue, true)
embed.addField("태그", status.content.tags.joinToString(", "), true) embed.addField("태그", status.content!!.tags.joinToString(", "), true)
embed.setImage(status.content.liveImageUrl.replace("{type}", "1080")) embed.setImage(status.content!!.liveImageUrl.replace("{type}", "1080"))
channel.sendMessage( channel.sendMessage(
MessageCreateBuilder() MessageCreateBuilder()

View File

@@ -5,6 +5,7 @@ import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEve
import net.dv8tion.jda.api.interactions.commands.OptionType import net.dv8tion.jda.api.interactions.commands.OptionType
import net.dv8tion.jda.api.interactions.commands.build.Commands import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.interactions.commands.build.OptionData import net.dv8tion.jda.api.interactions.commands.build.OptionData
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chatbot.chzzk.Connector import space.mori.chzzk_bot.chatbot.chzzk.Connector
@@ -39,8 +40,15 @@ object AddCommand : CommandInterface {
} }
if (manager != null) { if (manager != null) {
user = manager.user transaction {
ManagerService.updateManager(user, event.user.idLong, event.user.effectiveName) user = manager.user
}
user?.let { ManagerService.updateManager(it, event.user.idLong, event.user.effectiveName) }
}
if (user == null) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
return
} }
val commands = CommandService.getCommands(user!!) val commands = CommandService.getCommands(user!!)
@@ -49,14 +57,14 @@ object AddCommand : CommandInterface {
return return
} }
val chzzkChannel = Connector.getChannel(user.token) val chzzkChannel = Connector.getChannel(user!!.token)
try { try {
CommandService.saveCommand(user, label, content, failContent ?: "") CommandService.saveCommand(user!!, label, content, failContent ?: "")
try { try {
ChzzkHandler.reloadCommand(chzzkChannel!!) ChzzkHandler.reloadCommand(chzzkChannel!!)
} catch (_: Exception) {} } catch (_: Exception) {}
event.hook.sendMessage("등록이 완료되었습니다. $label = $content/$failContent").queue() event.hook.sendMessage("등록이 완료되었습니다. $label = $content / $failContent").queue()
} catch (e: Exception) { } catch (e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue() event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString()) logger.debug(e.stackTraceToString())

View File

@@ -5,6 +5,7 @@ import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEve
import net.dv8tion.jda.api.interactions.commands.OptionType import net.dv8tion.jda.api.interactions.commands.OptionType
import net.dv8tion.jda.api.interactions.commands.build.Commands import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.interactions.commands.build.OptionData import net.dv8tion.jda.api.interactions.commands.build.OptionData
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chatbot.chzzk.Connector import space.mori.chzzk_bot.chatbot.chzzk.Connector
@@ -15,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", "표시될 텍스트를 입력하세요. 비워두면 알람이 취소됩니다."))
@@ -31,14 +32,21 @@ object AlertCommand : CommandInterface {
} }
if (manager != null) { if (manager != null) {
user = manager.user transaction {
ManagerService.updateManager(user, event.user.idLong, event.user.effectiveName) user = manager.user
}
user?.let { ManagerService.updateManager(it, event.user.idLong, event.user.effectiveName) }
}
if (user == null) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
return
} }
val chzzkChannel = Connector.getChannel(user!!.token) val chzzkChannel = Connector.getChannel(user!!.token)
try { try {
val newUser = UserService.updateLiveAlert(user.id.value, channel?.guild?.idLong ?: 0L, channel?.idLong ?: 0L, content ?: "") val newUser = UserService.updateLiveAlert(user!!.id.value, channel?.guild?.idLong ?: 0L, channel?.idLong ?: 0L, content ?: "")
try { try {
ChzzkHandler.reloadUser(chzzkChannel!!, newUser) ChzzkHandler.reloadUser(chzzkChannel!!, newUser)
} catch (_: Exception) {} } catch (_: Exception) {}

View File

@@ -1,5 +1,8 @@
package space.mori.chzzk_bot.chatbot.discord.commands package space.mori.chzzk_bot.chatbot.discord.commands
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.dv8tion.jda.api.JDA import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.interactions.commands.OptionType import net.dv8tion.jda.api.interactions.commands.OptionType
@@ -14,12 +17,15 @@ import space.mori.chzzk_bot.common.services.UserService
object RegisterCommand: CommandInterface { object RegisterCommand: CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java) private val logger = LoggerFactory.getLogger(this::class.java)
override val name = "register" override val name = "register"
private val regex = """(?:.+chzzk\.naver\.com/)?([a-f0-9]{32})?(?:/live)?${'$'}""".toRegex()
override val command = Commands.slash(name, "치지직 계정을 등록합니다.") override val command = Commands.slash(name, "치지직 계정을 등록합니다.")
.addOptions( .addOptions(
OptionData( OptionData(
OptionType.STRING, OptionType.STRING,
"chzzk_id", "chzzk_id",
"36da10b7c35800f298e9c565a396bafd 형식으로 입력해주세요.", "치지직 채널 URL 혹은 ID를 입력해주세요.",
true true
) )
) )
@@ -30,17 +36,22 @@ object RegisterCommand: CommandInterface {
event.hook.sendMessage("치지직 계정은 필수 입력입니다.").queue() event.hook.sendMessage("치지직 계정은 필수 입력입니다.").queue()
return return
} }
val matchResult = regex.find(chzzkID)
val matchedChzzkId = matchResult?.groups?.get(1)?.value
val chzzkChannel = Connector.getChannel(chzzkID) val chzzkChannel = matchedChzzkId?.let { Connector.getChannel(it) }
if (chzzkChannel == null) { if (chzzkChannel == null) {
event.hook.sendMessage("치지직 계정을 찾을 수 없습니다.").queue() event.hook.sendMessage("치지직 계정을 찾을 수 없습니다.").queue()
return return
} }
try { try {
val user = UserService.saveUser(chzzkChannel.channelName, chzzkChannel.channelId, event.user.idLong) val user = UserService.saveUser(chzzkChannel.channelName, chzzkChannel.channelId, event.user.idLong)
CoroutineScope(Dispatchers.Main).launch {
ChzzkHandler.addUser(chzzkChannel, user)
}
event.hook.sendMessage("등록이 완료되었습니다. `${chzzkChannel.channelId}` - `${chzzkChannel.channelName}`") event.hook.sendMessage("등록이 완료되었습니다. `${chzzkChannel.channelId}` - `${chzzkChannel.channelName}`")
ChzzkHandler.addUser(chzzkChannel, user)
} catch(e: Exception) { } catch(e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue() event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString()) logger.debug(e.stackTraceToString())

View File

@@ -5,6 +5,7 @@ import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEve
import net.dv8tion.jda.api.interactions.commands.OptionType import net.dv8tion.jda.api.interactions.commands.OptionType
import net.dv8tion.jda.api.interactions.commands.build.Commands import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.interactions.commands.build.OptionData import net.dv8tion.jda.api.interactions.commands.build.OptionData
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chatbot.chzzk.Connector import space.mori.chzzk_bot.chatbot.chzzk.Connector
@@ -35,14 +36,21 @@ object RemoveCommand : CommandInterface {
} }
if (manager != null) { if (manager != null) {
user = manager.user transaction {
ManagerService.updateManager(user, event.user.idLong, event.user.effectiveName) user = manager.user
}
user?.let { ManagerService.updateManager(it, event.user.idLong, event.user.effectiveName) }
}
if (user == null) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
return
} }
val chzzkChannel = Connector.getChannel(user!!.token) val chzzkChannel = Connector.getChannel(user!!.token)
try { try {
CommandService.removeCommand(user, label) CommandService.removeCommand(user!!, label)
try { try {
ChzzkHandler.reloadCommand(chzzkChannel!!) ChzzkHandler.reloadCommand(chzzkChannel!!)
} catch (_: Exception) {} } catch (_: Exception) {}

View File

@@ -5,6 +5,7 @@ import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEve
import net.dv8tion.jda.api.interactions.commands.OptionType import net.dv8tion.jda.api.interactions.commands.OptionType
import net.dv8tion.jda.api.interactions.commands.build.Commands import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.interactions.commands.build.OptionData import net.dv8tion.jda.api.interactions.commands.build.OptionData
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chatbot.chzzk.Connector import space.mori.chzzk_bot.chatbot.chzzk.Connector
@@ -39,14 +40,21 @@ object UpdateCommand : CommandInterface {
} }
if (manager != null) { if (manager != null) {
user = manager.user transaction {
ManagerService.updateManager(user, event.user.idLong, event.user.effectiveName) user = manager.user
}
user?.let { ManagerService.updateManager(it, event.user.idLong, event.user.effectiveName) }
}
if (user == null) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
return
} }
val chzzkChannel = Connector.getChannel(user!!.token) val chzzkChannel = Connector.getChannel(user!!.token)
try { try {
CommandService.updateCommand(user, label, content, failContent ?: "") CommandService.updateCommand(user!!, label, content, failContent ?: "")
chzzkChannel?.let { ChzzkHandler.reloadCommand(it) } chzzkChannel?.let { ChzzkHandler.reloadCommand(it) }
event.hook.sendMessage("등록이 완료되었습니다. $label = $content").queue() event.hook.sendMessage("등록이 완료되었습니다. $label = $content").queue()
} catch (e: Exception) { } catch (e: Exception) {

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

@@ -24,7 +24,18 @@ object Connector {
init { init {
Database.connect(dataSource) Database.connect(dataSource)
val tables = listOf(Users, Commands, Counters, DailyCounters, PersonalCounters, Managers) val tables = listOf(
Users,
Commands,
Counters,
DailyCounters,
PersonalCounters,
Managers,
TimerConfigs,
LiveStatuses,
SongLists,
SongConfigs
)
transaction { transaction {
SchemaUtils.createMissingTablesAndColumns(* tables.toTypedArray()) SchemaUtils.createMissingTablesAndColumns(* tables.toTypedArray())

View File

@@ -0,0 +1,32 @@
package space.mori.chzzk_bot.common.events
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import kotlin.reflect.KClass
interface Event
interface EventBus {
suspend fun <T: Event> post(event: T)
fun <T: Event> subscribe(eventClass: KClass<T>, listener: (T) -> Unit)
}
class CoroutinesEventBus: EventBus {
private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> get() = _events
override suspend fun<T: Event> post(event: T) = _events.emit(event)
override fun <T: Event> subscribe(eventClass: KClass<T>, listener: (T) -> Unit) {
CoroutineScope(Dispatchers.Default).launch {
events.filterIsInstance(eventClass)
.collect {
listener(it)
}
}
}
}

View File

@@ -0,0 +1,22 @@
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 reqUid: String?,
val reqName: String?,
val name: String?,
val author: String?,
val time: Int?,
val url: String?
): Event {
var TAG = javaClass.simpleName
}

View File

@@ -0,0 +1,17 @@
package space.mori.chzzk_bot.common.events
enum class TimerType(var value: Int) {
UPTIME(0),
TIMER(1),
REMOVE(2),
STREAM_OFF(50)
}
class TimerEvent(
val uid: String,
val type: TimerType,
val time: String?
): Event {
var TAG = javaClass.simpleName
}

View File

@@ -0,0 +1,19 @@
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 LiveStatuses: IntIdTable("live_statuses") {
val user = reference("user", Users, onDelete = ReferenceOption.CASCADE)
val status = bool("status")
}
class LiveStatus(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<LiveStatus>(LiveStatuses)
var user by User referencedOn LiveStatuses.user
var status by LiveStatuses.status
}

View File

@@ -0,0 +1,24 @@
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 token = varchar("token", 64).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<SongConfig>(SongConfigs)
var user by User referencedOn SongConfigs.user
var token by SongConfigs.token
var streamerOnly by SongConfigs.streamerOnly
var queueLimit by SongConfigs.queueLimit
var personalLimit by SongConfigs.personalLimit
}

View File

@@ -0,0 +1,33 @@
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
import java.time.LocalDateTime
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 reqName = varchar("req_name", 20)
val author = text("author")
val time = integer("time")
val created_at = datetime("created_at").default(LocalDateTime.now())
}
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
var reqName by SongLists.reqName
}

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 TimerConfigs: IntIdTable("timer_config") {
val user = reference("user", Users, onDelete = ReferenceOption.CASCADE)
val option = integer("option")
}
class TimerConfig(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<TimerConfig>(TimerConfigs)
var user by User referencedOn TimerConfigs.user
var option by TimerConfigs.option
}

View File

@@ -11,7 +11,7 @@ import space.mori.chzzk_bot.common.models.User
object CommandService { object CommandService {
fun saveCommand(user: User, command: String, content: String, failContent: String): Command { fun saveCommand(user: User, command: String, content: String, failContent: String): Command {
return transaction { return transaction {
return@transaction Command.new { Command.new {
this.user = user this.user = user
this.command = command this.command = command
this.content = content this.content = content

View File

@@ -0,0 +1,30 @@
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.LiveStatus
import space.mori.chzzk_bot.common.models.LiveStatuses
import space.mori.chzzk_bot.common.models.User
object LiveStatusService {
fun updateOrCreate(user: User, status: Boolean): LiveStatus {
return transaction {
return@transaction when(val liveStatus = LiveStatus.find(LiveStatuses.user eq user.id).firstOrNull()) {
null -> LiveStatus.new {
this.user = user
this.status = status
}
else -> {
liveStatus.status = status
liveStatus
}
}
}
}
fun getLiveStatus(user: User): LiveStatus? {
return transaction {
LiveStatus.find(LiveStatuses.user eq user.id).firstOrNull()
}
}
}

View File

@@ -1,5 +1,7 @@
package space.mori.chzzk_bot.common.services package space.mori.chzzk_bot.common.services
import org.jetbrains.exposed.dao.load
import org.jetbrains.exposed.dao.with
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
@@ -23,31 +25,38 @@ object ManagerService {
} }
fun updateManager(user: User, discordId: Long, name: String): Manager { fun updateManager(user: User, discordId: Long, name: String): Manager {
if (user.liveAlertGuild == null) return transaction {
throw RuntimeException("${user.username} has no liveAlertGuild") if (user.liveAlertGuild == null)
throw RuntimeException("${user.username} has no liveAlertGuild")
val manager = getUser(user.liveAlertGuild!!, discordId) val manager = getUser(user.liveAlertGuild!!, discordId) ?: throw RuntimeException("$name isn't manager.")
if (manager == null) manager.lastUserName = name
throw RuntimeException("$name isn't manager.")
manager.lastUserName = name
return manager manager
}
} }
fun getUser(guildId: Long, discordId: Long): Manager? { fun getUser(guildId: Long, discordId: Long): Manager? {
return transaction { return transaction {
val manager = Manager.find( val manager = Manager.find(
(Managers.discordGuildId eq guildId) and (Managers.managerId eq discordId) (Managers.discordGuildId eq guildId) and (Managers.managerId eq discordId),
) )
.with(Manager::user)
manager.firstOrNull() .firstOrNull()
manager
} }
} }
fun getAllUsers(guildId: Long): List<Manager> { fun getAllUsers(guildId: Long): List<Manager> {
return transaction { return transaction {
Manager.find(Managers.discordGuildId eq guildId).toList() val result = Manager.find(Managers.discordGuildId eq guildId)
.with(Manager::user)
.toList()
result.forEach { it.load(Manager::user) }
result
} }
} }

View File

@@ -0,0 +1,86 @@
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?): SongConfig {
return transaction {
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
if (songConfig == null) {
songConfig = initConfig(user)
}
songConfig.token = token
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

@@ -0,0 +1,63 @@
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, reqName: String) {
return transaction {
SongList.new {
this.user = user
this.uid = uid
this.url = url
this.name = name
this.author = author
this.time = time
this.reqName = reqName
}
}
}
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().sortedBy { it.created_at }
}
}
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
}
}
fun deleteUser(user: User): Boolean {
return transaction {
val songRow = SongList.find(SongLists.user eq user.id).toList()
songRow.forEach { it.delete() }
true
}
}
}

View File

@@ -0,0 +1,48 @@
package space.mori.chzzk_bot.common.services
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import space.mori.chzzk_bot.common.events.TimerType
import space.mori.chzzk_bot.common.models.TimerConfig
import space.mori.chzzk_bot.common.models.TimerConfigs
import space.mori.chzzk_bot.common.models.User
object TimerConfigService {
fun saveConfig(user: User, timerConfig: TimerType) {
return transaction {
TimerConfig.new {
this.user = user
this.option = timerConfig.value
}
}
}
fun updateConfig(user: User, timerConfig: TimerType) {
return transaction {
val updated = TimerConfigs.update({
TimerConfigs.user eq user.id
}) {
it[option] = timerConfig.value
}
if (updated == 0) throw RuntimeException("TimerConfig not found! ${user.username}")
TimerConfig.find { TimerConfigs.user eq user.id }.first()
}
}
fun getConfig(user: User): TimerConfig? {
return transaction {
TimerConfig.find(TimerConfigs.user eq user.id).firstOrNull()
}
}
fun saveOrUpdateConfig(user: User, timerConfig: TimerType) {
return if (getConfig(user) == null) {
saveConfig(user, timerConfig)
} else {
updateConfig(user, timerConfig)
}
}
}

View File

@@ -1,4 +1,4 @@
package space.mori.chzzk_bot.chatbot.chzzk package space.mori.chzzk_bot.common.utils
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
@@ -108,7 +108,7 @@ val client = OkHttpClient.Builder()
.build() .build()
val gson = Gson() val gson = Gson()
fun getFollowDate(chatID: String, userId: String) : IData<IFollowContent> { fun getFollowDate(chatID: String, userId: String) : IData<IFollowContent?> {
val url = "https://comm-api.game.naver.com/nng_main/v1/chats/$chatID/users/$userId/profile-card?chatType=STREAMING" val url = "https://comm-api.game.naver.com/nng_main/v1/chats/$chatID/users/$userId/profile-card?chatType=STREAMING"
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
@@ -118,7 +118,7 @@ fun getFollowDate(chatID: String, userId: String) : IData<IFollowContent> {
try { try {
if(!response.isSuccessful) throw IOException("Unexpected code ${response.code}") if(!response.isSuccessful) throw IOException("Unexpected code ${response.code}")
val body = response.body?.string() val body = response.body?.string()
val follow = gson.fromJson(body, object: TypeToken<IData<IFollowContent>>() {}) val follow = gson.fromJson(body, object: TypeToken<IData<IFollowContent?>>() {})
return follow return follow
} catch(e: Exception) { } catch(e: Exception) {
@@ -128,8 +128,8 @@ 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()
@@ -138,7 +138,7 @@ fun getStreamInfo(userId: String) : IData<IStreamInfo> {
try { try {
if(!response.isSuccessful) throw IOException("Unexpected code ${response.code}") if(!response.isSuccessful) throw IOException("Unexpected code ${response.code}")
val body = response.body?.string() val body = response.body?.string()
val follow = gson.fromJson(body, object: TypeToken<IData<IStreamInfo>>() {}) val follow = gson.fromJson(body, object: TypeToken<IData<IStreamInfo?>>() {})
return follow return follow
} catch(e: Exception) { } catch(e: Exception) {

View File

@@ -0,0 +1,20 @@
package space.mori.chzzk_bot.common.utils
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
val logger: Logger = LoggerFactory.getLogger("convertChzzkDateToLocalDateTime")
fun convertChzzkDateToLocalDateTime(chzzkDate: String): LocalDateTime? {
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
return try {
LocalDateTime.parse(chzzkDate, formatter)
} catch(e: DateTimeParseException) {
logger.debug("Error to parsing date", e)
null
}
}

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

@@ -0,0 +1,14 @@
package space.mori.chzzk_bot.common.utils
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
fun getUptime(streamOnTime: LocalDateTime): String {
val currentTime = LocalDateTime.now()
val hours = ChronoUnit.HOURS.between(streamOnTime, currentTime)
val minutes = ChronoUnit.MINUTES.between(streamOnTime?.plusHours(hours), currentTime)
val seconds = ChronoUnit.SECONDS.between(streamOnTime?.plusHours(hours)?.plusMinutes(minutes), currentTime)
return String.format("%02d:%02d:%02d", hours, minutes, seconds)
}

View File

@@ -0,0 +1,115 @@
package space.mori.chzzk_bot.common.utils
import com.google.gson.JsonObject
import io.github.cdimascio.dotenv.dotenv
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.net.URLEncoder
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 dotenv = dotenv {
ignoreIfMissing = true
}
fun searchYoutube(query: String): String? {
val url = "https://youtube-search-results.p.rapidapi.com/youtube-search/?q=${URLEncoder.encode(query, "UTF-8")}"
val request = Request.Builder()
.url(url)
.addHeader("x-rapidapi-host", "youtube-search-results.p.rapidapi.com")
.addHeader("x-rapidapi-key", dotenv["RAPID_KEY"] ?: "")
.build()
OkHttpClient().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 videos = json.getAsJsonArray("videos")
val firstVideo = videos.get(0).asJsonObject
val videoId = firstVideo.get("id").asString
return videoId
}
}
fun getYoutubeVideoId(query: String): String? {
val matchResult = regex.find(query)
return if(matchResult == null) {
searchYoutube(query)
} else {
matchResult.groups[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(query: String): YoutubeVideo? {
val videoId = getYoutubeVideoId(query)
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,contentDetails,status")
.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.getAsJsonObject("contentDetails")
val status = item.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.0 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

@@ -4,5 +4,7 @@ DB_URL=jdbc:mariadb://localhost:3306/chzzk
DB_USER=chzzk DB_USER=chzzk
DB_PASS=chzzk DB_PASS=chzzk
RUN_AGENT=false RUN_AGENT=false
YOUTUBE_API_KEY=
RAPID_KEY=
NID_AUT= NID_AUT=
NID_SES= NID_SES=

View File

@@ -3,12 +3,15 @@ package space.mori.chzzk_bot
import io.github.cdimascio.dotenv.dotenv import io.github.cdimascio.dotenv.dotenv
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.koin.core.context.GlobalContext.startKoin
import org.koin.dsl.module
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chatbot.discord.Discord import space.mori.chzzk_bot.chatbot.discord.Discord
import space.mori.chzzk_bot.chatbot.chzzk.Connector as ChzzkConnector import space.mori.chzzk_bot.chatbot.chzzk.Connector as ChzzkConnector
import space.mori.chzzk_bot.common.Connector import space.mori.chzzk_bot.common.Connector
import space.mori.chzzk_bot.common.events.CoroutinesEventBus
import space.mori.chzzk_bot.webserver.start import space.mori.chzzk_bot.webserver.start
import space.mori.chzzk_bot.webserver.stop import space.mori.chzzk_bot.webserver.stop
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -19,13 +22,20 @@ val dotenv = dotenv {
} }
val logger: Logger = LoggerFactory.getLogger("main") val logger: Logger = LoggerFactory.getLogger("main")
val discord = Discord()
val connector = Connector
val chzzkConnector = ChzzkConnector
val chzzkHandler = ChzzkHandler
fun main(args: Array<String>) { fun main(args: Array<String>) {
val dispatcher = module {
single { CoroutinesEventBus() }
}
startKoin {
modules(dispatcher)
}
val discord = Discord()
val connector = Connector
val chzzkConnector = ChzzkConnector
val chzzkHandler = ChzzkHandler
discord.enable() discord.enable()
chzzkHandler.enable() chzzkHandler.enable()
chzzkHandler.runStreamInfo() chzzkHandler.runStreamInfo()

View File

@@ -29,6 +29,9 @@ dependencies {
// https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-reflect // https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-reflect
implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0") implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0")
// https://mvnrepository.com/artifact/io.insert-koin/koin-core
implementation("io.insert-koin:koin-core:4.0.0-RC1")
// https://mvnrepository.com/artifact/ch.qos.logback/logback-classic // https://mvnrepository.com/artifact/ch.qos.logback/logback-classic
implementation("ch.qos.logback:logback-classic:1.5.6") implementation("ch.qos.logback:logback-classic:1.5.6")

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.*
@@ -11,10 +12,18 @@ 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 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,9 +36,13 @@ val server = embeddedServer(Netty, port = 8080) {
} }
routing { routing {
apiRoutes() apiRoutes()
apiSongRoutes()
wsTimerRoutes()
wsSongRoutes()
wsSongListRoutes()
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

@@ -4,11 +4,34 @@ import io.ktor.http.*
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
import space.mori.chzzk_bot.common.services.SongConfigService
import space.mori.chzzk_bot.common.utils.getStreamInfo
@Serializable
data class GetUserDTO(
val uid: String,
val nickname: String,
val isStreamOn: Boolean,
val avatarUrl: String
)
@Serializable
data class GetSessionDTO(
val uid: String,
val nickname: String,
val isStreamOn: Boolean,
val avatarUrl: String,
val maxQueueSize: Int,
val maxUserSize: Int,
val isStreamerOnly: Boolean,
)
fun Routing.apiRoutes() { fun Routing.apiRoutes() {
route("/") { route("/") {
get { get {
call.respondText("Hello World!", status = HttpStatusCode.OK) call.respondText("Hello World!", status =
HttpStatusCode.OK)
} }
} }
route("/health") { route("/health") {
@@ -16,4 +39,62 @@ fun Routing.apiRoutes() {
call.respondText("OK", status= HttpStatusCode.OK) call.respondText("OK", status= HttpStatusCode.OK)
} }
} }
route("/user/{uid}") {
get {
val uid = call.parameters["uid"]
if(uid == null) {
call.respondText("Require UID", status = HttpStatusCode.NotFound)
return@get
}
val user = getStreamInfo(uid)
if(user.content == null) {
call.respondText("User not found", status = HttpStatusCode.NotFound)
return@get
} else {
call.respond(HttpStatusCode.OK, GetUserDTO(
user.content!!.channel.channelId,
user.content!!.channel.channelName,
user.content!!.status == "OPEN",
user.content!!.channel.channelImageUrl
))
}
}
}
route("/user") {
get {
call.respondText("Require UID", status = HttpStatusCode.NotFound)
}
}
route("/session/{sid}") {
get {
val sid = call.parameters["sid"]
if(sid == null) {
call.respondText("Require SID", status = HttpStatusCode.NotFound)
return@get
}
val user = SongConfigService.getUserByToken(sid)
val session = SongConfigService.getConfig(sid)
if(user == null) {
call.respondText("User not found", status = HttpStatusCode.NotFound)
return@get
} else {
val chzzkUser = getStreamInfo(user.token)
call.respond(HttpStatusCode.OK, GetSessionDTO(
chzzkUser.content!!.channel.channelId,
chzzkUser.content!!.channel.channelName,
chzzkUser.content!!.status == "OPEN",
chzzkUser.content!!.channel.channelImageUrl,
session!!.queueLimit,
session.personalLimit,
session.streamerOnly
))
}
}
}
route("/session") {
get {
call.respondText("Require SID", status = HttpStatusCode.NotFound)
}
}
} }

View File

@@ -0,0 +1,48 @@
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 kotlinx.serialization.Serializable
import space.mori.chzzk_bot.common.models.SongList
import space.mori.chzzk_bot.common.services.SongListService
import space.mori.chzzk_bot.common.services.UserService
@Serializable
data class SongsDTO(
val url: String,
val name: String,
val author: String,
val time: Int,
val reqName: String
)
fun SongList.toDTO(): SongsDTO = SongsDTO(
this.url,
this.name,
this.author,
this.time,
this.reqName
)
fun Routing.apiSongRoutes() {
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(HttpStatusCode.OK, songs.map { it.toDTO() })
}
}
route("/songs") {
get {
call.respondText("Require UID", status= HttpStatusCode.BadRequest)
}
}
}

View File

@@ -0,0 +1,210 @@
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 kotlinx.serialization.json.Json
import org.koin.java.KoinJavaComponent.inject
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.common.events.*
import space.mori.chzzk_bot.common.models.Counters.withDefinition
import space.mori.chzzk_bot.common.services.SongConfigService
import space.mori.chzzk_bot.common.services.SongListService
import space.mori.chzzk_bot.common.services.UserService
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("WSSongListRoutes")
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
fun addSession(sid: String, session: WebSocketServerSession) {
sessions.computeIfAbsent(sid) { ConcurrentLinkedQueue() }.add(session)
}
fun removeSession(sid: String, session: WebSocketServerSession) {
sessions[sid]?.remove(session)
if(sessions[sid]?.isEmpty() == true) {
sessions.remove(sid)
}
}
webSocket("/songlist/{sid}") {
val sid = call.parameters["sid"]
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) {
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,
null
))
}
removeSession(sid, this)
}
try {
for (frame in incoming) {
when(frame) {
is Frame.Text -> {
val data = frame.readText().let { Json.decodeFromString<SongRequest>(it) }
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.type == SongType.ADD.value && data.url != null) {
try {
val youtubeVideo = getYoutubeVideo(data.url)
if (youtubeVideo != null) {
CoroutineScope(Dispatchers.Default).launch {
SongListService.saveSong(
user,
user.token,
data.url,
youtubeVideo.name,
youtubeVideo.author,
youtubeVideo.length,
user.username
)
dispatcher.post(
SongEvent(
user.token,
SongType.ADD,
user.token,
user.username,
youtubeVideo.name,
youtubeVideo.author,
youtubeVideo.length,
youtubeVideo.url
)
)
}
}
} catch(e: Exception) {
logger.debug("SongType.ADD Error: {} / {}", session.token, e)
}
}
else if(data.type == SongType.REMOVE.value && data.url != null) {
dispatcher.post(SongEvent(
user.token,
SongType.REMOVE,
null,
null,
null,
null,
0,
data.url
))
} else if(data.type == SongType.NEXT.value) {
val song = SongListService.getSong(user)[0]
SongListService.deleteSong(user, song.uid, song.name)
dispatcher.post(SongEvent(
user.token,
SongType.NEXT,
null,
null,
null,
null,
null,
null
))
}
}
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 {
val user = UserService.getUser(it.uid)
if(user != null) {
val session = SongConfigService.getConfig(user)
sessions[session.token ?: ""]?.forEach { ws ->
ws.sendSerialized(
SongResponse(
it.type.value,
it.uid,
it.reqUid,
it.name,
it.author,
it.time,
it.url
)
)
}
}
}
}
dispatcher.subscribe(TimerEvent::class) {
if(it.type == TimerType.STREAM_OFF) {
CoroutineScope(Dispatchers.Default).launch {
val user = UserService.getUser(it.uid)
if(user != null) {
val session = SongConfigService.getConfig(user)
sessions[session.token ?: ""]?.forEach { ws ->
ws.sendSerialized(
SongResponse(
it.type.value,
it.uid,
null,
null,
null,
null,
null
)
)
removeSession(session.token ?: "", ws)
}
}
}
}
}
}
@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

@@ -0,0 +1,127 @@
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, SongType>()
val logger = LoggerFactory.getLogger("WSSongRoutes")
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] == SongType.STREAM_OFF) {
CoroutineScope(Dispatchers.Default).launch {
sendSerialized(SongResponse(
SongType.STREAM_OFF.value,
uid,
null,
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.reqUid,
it.name,
it.author,
it.time,
it.url
))
}
}
}
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,
null
))
}
}
}
}
}
@Serializable
data class SongResponse(
val type: Int,
val uid: String,
val reqUid: String?,
val name: String?,
val author: String?,
val time: Int?,
val url: String?
)

View File

@@ -0,0 +1,98 @@
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.TimerConfigService
import space.mori.chzzk_bot.common.services.UserService
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
fun Routing.wsTimerRoutes() {
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
val status = ConcurrentHashMap<String, TimerType>()
val logger = LoggerFactory.getLogger("WSTimerRoutes")
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("/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
}
addSession(uid, this)
if(status[uid] == TimerType.STREAM_OFF) {
CoroutineScope(Dispatchers.Default).launch {
sendSerialized(TimerResponse(TimerType.STREAM_OFF.value, null))
}
} else {
CoroutineScope(Dispatchers.Default).launch {
sendSerialized(TimerResponse(
TimerConfigService.getConfig(user)?.option ?: TimerType.REMOVE.value,
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(TimerEvent::class) {
logger.debug("TimerEvent: {} / {}", it.uid, it.type)
status[it.uid] = it.type
CoroutineScope(Dispatchers.Default).launch {
sessions[it.uid]?.forEach { ws ->
ws.sendSerialized(TimerResponse(it.type.value, it.time ?: ""))
}
}
}
}
@Serializable
data class TimerResponse(
val type: Int,
val time: String?
)

View File

@@ -8,8 +8,7 @@ servers:
paths: paths:
/: /:
get: get:
summary: "Webroot" description: ""
description: "Main page of this api"
responses: responses:
"200": "200":
description: "OK" description: "OK"
@@ -22,7 +21,7 @@ paths:
value: "Hello World!" value: "Hello World!"
/health: /health:
get: get:
description: "Health Check endpoint" description: ""
responses: responses:
"200": "200":
description: "OK" description: "OK"
@@ -32,4 +31,152 @@ paths:
type: "string" type: "string"
examples: examples:
Example#1: Example#1:
value: "OK" value: "OK"
/song/{uid}:
get:
description: ""
parameters:
- name: "uid"
in: "path"
required: true
schema:
type: "string"
- name: "Connection"
in: "header"
required: true
description: "Websocket Connection parameter"
schema:
type: "string"
- name: "Upgrade"
in: "header"
required: true
description: "Websocket Upgrade parameter"
schema:
type: "string"
- name: "Sec-WebSocket-Key"
in: "header"
required: true
description: "Websocket Sec-WebSocket-Key parameter"
schema:
type: "string"
responses:
"101":
description: "Switching Protocols"
headers:
Connection:
required: true
schema:
type: "string"
Upgrade:
required: true
schema:
type: "string"
Sec-WebSocket-Accept:
required: true
schema:
type: "string"
/songs:
get:
description: ""
responses:
"400":
description: "Bad Request"
content:
text/plain:
schema:
type: "string"
examples:
Example#1:
value: "Require UID"
/songs/{uid}:
get:
description: ""
parameters:
- name: "uid"
in: "path"
required: true
schema:
type: "string"
responses:
"404":
description: "Not Found"
content:
text/plain:
schema:
type: "string"
examples:
Example#1:
value: "No user found"
"200":
description: "OK"
content:
'*/*':
schema:
type: "array"
items:
$ref: "#/components/schemas/SongList"
/timer/{uid}:
get:
description: ""
parameters:
- name: "uid"
in: "path"
required: true
schema:
type: "string"
- name: "Connection"
in: "header"
required: true
description: "Websocket Connection parameter"
schema:
type: "string"
- name: "Upgrade"
in: "header"
required: true
description: "Websocket Upgrade parameter"
schema:
type: "string"
- name: "Sec-WebSocket-Key"
in: "header"
required: true
description: "Websocket Sec-WebSocket-Key parameter"
schema:
type: "string"
responses:
"101":
description: "Switching Protocols"
headers:
Connection:
required: true
schema:
type: "string"
Upgrade:
required: true
schema:
type: "string"
Sec-WebSocket-Accept:
required: true
schema:
type: "string"
components:
schemas:
Object:
type: "object"
properties: {}
ResultRow:
type: "object"
properties:
fieldIndex:
type: "object"
required:
- "fieldIndex"
SongList:
type: "object"
properties:
writeValues:
$ref: "#/components/schemas/Object"
_readValues:
$ref: "#/components/schemas/ResultRow"
required:
- "id"
- "writeValues"