Compare commits

..

No commits in common. "main" and "1.2.0" have entirely different histories.
main ... 1.2.0

58 changed files with 1166 additions and 2447 deletions

2
.idea/kotlinc.xml generated
View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.21" /> <option name="version" value="2.0.0" />
</component> </component>
</project> </project>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/modules/chzzk_bot.main.iml" filepath="$PROJECT_DIR$/.idea/modules/chzzk_bot.main.iml" />
</modules>
</component>
</project>

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

4
.idea/sqldialects.xml generated
View File

@ -3,8 +3,4 @@
<component name="SqlDialectMappings"> <component name="SqlDialectMappings">
<file url="PROJECT" dialect="MariaDB" /> <file url="PROJECT" dialect="MariaDB" />
</component> </component>
<component name="SqlResolveMappings">
<file url="file://$PROJECT_DIR$/common/src/main/kotlin/space/mori/chzzk_bot/common/models/User.kt" scope="{&quot;node&quot;:{ &quot;@negative&quot;:&quot;1&quot;, &quot;group&quot;:{ &quot;@kind&quot;:&quot;root&quot;, &quot;node&quot;:{ &quot;name&quot;:{ &quot;@qname&quot;:&quot;90f8ee11-600e-4155-a316-e8062c7c828b&quot; }, &quot;group&quot;:{ &quot;@kind&quot;:&quot;schema&quot;, &quot;node&quot;:{ &quot;name&quot;:{ &quot;@qname&quot;:&quot;chzzk&quot; } } } } } }}" />
<file url="PROJECT" scope="{&quot;node&quot;:{ &quot;@negative&quot;:&quot;1&quot;, &quot;group&quot;:{ &quot;@kind&quot;:&quot;root&quot;, &quot;node&quot;:{ &quot;name&quot;:{ &quot;@qname&quot;:&quot;90f8ee11-600e-4155-a316-e8062c7c828b&quot; }, &quot;group&quot;:{ &quot;@kind&quot;:&quot;schema&quot;, &quot;node&quot;:{ &quot;name&quot;:{ &quot;@qname&quot;:&quot;chzzk&quot; } } } } } }}" />
</component>
</project> </project>

View File

@ -18,7 +18,7 @@
- [x] \<days:yyyy-mm-dd> - [x] \<days:yyyy-mm-dd>
### 관리 명령어 (on Discord) ### 관리 명령어 (on Discord)
- [x] /hook token: \[디스코드 연동 페이지에서 받은 Token] - [x] /register chzzk_id: \[치지직 고유ID]
- [x] /alert channel: \[디스코드 Channel ID] content: \[알림 내용] - [x] /alert channel: \[디스코드 Channel ID] content: \[알림 내용]
- [x] /add label: \[명령어] content: \[내용] - [x] /add label: \[명령어] content: \[내용]
- [ ] /list - [ ] /list
@ -79,5 +79,3 @@
- [mariadb](https://mariadb.org/) - [mariadb](https://mariadb.org/)
- [docker](https://www.docker.com/) - [docker](https://www.docker.com/)
- [Teamcity](https://www.jetbrains.com/teamcity/) - [Teamcity](https://www.jetbrains.com/teamcity/)
- [Nuxtjs](https://nuxt.com/)
- [Bulma](https://bulma.io/)

View File

@ -1,5 +1,5 @@
plugins { plugins {
val kotlinVersion = "2.0.21" val kotlinVersion = "2.0.0"
id("java") id("java")
id("application") id("application")
@ -28,21 +28,21 @@ repositories {
dependencies { dependencies {
// 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.13") implementation("ch.qos.logback:logback-classic:1.5.6")
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core // https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0-RC")
// 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.21") implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0")
// https://mvnrepository.com/artifact/com.google.code.gson/gson // https://mvnrepository.com/artifact/com.google.code.gson/gson
implementation("com.google.code.gson:gson:2.11.0") implementation("com.google.code.gson:gson:2.11.0")
// 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.2") implementation("io.github.cdimascio:dotenv-kotlin:6.4.1")
// https://mvnrepository.com/artifact/io.insert-koin/koin-core // https://mvnrepository.com/artifact/io.insert-koin/koin-core
implementation("io.insert-koin:koin-core:4.0.0") implementation("io.insert-koin:koin-core:4.0.0-RC1")
kotlin("stdlib") kotlin("stdlib")

View File

@ -11,32 +11,32 @@ repositories {
dependencies { dependencies {
// https://mvnrepository.com/artifact/net.dv8tion/JDA // https://mvnrepository.com/artifact/net.dv8tion/JDA
api("net.dv8tion:JDA:5.2.1") { api("net.dv8tion:JDA:5.0.1") {
exclude(module = "opus-java") exclude(module = "opus-java")
} }
// https://mvnrepository.com/artifact/io.github.R2turnTrue/chzzk4j // https://mvnrepository.com/artifact/io.github.R2turnTrue/chzzk4j
implementation("io.github.R2turnTrue:chzzk4j:0.1.1") implementation("io.github.R2turnTrue:chzzk4j:0.0.9")
// 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.13") implementation("ch.qos.logback:logback-classic:1.5.6")
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core // https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0-RC")
// 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.21") implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0")
// https://mvnrepository.com/artifact/com.google.code.gson/gson // https://mvnrepository.com/artifact/com.google.code.gson/gson
implementation("com.google.code.gson:gson:2.11.0") implementation("com.google.code.gson:gson:2.11.0")
// 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.2") implementation("io.github.cdimascio:dotenv-kotlin:6.4.1")
// 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 // https://mvnrepository.com/artifact/io.insert-koin/koin-core
implementation("io.insert-koin:koin-core:4.0.0") implementation("io.insert-koin:koin-core:4.0.0-RC1")
testImplementation(kotlin("test")) testImplementation(kotlin("test"))

View File

@ -1,84 +1,47 @@
package space.mori.chzzk_bot.chatbot.chzzk package space.mori.chzzk_bot.chatbot.chzzk
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject 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.getChannel 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.chatbot.utils.refreshAccessToken
import space.mori.chzzk_bot.common.events.* import space.mori.chzzk_bot.common.events.*
import space.mori.chzzk_bot.common.models.User import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.services.LiveStatusService import space.mori.chzzk_bot.common.services.LiveStatusService
import space.mori.chzzk_bot.common.services.TimerConfigService 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 space.mori.chzzk_bot.common.utils.*
import xyz.r2turntrue.chzzk4j.ChzzkClient import xyz.r2turntrue.chzzk4j.chat.ChatEventListener
import xyz.r2turntrue.chzzk4j.session.ChzzkSessionBuilder import xyz.r2turntrue.chzzk4j.chat.ChatMessage
import xyz.r2turntrue.chzzk4j.session.ChzzkSessionSubscriptionType import xyz.r2turntrue.chzzk4j.chat.ChzzkChat
import xyz.r2turntrue.chzzk4j.session.ChzzkUserSession
import xyz.r2turntrue.chzzk4j.session.event.SessionChatMessageEvent
import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel
import xyz.r2turntrue.chzzk4j.types.channel.live.ChzzkLiveDetail
import java.lang.Exception import java.lang.Exception
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.time.LocalDateTime import java.time.LocalDateTime
import java.nio.charset.Charset
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 lateinit var botUid: String
@Volatile private var running: Boolean = false @Volatile private var running: Boolean = false
private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
fun addUser(chzzkChannel: ChzzkChannel, user: User) { fun addUser(chzzkChannel: ChzzkChannel, user: User) {
handlers.add(UserHandler(chzzkChannel, logger, user, streamStartTime = LocalDateTime.now())) handlers.add(UserHandler(chzzkChannel, logger, user, streamStartTime = null))
} }
fun enable() { fun enable() {
botUid = Connector.client.fetchLoggedUser().userId botUid = chzzk.loggedUser.userId
UserService.getAllUsers().map { UserService.getAllUsers().map {
if(!it.isDisabled) chzzk.getChannel(it.token)?.let { token -> addUser(token, it) }
try {
Connector.getChannel(it.token)?.let { token -> addUser(token, it) }
} catch(e: Exception) {
logger.info("Exception: ${it.token}(${it.username}) not found. ${e.stackTraceToString()}")
}
} }
handlers.forEach { handler -> handlers.forEach { handler ->
val streamInfo = Connector.getLive(handler.channel.channelId) val streamInfo = getStreamInfo(handler.listener.channelId)
if (streamInfo?.isOnline == true) handler.isActive(true, streamInfo) if (streamInfo.content?.status == "OPEN") handler.isActive(true, streamInfo)
}
dispatcher.subscribe(UserRegisterEvent::class) {
val channel = getChannel(it.chzzkId)
val user = UserService.getUser(it.chzzkId)
if(channel != null && user != null) {
addUser(channel, user)
}
}
dispatcher.subscribe(CommandReloadEvent::class) {
handlers.firstOrNull { handlers -> handlers.channel.channelId == it.uid }?.reloadCommand()
}
dispatcher.subscribe(BotEnabledEvent::class) {
if(it.isDisabled) {
handlers.removeIf { handlers -> handlers.channel.channelId == it.chzzkId }
} else {
val channel = getChannel(it.chzzkId)
val user = UserService.getUser(it.chzzkId)
if(channel != null && user != null) {
addUser(channel, user)
}
}
} }
} }
@ -106,90 +69,27 @@ object ChzzkHandler {
fun runStreamInfo() { fun runStreamInfo() {
running = true running = true
val thread = Thread({
val threadRunner1 = Runnable { while(running) {
logger.info("Thread 1 started!")
while (running) {
handlers.forEach { handlers.forEach {
if (!running) return@forEach if (!running) return@forEach
try { try {
val streamInfo = Connector.getLive(it.channel.channelId) val streamInfo = getStreamInfo(it.channel.channelId)
if (streamInfo?.isOnline == true && !it.isActive) { if (streamInfo.content?.status == "OPEN" && !it.isActive) it.isActive(true, streamInfo)
try { if (streamInfo.content?.status == "CLOSE" && it.isActive) it.isActive(false, streamInfo)
it.isActive(true, streamInfo) } catch(e: SocketTimeoutException) {
} catch(e: Exception) { logger.info("Timeout: ${it.channel.channelName} / ${e.stackTraceToString()}")
logger.info("Exception: ${e.stackTraceToString()}")
}
}
if (streamInfo?.isOnline == false && it.isActive) it.isActive(false, streamInfo)
} catch (e: SocketTimeoutException) {
logger.info("Thread 1 Timeout: ${it.channel.channelName} / ${e.stackTraceToString()}")
} catch (e: Exception) { } catch (e: Exception) {
logger.info("Thread 1 Exception: ${it.channel.channelName} / ${e.stackTraceToString()}") logger.info("Exception: ${it.channel.channelName} / ${e.stackTraceToString()}")
} finally { } finally {
Thread.sleep(5000) Thread.sleep(5000)
} }
} }
Thread.sleep(60000) Thread.sleep(60000)
} }
} }, "Chzzk-StreamInfo")
val threadRunner2 = Runnable { thread.start()
logger.info("Thread 2 started!")
logger.info("Thread 2 started!")
while (running) {
handlers.forEach {
if (!running) return@forEach
try {
val streamInfo = Connector.getLive(it.channel.channelId)
if (streamInfo?.isOnline == true && !it.isActive) {
try {
it.isActive(true, streamInfo)
} catch(e: Exception) {
logger.info("Exception: ${e.stackTraceToString()}")
}
}
if (streamInfo?.isOnline == false && it.isActive) it.isActive(false, streamInfo)
} catch (e: SocketTimeoutException) {
logger.info("Thread 1 Timeout: ${it.channel.channelName} / ${e.stackTraceToString()}")
} catch (e: Exception) {
logger.info("Thread 1 Exception: ${it.channel.channelName} / ${e.stackTraceToString()}")
} finally {
Thread.sleep(5000)
}
}
Thread.sleep(60000)
}
}
fun startThread(name: String, runner: Runnable) {
Thread({
while(running) {
try {
val thread = Thread(runner, name)
thread.start()
thread.join()
} catch(e: Exception) {
logger.error("Thread $name Exception: ${e.stackTraceToString()}")
}
if(running) {
logger.info("Thread $name restart in 5 seconds")
Thread.sleep(5000)
}
}
}, "${name}-runner").start()
}
// 첫 번째 스레드 시작
startThread("Chzzk-StreamInfo-1", threadRunner1)
// 85초 대기 후 두 번째 스레드 시작
CoroutineScope(Dispatchers.Default).launch {
delay(95000) // start with 95 secs after.
if (running) {
startThread("Chzzk-StreamInfo-2", threadRunner2)
}
}
} }
fun stopStreamInfo() { fun stopStreamInfo() {
@ -197,18 +97,13 @@ object ChzzkHandler {
} }
} }
@OptIn(DelicateCoroutinesApi::class)
class UserHandler( class UserHandler(
val channel: ChzzkChannel, val channel: ChzzkChannel,
val logger: Logger, val logger: Logger,
private var user: User, private var user: User,
var streamStartTime: LocalDateTime?, var streamStartTime: LocalDateTime?,
) { ) {
var messageHandler: MessageHandler private lateinit var messageHandler: MessageHandler
var client: ChzzkClient
var listener: ChzzkUserSession
var chatChannelId: String?
private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java) private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
private var _isActive: Boolean private var _isActive: Boolean
get() = LiveStatusService.getLiveStatus(user)?.status ?: false get() = LiveStatusService.getLiveStatus(user)?.status ?: false
@ -216,58 +111,33 @@ class UserHandler(
LiveStatusService.updateOrCreate(user, value) LiveStatusService.updateOrCreate(user, value)
} }
init { var listener: ChzzkChat = chzzk.chat(channel.channelId)
val user = UserService.getUser(channel.channelId) .withAutoReconnect(true)
.withChatListener(object : ChatEventListener {
if(user?.accessToken == null || user.refreshToken == null) { override fun onConnect(chat: ChzzkChat, isReconnecting: Boolean) {
throw RuntimeException("AccessToken or RefreshToken is not valid.") logger.info("ChzzkChat connected. ${channel.channelName} - ${channel.channelId} / reconnected: $isReconnecting")
} messageHandler = MessageHandler(this@UserHandler)
try {
val tokens = user.refreshToken?.let { token -> Connector.client.refreshAccessToken(token) }
if(tokens == null) {
throw RuntimeException("AccessToken is not valid.")
}
client = Connector.getClient(tokens.first, tokens.second)
UserService.setRefreshToken(user, tokens.first, tokens.second)
chatChannelId = getChzzkChannelId(channel.channelId)
client.loginAsync().join()
listener = ChzzkSessionBuilder(client).buildUserSession()
listener.createAndConnectAsync().join()
messageHandler = MessageHandler(this@UserHandler)
listener.on(SessionChatMessageEvent::class.java) {
messageHandler.handle(it.message, user)
} }
GlobalScope.launch { override fun onError(ex: Exception) {
val timer = TimerConfigService.getConfig(user) logger.info("ChzzkChat error. ${channel.channelName} - ${channel.channelId}")
if (timer?.option == TimerType.UPTIME.value) logger.debug(ex.stackTraceToString())
dispatcher.post(
TimerEvent(
channel.channelId,
TimerType.UPTIME,
getUptime(streamStartTime!!)
)
)
else dispatcher.post(
TimerEvent(
channel.channelId,
TimerType.entries.firstOrNull { it.value == timer?.option } ?: TimerType.REMOVE,
null
)
)
} }
} catch(e: Exception) { override fun onChat(msg: ChatMessage) {
logger.error("Exception(${user.username}): ${e.stackTraceToString()}") if(!_isActive) return
throw RuntimeException("Exception: ${e.stackTraceToString()}") messageHandler.handle(msg, user)
} }
}
override fun onConnectionClosed(code: Int, reason: String?, remote: Boolean, tryingToReconnect: Boolean) {
logger.info("ChzzkChat closed. ${channel.channelName} - ${channel.channelId}")
logger.info("Reason: $reason / $tryingToReconnect")
}
})
.build()
internal fun disable() { internal fun disable() {
listener.disconnectAsync().join() listener.closeAsync()
_isActive = false
} }
internal fun reloadCommand() { internal fun reloadCommand() {
@ -281,20 +151,17 @@ class UserHandler(
internal val isActive: Boolean internal val isActive: Boolean
get() = _isActive get() = _isActive
internal fun isActive(value: Boolean, status: ChzzkLiveDetail) { internal fun isActive(value: Boolean, status: IData<IStreamInfo?>) {
if(value) { if(value) {
logger.info("${user.username} is live.")
logger.info("ChzzkChat connecting... ${channel.channelName} - ${channel.channelId}")
listener.connectBlocking()
streamStartTime = status.content?.openDate?.let { convertChzzkDateToLocalDateTime(it) }
CoroutineScope(Dispatchers.Default).launch { CoroutineScope(Dispatchers.Default).launch {
logger.info("${user.username} is live.")
reloadUser(UserService.getUser(user.id.value)!!)
logger.info("ChzzkChat connecting... ${channel.channelName} - ${channel.channelId}")
listener.subscribeAsync(ChzzkSessionSubscriptionType.CHAT).join()
streamStartTime = LocalDateTime.now()
if(!_isActive) { if(!_isActive) {
_isActive = true
when(TimerConfigService.getConfig(UserService.getUser(channel.channelId)!!)?.option) { when(TimerConfigService.getConfig(UserService.getUser(channel.channelId)!!)?.option) {
TimerType.UPTIME.value -> dispatcher.post( TimerType.UPTIME.value -> dispatcher.post(
TimerEvent( TimerEvent(
@ -314,8 +181,7 @@ class UserHandler(
} }
delay(5000L) delay(5000L)
try { try {
if(!user.isDisableStartupMsg) listener.sendChat("${user.username} 님! 오늘도 열심히 방송하세요!")
sendChat("${user.username} 님! 오늘도 열심히 방송하세요!")
Discord.sendDiscord(user, status) Discord.sendDiscord(user, status)
} catch(e: Exception) { } catch(e: Exception) {
logger.info("Stream on logic has some error: ${e.stackTraceToString()}") logger.info("Stream on logic has some error: ${e.stackTraceToString()}")
@ -325,8 +191,7 @@ class UserHandler(
} else { } else {
logger.info("${user.username} is offline.") logger.info("${user.username} is offline.")
streamStartTime = null streamStartTime = null
listener.disconnectAsync().join() listener.closeAsync()
_isActive = false
CoroutineScope(Dispatchers.Default).launch { CoroutineScope(Dispatchers.Default).launch {
val events = listOf( val events = listOf(
@ -342,29 +207,13 @@ class UserHandler(
null, null,
null, null,
null, null,
null,
null
) )
) )
events.forEach { dispatcher.post(it) } events.forEach { dispatcher.post(it) }
} }
} }
_isActive = value
} }
}
private fun String.limitUtf8Length(maxBytes: Int): String {
val bytes = this.toByteArray(Charset.forName("UTF-8"))
if (bytes.size <= maxBytes) return this
var truncatedString = this
while (truncatedString.toByteArray(Charset.forName("UTF-8")).size > maxBytes) {
truncatedString = truncatedString.substring(0, truncatedString.length - 1)
}
return truncatedString
}
@OptIn(DelicateCoroutinesApi::class)
internal fun sendChat(msg: String) {
GlobalScope.launch {
delay(100L)
client.sendChatToLoggedInChannel(msg.limitUtf8Length(100))
}
}
}

View File

@ -1,66 +1,24 @@
package space.mori.chzzk_bot.chatbot.chzzk package space.mori.chzzk_bot.chatbot.chzzk
import io.github.cdimascio.dotenv.dotenv import io.github.cdimascio.dotenv.dotenv
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.common.events.ChzzkUserFindEvent import xyz.r2turntrue.chzzk4j.Chzzk
import space.mori.chzzk_bot.common.events.ChzzkUserReceiveEvent import xyz.r2turntrue.chzzk4j.ChzzkBuilder
import space.mori.chzzk_bot.common.events.CoroutinesEventBus
import xyz.r2turntrue.chzzk4j.ChzzkClient
import xyz.r2turntrue.chzzk4j.ChzzkClientBuilder
import xyz.r2turntrue.chzzk4j.auth.ChzzkLegacyLoginAdapter
import xyz.r2turntrue.chzzk4j.auth.ChzzkSimpleUserLoginAdapter
import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel
import xyz.r2turntrue.chzzk4j.types.channel.live.ChzzkLiveDetail
import kotlin.getValue
val dotenv = dotenv { val dotenv = dotenv {
ignoreIfMissing = true ignoreIfMissing = true
} }
@OptIn(DelicateCoroutinesApi::class)
object Connector { object Connector {
val adapter = ChzzkLegacyLoginAdapter(dotenv["NID_AUT"], dotenv["NID_SES"]) val chzzk: Chzzk = ChzzkBuilder()
val client: ChzzkClient = ChzzkClientBuilder(dotenv["NAVER_CLIENT_ID"], dotenv["NAVER_CLIENT_SECRET"]) .withAuthorization(dotenv["NID_AUT"], dotenv["NID_SES"])
.withLoginAdapter(adapter)
.build() .build()
private val logger = LoggerFactory.getLogger(this::class.java) private val logger = LoggerFactory.getLogger(this::class.java)
private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
fun getChannel(channelId: String): ChzzkChannel? = client.fetchChannel(channelId) fun getChannel(channelId: String): ChzzkChannel? = chzzk.getChannel(channelId)
fun getLive(channelId: String): ChzzkLiveDetail? = client.fetchLiveDetail(channelId)
init { init {
logger.info("chzzk logged: ${client.isLoggedIn}") logger.info("chzzk logged: ${chzzk.isLoggedIn} / ${chzzk.loggedUser?.nickname ?: "----"}")
client.loginAsync().join()
dispatcher.subscribe(ChzzkUserFindEvent::class) { event ->
GlobalScope.launch {
val user = getChannel(event.uid)
dispatcher.post(ChzzkUserReceiveEvent(
find = user != null,
uid = user?.channelId,
nickname = user?.channelName,
isStreamOn = user?.isBroadcasting,
avatarUrl = user?.channelImageUrl
))
}
}
} }
fun getClient(accessToken: String, refreshToken: String): ChzzkClient {
val adapter = ChzzkSimpleUserLoginAdapter(accessToken, refreshToken)
val client = ChzzkClientBuilder(dotenv["NAVER_CLIENT_ID"], dotenv["NAVER_CLIENT_SECRET"])
.withLoginAdapter(adapter)
.build()
return client
}
} }

View File

@ -13,16 +13,16 @@ import space.mori.chzzk_bot.common.utils.getUptime
import space.mori.chzzk_bot.common.utils.getYoutubeVideo import space.mori.chzzk_bot.common.utils.getYoutubeVideo
import xyz.r2turntrue.chzzk4j.chat.ChatMessage import xyz.r2turntrue.chzzk4j.chat.ChatMessage
import xyz.r2turntrue.chzzk4j.chat.ChzzkChat import xyz.r2turntrue.chzzk4j.chat.ChzzkChat
import xyz.r2turntrue.chzzk4j.session.ChzzkUserSession
import xyz.r2turntrue.chzzk4j.session.message.SessionChatMessage
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 handler: UserHandler private val handler: UserHandler
) { ) {
private val commands = mutableMapOf<String, (msg: SessionChatMessage, user: User) -> Unit>() private val commands = mutableMapOf<String, (msg: ChatMessage, user: User) -> Unit>()
private val counterPattern = Regex("<counter:([^>]+)>") private val counterPattern = Regex("<counter:([^>]+)>")
private val personalCounterPattern = Regex("<counter_personal:([^>]+)>") private val personalCounterPattern = Regex("<counter_personal:([^>]+)>")
@ -43,7 +43,7 @@ class MessageHandler(
if(it.type == SongType.STREAM_OFF) { if(it.type == SongType.STREAM_OFF) {
val user = UserService.getUser(channel.channelId) val user = UserService.getUser(channel.channelId)
if(! user?.let { usr -> SongListService.getSong(usr) }.isNullOrEmpty()) { if(! user?.let { usr -> SongListService.getSong(usr) }.isNullOrEmpty()) {
SongListService.deleteUser(user) SongListService.deleteUser(user!!)
} }
} }
} }
@ -54,14 +54,13 @@ class MessageHandler(
?: 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( val manageCommands = mapOf(
"!명령어" to this::commandListCommand,
"!명령어추가" to this::manageAddCommand, "!명령어추가" to this::manageAddCommand,
"!명령어삭제" to this::manageRemoveCommand, "!명령어삭제" to this::manageRemoveCommand,
"!명령어수정" to this::manageUpdateCommand, "!명령어수정" to this::manageUpdateCommand,
"!시간" to this::timerCommand, "!시간" to this::timerCommand,
"!신청곡" to this::songAddCommand, "!노래추가" to this::songAddCommand,
"!노래목록" to this::songListCommand, "!노래목록" to this::songListCommand,
"!노래시작" to this::songStartCommand, "!노래시작" to this::songStartCommand
) )
manageCommands.forEach { (commandName, command) -> manageCommands.forEach { (commandName, command) ->
@ -72,90 +71,81 @@ class MessageHandler(
this.commands.put(it.command.lowercase()) { msg, user -> this.commands.put(it.command.lowercase()) { msg, user ->
logger.debug("${channel.channelName} - ${it.command} - ${it.content}/${it.failContent}") logger.debug("${channel.channelName} - ${it.command} - ${it.content}/${it.failContent}")
val result = replaceCounters( val result = replaceCounters(Pair(it.content, it.failContent), user, msg, listener, msg.profile?.nickname ?: "")
Pair(it.content, it.failContent), listener.sendChat(result)
user,
msg,
msg.profile?.nickname ?: ""
)
handler.sendChat(result)
} }
} }
} }
private fun commandListCommand(msg: SessionChatMessage, user: User) { private fun manageAddCommand(msg: ChatMessage, user: User) {
handler.sendChat("리스트는 여기입니다. https://nabot.mori.space/commands/${user.token}") if (msg.profile?.userRoleCode == "common_user") {
} listener.sendChat("매니저만 명령어를 추가할 수 있습니다.")
private fun manageAddCommand(msg: SessionChatMessage, user: User) {
if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 명령어를 추가할 수 있습니다.")
return return
} }
val parts = msg.content.split(" ", limit = 3) val parts = msg.content.split(" ", limit = 3)
if (parts.size < 3) { if (parts.size < 3) {
handler.sendChat("명령어 추가 형식은 '!명령어추가 명령어 내용'입니다.") listener.sendChat("명령어 추가 형식은 '!명령어추가 명령어 내용'입니다.")
return return
} }
if (commands.containsKey(parts[1])) { if (commands.containsKey(parts[1])) {
handler.sendChat("${parts[1]} 명령어는 이미 있는 명령어입니다.") listener.sendChat("${parts[1]} 명령어는 이미 있는 명령어입니다.")
return return
} }
val command = parts[1] val command = parts[1]
val content = parts[2] val content = parts[2]
CommandService.saveCommand(user, command, content, "") CommandService.saveCommand(user, command, content, "")
handler.sendChat("명령어 '$command' 추가되었습니다.") listener.sendChat("명령어 '$command' 추가되었습니다.")
} }
private fun manageUpdateCommand(msg: SessionChatMessage, user: User) { private fun manageUpdateCommand(msg: ChatMessage, user: User) {
if (msg.profile.badges.none { it.imageUrl.contains("manager") }) { if (msg.profile?.userRoleCode == "common_user") {
handler.sendChat("매니저만 명령어를 추가할 수 있습니다.") listener.sendChat("매니저만 명령어를 추가할 수 있습니다.")
return return
} }
val parts = msg.content.split(" ", limit = 3) val parts = msg.content.split(" ", limit = 3)
if (parts.size < 3) { if (parts.size < 3) {
handler.sendChat("명령어 수정 형식은 '!명령어수정 명령어 내용'입니다.") listener.sendChat("명령어 수정 형식은 '!명령어수정 명령어 내용'입니다.")
return return
} }
if (!commands.containsKey(parts[1])) { if (!commands.containsKey(parts[1])) {
handler.sendChat("${parts[1]} 명령어는 없는 명령어입니다.") listener.sendChat("${parts[1]} 명령어는 없는 명령어입니다.")
return return
} }
val command = parts[1] val command = parts[1]
val content = parts[2] val content = parts[2]
CommandService.updateCommand(user, command, content, "") CommandService.updateCommand(user, command, content, "")
handler.sendChat("명령어 '$command' 수정되었습니다.") listener.sendChat("명령어 '$command' 수정되었습니다.")
ChzzkHandler.reloadCommand(channel) ChzzkHandler.reloadCommand(channel)
} }
private fun manageRemoveCommand(msg: SessionChatMessage, user: User) { private fun manageRemoveCommand(msg: ChatMessage, user: User) {
if (msg.profile.badges.none { it.imageUrl.contains("manager") }) { if (msg.profile?.userRoleCode == "common_user") {
handler.sendChat("매니저만 명령어를 삭제할 수 있습니다.") listener.sendChat("매니저만 명령어를 삭제할 수 있습니다.")
return return
} }
val parts = msg.content.split(" ", limit = 2) val parts = msg.content.split(" ", limit = 2)
if (parts.size < 2) { if (parts.size < 2) {
handler.sendChat("명령어 삭제 형식은 '!명령어삭제 명령어'입니다.") listener.sendChat("명령어 삭제 형식은 '!명령어삭제 명령어'입니다.")
return return
} }
val command = parts[1] val command = parts[1]
CommandService.removeCommand(user, command) CommandService.removeCommand(user, command)
handler.sendChat("명령어 '$command' 삭제되었습니다.") listener.sendChat("명령어 '$command' 삭제되었습니다.")
ChzzkHandler.reloadCommand(channel) ChzzkHandler.reloadCommand(channel)
} }
private fun timerCommand(msg: SessionChatMessage, user: User) { private fun timerCommand(msg: ChatMessage, user: User) {
if (msg.profile.badges.none { it.imageUrl.contains("manager") }) { if (msg.profile?.userRoleCode == "common_user") {
handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.") listener.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return return
} }
val parts = msg.content.split(" ", limit = 3) val parts = msg.content.split(" ", limit = 3)
if (parts.size < 2) { if (parts.size < 2) {
handler.sendChat("타이머 명령어 형식을 잘 찾아봐주세요!") listener.sendChat("타이머 명령어 형식을 잘 찾아봐주세요!")
return return
} }
@ -184,13 +174,13 @@ class MessageHandler(
when (parts[2]) { when (parts[2]) {
"업타임" -> { "업타임" -> {
TimerConfigService.saveOrUpdateConfig(user, TimerType.UPTIME) TimerConfigService.saveOrUpdateConfig(user, TimerType.UPTIME)
handler.sendChat("기본 타이머 설정이 업타임으로 바뀌었습니다.") listener.sendChat("기본 타이머 설정이 업타임으로 바뀌었습니다.")
} }
"삭제" -> { "삭제" -> {
TimerConfigService.saveOrUpdateConfig(user, TimerType.REMOVE) TimerConfigService.saveOrUpdateConfig(user, TimerType.REMOVE)
handler.sendChat("기본 타이머 설정이 삭제로 바뀌었습니다.") listener.sendChat("기본 타이머 설정이 삭제로 바뀌었습니다.")
} }
else -> handler.sendChat("!타이머 설정 (업타임/삭제) 형식으로 써주세요!") else -> listener.sendChat("!타이머 설정 (업타임/삭제) 형식으로 써주세요!")
} }
} }
else -> { else -> {
@ -204,9 +194,9 @@ class MessageHandler(
dispatcher.post(TimerEvent(user.token, TimerType.TIMER, timestamp.toString())) dispatcher.post(TimerEvent(user.token, TimerType.TIMER, timestamp.toString()))
} }
} catch (e: NumberFormatException) { } catch (e: NumberFormatException) {
handler.sendChat("!타이머/숫자 형식으로 적어주세요! 단위: 분") listener.sendChat("!타이머/숫자 형식으로 적어주세요! 단위: 분")
} catch (e: Exception) { } catch (e: Exception) {
handler.sendChat("타이머 설정 중 오류가 발생했습니다.") listener.sendChat("타이머 설정 중 오류가 발생했습니다.")
logger.error("Error processing timer command: ${e.message}", e) logger.error("Error processing timer command: ${e.message}", e)
} }
} }
@ -214,21 +204,17 @@ class MessageHandler(
} }
// songs // songs
private fun songAddCommand(msg: SessionChatMessage, user: User) { private fun songAddCommand(msg: ChatMessage, user: User) {
if(SongConfigService.getConfig(user).disabled) { val parts = msg.content.split(" ", limit = 3)
return
}
val parts = msg.content.split(" ", limit = 2)
if (parts.size < 2) { if (parts.size < 2) {
handler.sendChat("유튜브 URL을 입력해주세요!") listener.sendChat("유튜브 URL을 입력해주세요!")
return return
} }
val config = SongConfigService.getConfig(user) val config = SongConfigService.getConfig(user)
if(config.streamerOnly && msg.profile.badges.none { it.imageUrl.contains("manager") }) { if(config.streamerOnly && msg.profile?.userRoleCode == "common_user") {
handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.") listener.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return return
} }
@ -236,34 +222,29 @@ class MessageHandler(
val songs = SongListService.getSong(user) val songs = SongListService.getSong(user)
if(songs.size >= config.queueLimit) { if(songs.size >= config.queueLimit) {
handler.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!") listener.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!")
return return
} }
if(songs.filter { it.uid == msg.senderChannelId }.size >= config.personalLimit) { if(songs.filter { it.uid == msg.userId }.size >= config.personalLimit) {
handler.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!") listener.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!")
return return
} }
try { try {
val video = getYoutubeVideo(url) val video = getYoutubeVideo(url)
if (video == null) { if (video == null) {
handler.sendChat("유튜브에서 찾을 수 없어요!") listener.sendChat("유튜브에서 찾을 수 없어요!")
return return
} }
if (songs.any { it.url == video.url }) { if (songs.any { it.url == video.url }) {
handler.sendChat("같은 노래가 이미 신청되어 있습니다.") listener.sendChat("같은 노래가 이미 신청되어 있습니다.")
return
}
if (video.length > 600) {
handler.sendChat("10분이 넘는 노래는 신청할 수 없습니다.")
return return
} }
SongListService.saveSong( SongListService.saveSong(
user, user,
msg.senderChannelId, msg.userId,
video.url, video.url,
video.name, video.name,
video.author, video.author,
@ -275,65 +256,64 @@ class MessageHandler(
SongEvent( SongEvent(
user.token, user.token,
SongType.ADD, SongType.ADD,
msg.senderChannelId, msg.userId,
null, msg.profile?.nickname ?: "",
video, video.name,
video.author,
video.length,
video.url
) )
) )
} }
handler.sendChat("노래가 추가되었습니다. ${video.name} - ${video.author}") listener.sendChat("노래가 추가되었습니다.")
} catch(e: Exception) { } catch(e: Exception) {
handler.sendChat("유튜브 영상 주소로 다시 신청해주세요!") listener.sendChat("유튜브 영상 주소로 다시 신청해주세요!")
logger.info(e.stackTraceToString()) logger.info(e.stackTraceToString())
} }
} }
private fun songListCommand(msg: SessionChatMessage, user: User) { private fun songListCommand(msg: ChatMessage, user: User) {
if(SongConfigService.getConfig(user).disabled) { listener.sendChat("리스트는 여기입니다. https://nabot.mori.space/songs/${user.token}")
return
}
handler.sendChat("리스트는 여기입니다. https://nabot.mori.space/songs/${user.token}")
} }
private fun songStartCommand(msg: SessionChatMessage, user: User) { private fun songStartCommand(msg: ChatMessage, user: User) {
if (msg.profile.badges.none { it.imageUrl.contains("manager") }) { if (msg.profile?.userRoleCode == "common_user") {
handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.") listener.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return return
} }
val session = "${UUID.randomUUID()}${UUID.randomUUID()}".replace("-", "")
if(user.discord != null) { SongConfigService.updateSession(user, session)
bot.retrieveUserById(user.discord!!).queue { discordUser ->
discordUser?.openPrivateChannel()?.queue { channel ->
channel.sendMessage("여기로 접속해주세요! ||https://nabot.mori.space/songlist||.") bot.retrieveUserById(user.discord).queue { discordUser ->
.queue() discordUser?.openPrivateChannel()?.queue { channel ->
} channel.sendMessage("여기로 접속해주세요! ||https://nabot.mori.space/songlist/${session}||.\n주소가 노출될 경우 방송을 다시 켜셔야 합니다!")
.queue()
} }
} else {
handler.sendChat("나봇 홈페이지의 노래목록 페이지를 이용해주세요! 디스코드 연동을 하시면 DM으로 바로 전송됩니다.")
} }
} }
internal fun handle(msg: SessionChatMessage, user: User) { internal fun handle(msg: ChatMessage, user: User) {
if(msg.senderChannelId == ChzzkHandler.botUid) return if(msg.userId == ChzzkHandler.botUid) return
val commandKey = msg.content.split(' ')[0] val commandKey = msg.content.split(' ')[0]
commands[commandKey.lowercase()]?.let { it(msg, user) } commands[commandKey.lowercase()]?.let { it(msg, user) }
} }
private fun replaceCounters(chat: Pair<String, String>, user: User, msg: SessionChatMessage, userName: String): String { private fun replaceCounters(chat: Pair<String, String>, user: User, msg: ChatMessage, listener: ChzzkChat, userName: String): String {
var result = chat.first var result = chat.first
var isFail = false var isFail = false
// Replace dailyCounterPattern // Replace dailyCounterPattern
result = dailyCounterPattern.replace(result) { matchResult -> result = dailyCounterPattern.replace(result) { matchResult ->
val name = matchResult.groupValues[1] val name = matchResult.groupValues[1]
val dailyCounter = CounterService.getDailyCounterValue(name, msg.senderChannelId, user) val dailyCounter = CounterService.getDailyCounterValue(name, msg.userId, user)
if (dailyCounter.second) { if (dailyCounter.second) {
CounterService.updateDailyCounterValue(name, msg.senderChannelId, 1, user).first.toString() CounterService.updateDailyCounterValue(name, msg.userId, 1, user).first.toString()
} else { } else {
isFail = true isFail = true
dailyCounter.first.toString() dailyCounter.first.toString()
@ -345,7 +325,7 @@ class MessageHandler(
result = chat.second result = chat.second
result = dailyCounterPattern.replace(result) { matchResult -> result = dailyCounterPattern.replace(result) { matchResult ->
val name = matchResult.groupValues[1] val name = matchResult.groupValues[1]
val dailyCounter = CounterService.getDailyCounterValue(name, msg.senderChannelId, user) val dailyCounter = CounterService.getDailyCounterValue(name, msg.userId, user)
dailyCounter.first.toString() dailyCounter.first.toString()
} }
} }
@ -353,15 +333,15 @@ class MessageHandler(
// Replace followPattern // Replace followPattern
result = followPattern.replace(result) { _ -> result = followPattern.replace(result) { _ ->
try { try {
val followingDate = handler.chatChannelId?.let { getFollowDate(it, msg.senderChannelId) } val followingDate = getFollowDate(listener.chatId, msg.userId)
?.content?.streamingProperty?.following?.followDate ?: LocalDateTime.now().minusDays(1).toString() .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")
val pastDate = LocalDateTime.parse(it, formatter) val pastDate = LocalDateTime.parse(it, formatter)
val today = LocalDateTime.now() val today = LocalDateTime.now()
ChronoUnit.DAYS.between(pastDate, today) ChronoUnit.DAYS.between(pastDate, today)
} + 1 } ?: 0
period.toString() period.toString()
} catch (e: Exception) { } catch (e: Exception) {
@ -389,7 +369,7 @@ class MessageHandler(
// Replace personalCounterPattern // Replace personalCounterPattern
result = personalCounterPattern.replace(result) { matchResult -> result = personalCounterPattern.replace(result) { matchResult ->
val name = matchResult.groupValues[1] val name = matchResult.groupValues[1]
CounterService.updatePersonalCounterValue(name, msg.senderChannelId, 1, user).toString() CounterService.updatePersonalCounterValue(name, msg.userId, 1, user).toString()
} }
// Replace namePattern // Replace namePattern
@ -397,4 +377,5 @@ class MessageHandler(
return result return result
} }
}
}

View File

@ -8,16 +8,17 @@ 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.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.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.common.utils.IData
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 xyz.r2turntrue.chzzk4j.types.channel.live.ChzzkLiveDetail import space.mori.chzzk_bot.common.services.ManagerService
import xyz.r2turntrue.chzzk4j.types.channel.live.Resolution
import java.time.Instant import java.time.Instant
import kotlin.jvm.optionals.getOrNull
val dotenv = dotenv { val dotenv = dotenv {
ignoreIfMissing = true ignoreIfMissing = true
@ -30,30 +31,21 @@ class Discord: ListenerAdapter() {
companion object { companion object {
lateinit var bot: JDA lateinit var bot: JDA
internal fun getChannel(guildId: Long, channelId: Long): TextChannel? { internal fun getChannel(guildId: Long, channelId: Long): TextChannel? = bot.getGuildById(guildId)?.getTextChannelById(channelId)
return bot.getGuildById(guildId)?.getTextChannelById(channelId)
}
fun sendDiscord(user: User, status: ChzzkLiveDetail) { fun sendDiscord(user: User, status: IData<IStreamInfo?>) {
if(user.liveAlertMessage != null && user.liveAlertGuild != null && user.liveAlertChannel != null) { if(status.content == null) return
val channel = getChannel(user.liveAlertGuild ?: 0, user.liveAlertChannel ?: 0) if(user.liveAlertMessage != "" && user.liveAlertGuild != null && user.liveAlertChannel != null) {
?: 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.title, "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.setTimestamp(Instant.now()) embed.setTimestamp(Instant.now())
embed.setAuthor(user.username, "https://chzzk.naver.com/live/${user.token}") embed.setAuthor(user.username, "https://chzzk.naver.com/live/${user.token}", status.content!!.channel.channelImageUrl)
embed.addField("카테고리", status.liveCategoryValue, true) embed.addField("카테고리", status.content!!.liveCategoryValue, true)
embed.addField("태그", status.tags.joinToString(", ") { it.trim() }, true) embed.addField("태그", status.content!!.tags.joinToString(", "), true)
status.defaultThumbnailImageUrl.getOrNull()?.let { embed.setImage(it) } embed.setImage(status.content!!.liveImageUrl.replace("{type}", "1080"))
?: Resolution.entries.reversed().forEach {
val thumbnail = status.getLiveImageUrl(it)
if (thumbnail != null) {
embed.setImage(thumbnail)
return@forEach
}
}
channel.sendMessage( channel.sendMessage(
MessageCreateBuilder() MessageCreateBuilder()
@ -66,7 +58,15 @@ class Discord: ListenerAdapter() {
} }
private val commands = listOf( private val commands = listOf(
AddCommand,
AlertCommand,
PingCommand, PingCommand,
RegisterCommand,
RemoveCommand,
UpdateCommand,
AddManagerCommand,
ListManagerCommand,
RemoveManagerCommand,
) )
override fun onSlashCommandInteraction(event: SlashCommandInteractionEvent) { override fun onSlashCommandInteraction(event: SlashCommandInteractionEvent) {
@ -76,6 +76,10 @@ class Discord: ListenerAdapter() {
handler?.run(event, bot) handler?.run(event, bot)
} }
override fun onGuildMemberRemove(event: GuildMemberRemoveEvent) {
event.member?.let { ManagerService.deleteManager(event.guild.idLong, it.idLong) }
}
override fun onGuildJoin(event: GuildJoinEvent) { override fun onGuildJoin(event: GuildJoinEvent) {
commandUpdate(event.guild) commandUpdate(event.guild)
} }

View File

@ -0,0 +1,73 @@
package space.mori.chzzk_bot.chatbot.discord.commands
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
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.OptionData
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chatbot.chzzk.Connector
import space.mori.chzzk_bot.chatbot.discord.CommandInterface
import space.mori.chzzk_bot.common.services.CommandService
import space.mori.chzzk_bot.common.services.ManagerService
import space.mori.chzzk_bot.common.services.UserService
object AddCommand : CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)
override val name: String = "add"
override val command = Commands.slash(name, "명령어를 추가합니다.")
.addOptions(OptionData(OptionType.STRING, "label", "작동할 명령어를 입력하세요.", true))
.addOptions(OptionData(OptionType.STRING, "content", "표시될 텍스트를 입력하세요.", true))
.addOptions(OptionData(OptionType.STRING, "fail_content", "카운터 업데이트 실패시 표시될 텍스트를 입력하세요.", false))
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
val label = event.getOption("label")?.asString
val content = event.getOption("content")?.asString
val failContent = event.getOption("fail_content")?.asString
if(label == null || content == null) {
event.hook.sendMessage("명령어와 텍스트는 필수 입력입니다.").queue()
return
}
var user = UserService.getUser(event.user.idLong)
val manager = event.guild?.idLong?.let { ManagerService.getUser(it, event.user.idLong) }
if(user == null && manager == null) {
event.hook.sendMessage("당신은 이 명령어를 사용할 수 없습니다.").queue()
return
}
if (manager != null) {
transaction {
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!!)
if (commands.any { it.command == label }) {
event.hook.sendMessage("$label 명령어는 이미 있습니다! 업데이트 명령어를 써주세요.").queue()
return
}
val chzzkChannel = Connector.getChannel(user!!.token)
try {
CommandService.saveCommand(user!!, label, content, failContent ?: "")
try {
ChzzkHandler.reloadCommand(chzzkChannel!!)
} catch (_: Exception) {}
event.hook.sendMessage("등록이 완료되었습니다. $label = $content / $failContent").queue()
} catch (e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -0,0 +1,47 @@
package space.mori.chzzk_bot.chatbot.discord.commands
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
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.OptionData
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.discord.CommandInterface
import space.mori.chzzk_bot.common.services.ManagerService
import space.mori.chzzk_bot.common.services.UserService
object AddManagerCommand : CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)
override val name: String = "addmanager"
override val command = Commands.slash(name, "매니저를 추가합니다.")
.addOptions(OptionData(OptionType.USER, "user", "추가할 유저를 선택하세요.", true))
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
val manager = event.getOption("user")?.asUser
if(manager == null) {
event.hook.sendMessage("유저는 필수사항입니다.").queue()
return
}
if(manager.idLong == event.user.idLong) {
event.hook.sendMessage("자신은 매니저로 설정할 수 없습니다.").queue()
return
}
val user = UserService.getUser(event.user.idLong)
if(user == null) {
event.hook.sendMessage("치지직 계정을 찾을 수 없습니다.").queue()
return
}
try {
ManagerService.saveManager(user, manager.idLong, manager.effectiveName)
if(user.liveAlertGuild == null)
UserService.updateLiveAlert(user.id.value, event.guild!!.idLong, event.channelIdLong, "")
event.hook.sendMessage("등록이 완료되었습니다. ${manager.effectiveName}").queue()
} catch (e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -0,0 +1,59 @@
package space.mori.chzzk_bot.chatbot.discord.commands
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
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.OptionData
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chatbot.chzzk.Connector
import space.mori.chzzk_bot.chatbot.discord.CommandInterface
import space.mori.chzzk_bot.common.services.ManagerService
import space.mori.chzzk_bot.common.services.UserService
object AlertCommand : CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)
override val name: String = "alert"
override val command = Commands.slash(name, "방송알람 채널을 설정합니다. / 알람 취소도 이 명령어를 이용하세요!")
.addOptions(OptionData(OptionType.CHANNEL, "channel", "알림을 보낼 채널을 입력하세요."))
.addOptions(OptionData(OptionType.STRING, "content", "표시될 텍스트를 입력하세요. 비워두면 알람이 취소됩니다."))
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
val channel = event.getOption("channel")?.asChannel
val content = event.getOption("content")?.asString
var user = UserService.getUser(event.user.idLong)
val manager = event.guild?.idLong?.let { ManagerService.getUser(it, event.user.idLong) }
if(user == null && manager == null) {
event.hook.sendMessage("당신은 이 명령어를 사용할 수 없습니다.").queue()
return
}
if (manager != null) {
transaction {
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)
try {
val newUser = UserService.updateLiveAlert(user!!.id.value, channel?.guild?.idLong ?: 0L, channel?.idLong ?: 0L, content ?: "")
try {
ChzzkHandler.reloadUser(chzzkChannel!!, newUser)
} catch (_: Exception) {}
event.hook.sendMessage("업데이트가 완료되었습니다.").queue()
} catch (e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -0,0 +1,44 @@
package space.mori.chzzk_bot.chatbot.discord.commands
import net.dv8tion.jda.api.EmbedBuilder
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.interactions.commands.build.Commands
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.discord.CommandInterface
import space.mori.chzzk_bot.common.services.ManagerService
import space.mori.chzzk_bot.common.services.UserService
object ListManagerCommand : CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)
override val name: String = "listmanager"
override val command = Commands.slash(name, "매니저 목록을 확인합니다.")
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
try {
val managers = event.guild?.idLong?.let { ManagerService.getAllUsers(it) }
if(managers == null) {
event.channel.sendMessage("여기에서는 사용할 수 없습니다.")
return
}
val user = UserService.getUserWithGuildId(event.guild!!.idLong)
val embed = EmbedBuilder()
embed.setTitle("${user!!.username} 매니저 목록")
embed.setDescription("매니저 목록입니다.")
var idx = 1
managers.forEach {
embed.addField("${idx++}", it.lastUserName ?: it.managerId.toString(), true)
}
event.channel.sendMessageEmbeds(embed.build()).queue()
} catch (e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -0,0 +1,60 @@
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.events.interaction.command.SlashCommandInteractionEvent
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.OptionData
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chatbot.chzzk.Connector
import space.mori.chzzk_bot.chatbot.discord.CommandInterface
import space.mori.chzzk_bot.common.services.UserService
object RegisterCommand: CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)
override val name = "register"
private val regex = """(?:.+chzzk\.naver\.com/)?([a-f0-9]{32})?(?:/live)?${'$'}""".toRegex()
override val command = Commands.slash(name, "치지직 계정을 등록합니다.")
.addOptions(
OptionData(
OptionType.STRING,
"chzzk_id",
"치지직 채널 URL 혹은 ID를 입력해주세요.",
true
)
)
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
val chzzkID = event.getOption("chzzk_id")?.asString
if(chzzkID == null) {
event.hook.sendMessage("치지직 계정은 필수 입력입니다.").queue()
return
}
val matchResult = regex.find(chzzkID)
val matchedChzzkId = matchResult?.groups?.get(1)?.value
val chzzkChannel = matchedChzzkId?.let { Connector.getChannel(it) }
if (chzzkChannel == null) {
event.hook.sendMessage("치지직 계정을 찾을 수 없습니다.").queue()
return
}
try {
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}`")
} catch(e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -0,0 +1,63 @@
package space.mori.chzzk_bot.chatbot.discord.commands
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
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.OptionData
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chatbot.chzzk.Connector
import space.mori.chzzk_bot.chatbot.discord.CommandInterface
import space.mori.chzzk_bot.common.services.CommandService
import space.mori.chzzk_bot.common.services.ManagerService
import space.mori.chzzk_bot.common.services.UserService
object RemoveCommand : CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)
override val name: String = "remove"
override val command = Commands.slash(name, "명령어를 삭제합니다.")
.addOptions(OptionData(OptionType.STRING, "label", "삭제할 명령어를 입력하세요.", true))
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
val label = event.getOption("label")?.asString
if(label == null) {
event.hook.sendMessage("명령어는 필수 입력입니다.").queue()
return
}
var user = UserService.getUser(event.user.idLong)
val manager = event.guild?.idLong?.let { ManagerService.getUser(it, event.user.idLong) }
if(user == null && manager == null) {
event.hook.sendMessage("당신은 이 명령어를 사용할 수 없습니다.").queue()
return
}
if (manager != null) {
transaction {
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)
try {
CommandService.removeCommand(user!!, label)
try {
ChzzkHandler.reloadCommand(chzzkChannel!!)
} catch (_: Exception) {}
event.hook.sendMessage("삭제가 완료되었습니다. $label").queue()
} catch (e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -0,0 +1,49 @@
package space.mori.chzzk_bot.chatbot.discord.commands
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
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.OptionData
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.discord.CommandInterface
import space.mori.chzzk_bot.common.services.ManagerService
import space.mori.chzzk_bot.common.services.UserService
object RemoveManagerCommand : CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)
override val name: String = "removemanager"
override val command = Commands.slash(name, "매니저를 삭제합니다.")
.addOptions(OptionData(OptionType.USER, "user", "삭제할 유저를 선택하세요.", true))
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
val manager = event.getOption("user")?.asUser
if(manager == null) {
event.hook.sendMessage("유저는 필수사항입니다.").queue()
return
}
if(manager.idLong == event.user.idLong) {
event.hook.sendMessage("자신은 매니저로 설정할 수 없습니다.").queue()
return
}
val user = UserService.getUser(event.user.idLong)
if(user == null) {
event.hook.sendMessage("치지직 계정을 찾을 수 없습니다.").queue()
return
}
if(ManagerService.getUser(user.liveAlertGuild ?: 0L, manager.idLong) == null) {
event.hook.sendMessage("${manager.name}은 매니저가 아닙니다.")
}
try {
ManagerService.deleteManager(user, manager.idLong)
event.hook.sendMessage("삭제가 완료되었습니다. ${manager.effectiveName}").queue()
} catch (e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -0,0 +1,65 @@
package space.mori.chzzk_bot.chatbot.discord.commands
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
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.OptionData
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chatbot.chzzk.Connector
import space.mori.chzzk_bot.chatbot.discord.CommandInterface
import space.mori.chzzk_bot.common.services.CommandService
import space.mori.chzzk_bot.common.services.ManagerService
import space.mori.chzzk_bot.common.services.UserService
object UpdateCommand : CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)
override val name: String = "update"
override val command = Commands.slash(name, "명령어를 수정합니다.")
.addOptions(OptionData(OptionType.STRING, "label", "수정할 명령어를 입력하세요.", true))
.addOptions(OptionData(OptionType.STRING, "content", "표시될 텍스트를 입력하세요.", true))
.addOptions(OptionData(OptionType.STRING, "fail_content", "카운터 업데이트 실패시 표시될 텍스트를 입력하세요.", false))
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
val label = event.getOption("label")?.asString
val content = event.getOption("content")?.asString
val failContent = event.getOption("fail_content")?.asString
if(label == null || content == null) {
event.hook.sendMessage("명령어와 텍스트는 필수 입력입니다.").queue()
return
}
var user = UserService.getUser(event.user.idLong)
val manager = event.guild?.idLong?.let { ManagerService.getUser(it, event.user.idLong) }
if(user == null && manager == null) {
event.hook.sendMessage("당신은 이 명령어를 사용할 수 없습니다.").queue()
return
}
if (manager != null) {
transaction {
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)
try {
CommandService.updateCommand(user!!, label, content, failContent ?: "")
chzzkChannel?.let { ChzzkHandler.reloadCommand(it) }
event.hook.sendMessage("등록이 완료되었습니다. $label = $content").queue()
} catch (e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -1,58 +0,0 @@
package space.mori.chzzk_bot.chatbot.utils
import com.google.gson.Gson
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import space.mori.chzzk_bot.chatbot.chzzk.dotenv
import space.mori.chzzk_bot.common.utils.client
import xyz.r2turntrue.chzzk4j.ChzzkClient
import java.io.IOException
val client = OkHttpClient.Builder()
.addNetworkInterceptor { chain ->
chain.proceed(
chain.request()
.newBuilder()
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
.build()
)
}
.build()
val gson = Gson()
data class RefreshTokenResponse(
val accessToken: String,
val refreshToken: String,
val expiresIn: Int,
val tokenType: String = "Bearer",
val scope: String
)
fun ChzzkClient.refreshAccessToken(refreshToken: String): Pair<String, String> {
val url = "https://openapi.chzzk.naver.com/auth/v1/token"
val request = Request.Builder()
.url(url)
.header("Content-Type", "application/json")
.post(gson.toJson(mapOf(
"grantType" to "refresh_token",
"refreshToken" to refreshToken,
"clientId" to dotenv["NAVER_CLIENT_ID"],
"clientSecret" to dotenv["NAVER_CLIENT_SECRET"]
)).toRequestBody("application/json; charset=utf-8".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
try {
if(!response.isSuccessful) throw IOException("Unexpected code ${response.code}")
val body = response.body?.string()
val data = gson.fromJson(body, RefreshTokenResponse::class.java)
return Pair(data.accessToken, data.refreshToken)
} catch(e: Exception) {
throw e
}
}
}

View File

@ -11,25 +11,25 @@ repositories {
dependencies { dependencies {
// https://mvnrepository.com/artifact/org.jetbrains.exposed/exposed-core // https://mvnrepository.com/artifact/org.jetbrains.exposed/exposed-core
api("org.jetbrains.exposed:exposed-core:0.56.0") api("org.jetbrains.exposed:exposed-core:0.52.0")
// https://mvnrepository.com/artifact/org.jetbrains.exposed/exposed-dao // https://mvnrepository.com/artifact/org.jetbrains.exposed/exposed-dao
api("org.jetbrains.exposed:exposed-dao:0.56.0") api("org.jetbrains.exposed:exposed-dao:0.52.0")
// https://mvnrepository.com/artifact/org.jetbrains.exposed/exposed-jdbc // https://mvnrepository.com/artifact/org.jetbrains.exposed/exposed-jdbc
api("org.jetbrains.exposed:exposed-jdbc:0.56.0") api("org.jetbrains.exposed:exposed-jdbc:0.52.0")
// https://mvnrepository.com/artifact/org.jetbrains.exposed/exposed-kotlin-datetime // https://mvnrepository.com/artifact/org.jetbrains.exposed/exposed-kotlin-datetime
api("org.jetbrains.exposed:exposed-java-time:0.56.0") api("org.jetbrains.exposed:exposed-java-time:0.52.0")
// https://mvnrepository.com/artifact/com.zaxxer/HikariCP // https://mvnrepository.com/artifact/com.zaxxer/HikariCP
api("com.zaxxer:HikariCP:6.1.0") api("com.zaxxer:HikariCP:5.1.0")
// 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.13") implementation("ch.qos.logback:logback-classic:1.5.6")
// https://mvnrepository.com/artifact/org.mariadb.jdbc/mariadb-java-client // https://mvnrepository.com/artifact/org.mariadb.jdbc/mariadb-java-client
implementation("org.mariadb.jdbc:mariadb-java-client:3.5.0") implementation("org.mariadb.jdbc:mariadb-java-client:3.4.1")
// 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.2") implementation("io.github.cdimascio:dotenv-kotlin:6.4.1")
// 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")

View File

@ -25,17 +25,16 @@ object Connector {
init { init {
Database.connect(dataSource) Database.connect(dataSource)
val tables = listOf( val tables = listOf(
UserManagers,
Users, Users,
Commands, Commands,
Counters, Counters,
DailyCounters, DailyCounters,
PersonalCounters, PersonalCounters,
Managers,
TimerConfigs, TimerConfigs,
LiveStatuses, LiveStatuses,
SongLists, SongLists,
SongConfigs, SongConfigs
Sessions
) )
transaction { transaction {

View File

@ -1,8 +0,0 @@
package space.mori.chzzk_bot.common.events
data class BotEnabledEvent(
val chzzkId: String,
val isDisabled: Boolean,
): Event {
val TAG = javaClass.simpleName
}

View File

@ -1,7 +0,0 @@
package space.mori.chzzk_bot.common.events
data class ChzzkUserFindEvent(
val uid: String
): Event {
val TAG = javaClass.simpleName
}

View File

@ -1,11 +0,0 @@
package space.mori.chzzk_bot.common.events
data class ChzzkUserReceiveEvent(
val find: Boolean = true,
val uid: String? = null,
val nickname: String? = null,
val isStreamOn: Boolean? = null,
val avatarUrl: String? = null,
): Event {
val TAG = javaClass.simpleName
}

View File

@ -1,7 +0,0 @@
package space.mori.chzzk_bot.common.events
data class CommandReloadEvent(
val uid: String
): Event {
val TAG = javaClass.simpleName
}

View File

@ -1,8 +0,0 @@
package space.mori.chzzk_bot.common.events
class DiscordRegisterEvent(
val user: String,
val token: String,
): Event {
val TAG = javaClass.simpleName
}

View File

@ -1,23 +1,22 @@
package space.mori.chzzk_bot.common.events package space.mori.chzzk_bot.common.events
import space.mori.chzzk_bot.common.utils.YoutubeVideo
enum class SongType(var value: Int) { enum class SongType(var value: Int) {
ADD(0), ADD(0),
REMOVE(1), REMOVE(1),
NEXT(2), NEXT(2),
STREAM_OFF(50), STREAM_OFF(50)
ACK(51)
} }
class SongEvent( class SongEvent(
val uid: String, val uid: String,
val type: SongType, val type: SongType,
val reqUid: String?, val reqUid: String?,
val current: YoutubeVideo? = null, val reqName: String?,
val next: YoutubeVideo? = null, val name: String?,
val delUrl: String? = null, val author: String?,
val time: Int?,
val url: String?
): Event { ): Event {
var TAG = javaClass.simpleName var TAG = javaClass.simpleName
} }

View File

@ -5,8 +5,7 @@ enum class TimerType(var value: Int) {
TIMER(1), TIMER(1),
REMOVE(2), REMOVE(2),
STREAM_OFF(50), STREAM_OFF(50)
ACK(51)
} }
class TimerEvent( class TimerEvent(

View File

@ -1,7 +0,0 @@
package space.mori.chzzk_bot.common.events
data class UserRegisterEvent(
val chzzkId: String
): Event {
val TAG = javaClass.simpleName
}

View File

@ -0,0 +1,22 @@
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
object Managers: IntIdTable("manager") {
val user = reference("user", Users)
val managerId = long("manager_id")
val discordGuildId = long("discord_guild_id")
var lastUserName = varchar("last_user_name", 255).nullable()
}
class Manager(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<Manager>(Managers)
var user by User referencedOn Managers.user
var managerId by Managers.managerId
var discordGuildId by Managers.discordGuildId
var lastUserName by Managers.lastUserName
}

View File

@ -1,18 +0,0 @@
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
object Sessions: IntIdTable("session") {
val key = text("key")
val value = text("value")
}
class Session(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<Session>(Sessions)
var key by Sessions.key
var value by Sessions.value
}

View File

@ -12,7 +12,6 @@ object SongConfigs: IntIdTable("song_config") {
val streamerOnly = bool("streamer_only").default(false) val streamerOnly = bool("streamer_only").default(false)
val queueLimit = integer("queue_limit").default(50) val queueLimit = integer("queue_limit").default(50)
val personalLimit = integer("personal_limit").default(5) val personalLimit = integer("personal_limit").default(5)
val disabled = bool("disabled").default(false)
} }
class SongConfig(id: EntityID<Int>) : IntEntity(id) { class SongConfig(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<SongConfig>(SongConfigs) companion object : IntEntityClass<SongConfig>(SongConfigs)
@ -22,5 +21,4 @@ class SongConfig(id: EntityID<Int>) : IntEntity(id) {
var streamerOnly by SongConfigs.streamerOnly var streamerOnly by SongConfigs.streamerOnly
var queueLimit by SongConfigs.queueLimit var queueLimit by SongConfigs.queueLimit
var personalLimit by SongConfigs.personalLimit var personalLimit by SongConfigs.personalLimit
var disabled by SongConfigs.disabled
} }

View File

@ -12,7 +12,7 @@ object SongLists: IntIdTable("song_list") {
val uid = varchar("uid", 64) val uid = varchar("uid", 64)
val url = varchar("url", 128) val url = varchar("url", 128)
val name = text("name") val name = text("name")
val reqName = varchar("req_name", 80) val reqName = varchar("req_name", 20)
val author = text("author") val author = text("author")
val time = integer("time") val time = integer("time")
val created_at = datetime("created_at").default(LocalDateTime.now()) val created_at = datetime("created_at").default(LocalDateTime.now())

View File

@ -9,14 +9,10 @@ import org.jetbrains.exposed.dao.id.IntIdTable
object Users: IntIdTable("users") { object Users: IntIdTable("users") {
val username = varchar("username", 255) val username = varchar("username", 255)
val token = varchar("token", 64) val token = varchar("token", 64)
val discord = long("discord").nullable() val discord = long("discord")
val liveAlertGuild = long("live_alert_guild").nullable() val liveAlertGuild = long("live_alert_guild").nullable()
val liveAlertChannel = long("live_alert_channel").nullable() val liveAlertChannel = long("live_alert_channel").nullable()
val liveAlertMessage = text("live_alert_message").nullable() val liveAlertMessage = text("live_alert_message").nullable()
val isDisableStartupMsg = bool("is_disable_startup_msg").default(false)
val isDisabled = bool("is_disabled").default(false)
val accessToken = varchar("access_token", 255).nullable()
val refreshToken = varchar("refresh_token", 255).nullable()
} }
class User(id: EntityID<Int>) : IntEntity(id) { class User(id: EntityID<Int>) : IntEntity(id) {
@ -28,15 +24,4 @@ class User(id: EntityID<Int>) : IntEntity(id) {
var liveAlertGuild by Users.liveAlertGuild var liveAlertGuild by Users.liveAlertGuild
var liveAlertChannel by Users.liveAlertChannel var liveAlertChannel by Users.liveAlertChannel
var liveAlertMessage by Users.liveAlertMessage var liveAlertMessage by Users.liveAlertMessage
var isDisableStartupMsg by Users.isDisableStartupMsg
var isDisabled by Users.isDisabled
var accessToken by Users.accessToken
var refreshToken by Users.refreshToken
// 유저가 가진 매니저들
var managers by User.via(UserManagers.user, UserManagers.manager)
// 매니저가 관리하는 유저들
var subordinates by User.via(UserManagers.manager, UserManagers.user)
} }

View File

@ -1,9 +0,0 @@
package space.mori.chzzk_bot.common.models
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.ReferenceOption
object UserManagers: IntIdTable("user_managers") {
val user = reference("user_id", Users, ReferenceOption.CASCADE)
val manager = reference("manager_id", Users, ReferenceOption.CASCADE)
}

View File

@ -0,0 +1,79 @@
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.and
import org.jetbrains.exposed.sql.transactions.transaction
import space.mori.chzzk_bot.common.models.Manager
import space.mori.chzzk_bot.common.models.Managers
import space.mori.chzzk_bot.common.models.User
object ManagerService {
fun saveManager(user: User, discordId: Long, name: String): Manager {
if (user.liveAlertGuild == null)
throw RuntimeException("${user.username} has no liveAlertGuild")
return transaction {
Manager.new {
this.user = user
this.discordGuildId = user.liveAlertGuild!!
this.managerId = discordId
this.lastUserName = name
}
}
}
fun updateManager(user: User, discordId: Long, name: String): Manager {
return transaction {
if (user.liveAlertGuild == null)
throw RuntimeException("${user.username} has no liveAlertGuild")
val manager = getUser(user.liveAlertGuild!!, discordId) ?: throw RuntimeException("$name isn't manager.")
manager.lastUserName = name
manager
}
}
fun getUser(guildId: Long, discordId: Long): Manager? {
return transaction {
val manager = Manager.find(
(Managers.discordGuildId eq guildId) and (Managers.managerId eq discordId),
)
.with(Manager::user)
.firstOrNull()
manager
}
}
fun getAllUsers(guildId: Long): List<Manager> {
return transaction {
val result = Manager.find(Managers.discordGuildId eq guildId)
.with(Manager::user)
.toList()
result.forEach { it.load(Manager::user) }
result
}
}
fun deleteManager(user: User, discordId: Long): Manager {
if (user.liveAlertGuild == null)
throw RuntimeException("${user.username} has no liveAlertGuild")
return deleteManager(user.liveAlertGuild!!, discordId)
}
fun deleteManager(guildId: Long, discordId: Long): Manager {
return transaction {
val managerRow = Manager.find((Managers.discordGuildId eq guildId) and (Managers.managerId eq discordId)).firstOrNull()
managerRow ?: throw RuntimeException("Manager not found! $discordId")
managerRow.delete()
managerRow
}
}
}

View File

@ -26,6 +26,19 @@ object SongConfigService {
} }
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 { fun updatePersonalLimit(user: User, limit: Int): SongConfig {
return transaction { return transaction {
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull() var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
@ -47,6 +60,18 @@ object SongConfigService {
} }
} }
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 { fun updateStreamerOnly(user: User, config: Boolean): SongConfig {
return transaction { return transaction {
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull() var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
@ -58,16 +83,4 @@ object SongConfigService {
songConfig songConfig
} }
} }
fun updateDisabled(user: User, config: Boolean): SongConfig {
return transaction {
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
if (songConfig == null) {
songConfig = initConfig(user)
}
songConfig.disabled = config
songConfig
}
}
} }

View File

@ -1,66 +1,49 @@
package space.mori.chzzk_bot.common.services package space.mori.chzzk_bot.common.services
import org.jetbrains.exposed.dao.load import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import space.mori.chzzk_bot.common.models.User import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.models.Users import space.mori.chzzk_bot.common.models.Users
object UserService { object UserService {
fun saveUser(username: String, token: String): User { fun saveUser(username: String, token: String, discordID: Long): User {
return transaction { return transaction {
User.new { User.new {
this.username = username this.username = username
this.token = token this.token = token
this.discord = discordID
} }
} }
} }
fun updateUser(user: User, chzzkId: String, username: String): User {
return transaction {
user.token = chzzkId
user.username = username
user
}
}
fun updateUser(user: User, discordID: Long): User {
return transaction {
user.discord = discordID
user.load(User::subordinates, User::managers)
user
}
}
fun getUser(id: Int): User? { fun getUser(id: Int): User? {
return transaction { return transaction {
val user = User.find{ Users.id eq id }.firstOrNull() User.findById(id)
user?.load(User::subordinates, User::managers)
user
} }
} }
fun getUser(discordID: Long): User? { fun getUser(discordID: Long): User? {
return transaction { return transaction {
val user = User.find{ Users.discord eq discordID }.firstOrNull() val users = User.find(Users.discord eq discordID)
user?.load(User::subordinates, User::managers)
user users.firstOrNull()
} }
} }
fun getUser(chzzkID: String): User? { fun getUser(chzzkID: String): User? {
return transaction { return transaction {
val user = User.find{ Users.token eq chzzkID }.firstOrNull() val users = User.find(Users.token eq chzzkID)
user?.load(User::subordinates, User::managers)
user users.firstOrNull()
} }
} }
fun getUserWithGuildId(discordGuildId: Long): User? { fun getUserWithGuildId(discordGuildId: Long): User? {
return transaction { return transaction {
val user = User.find { Users.liveAlertGuild eq discordGuildId }.firstOrNull() val users = User.find(Users.liveAlertGuild eq discordGuildId)
user?.load(User::subordinates, User::managers)
user users.firstOrNull()
} }
} }
@ -70,46 +53,18 @@ object UserService {
} }
} }
fun updateLiveAlert(user: User, guildId: Long, channelId: Long, alertMessage: String?): User { fun updateLiveAlert(id: Int, guildId: Long, channelId: Long, alertMessage: String?): User {
return transaction { return transaction {
user.liveAlertGuild = guildId val updated = Users.update({ Users.id eq id }) {
user.liveAlertChannel = channelId it[liveAlertGuild] = guildId
user.liveAlertMessage = alertMessage ?: "" it[liveAlertChannel] = channelId
it[liveAlertMessage] = alertMessage ?: ""
}
user.load(User::subordinates, User::managers) if(updated == 0) throw RuntimeException("User not found! $id")
val users = User.find { Users.id eq id }
user return@transaction users.first()
}
}
fun setIsDisabled(user: User, disabled: Boolean): User {
return transaction {
user.isDisabled = disabled
user
}
}
fun setIsStartupDisabled(user: User, disabled: Boolean): User {
return transaction {
user.isDisableStartupMsg = disabled
user
}
}
fun setAccessToken(user: User, accessToken: String): User {
return transaction {
user.accessToken = accessToken
user
}
}
fun setRefreshToken(user: User, accessToken: String, refreshToken: String): User {
return transaction {
user.accessToken = accessToken
user.refreshToken = refreshToken
user
} }
} }
} }

View File

@ -52,18 +52,47 @@ data class NicknameColor(
val colorCode: String = "" val colorCode: String = ""
) )
data class LiveStatus( // Stream info
val liveTitle: String, data class IStreamInfo(
val status: String, val liveId: Int = 0,
val concurrentUserCount: Int, val liveTitle: String = "",
val accumulateCount: Int, val status: String = "",
val paidPromotion: Boolean, val liveImageUrl: String = "",
val adult: Boolean, val defaultThumbnailImageUrl: String? = null,
val krOnlyViewing: Boolean, val concurrentUserCount: Int = 0,
val openDate: String, val accumulateCount: Int = 0,
val closeDate: String?, val openDate: String = "",
val clipActive: Boolean, val closeDate: String = "",
val chatChannelId: String val adult: Boolean = false,
val clipActive: Boolean = false,
val tags: List<String> = emptyList(),
val chatChannelId: String = "",
val categoryType: String = "",
val liveCategory: String = "",
val liveCategoryValue: String = "",
val chatActive: Boolean = true,
val chatAvailableGroup: String = "",
val paidPromotion: Boolean = false,
val chatAvailableCondition: String = "",
val minFollowerMinute: Int = 0,
val livePlaybackJson: String = "",
val p2pQuality: List<Any> = emptyList(),
val channel: Channel = Channel(),
val livePollingStatusJson: String = "",
val userAdultStatus: String? = null,
val chatDonationRankingExposure: Boolean = true,
val adParameter: AdParameter = AdParameter()
)
data class Channel(
val channelId: String = "",
val channelName: String = "",
val channelImageUrl: String = "",
val verifiedMark: Boolean = false
)
data class AdParameter(
val tag: String = ""
) )
// OkHttpClient에 Interceptor 추가 // OkHttpClient에 Interceptor 추가
@ -99,23 +128,21 @@ fun getFollowDate(chatID: String, userId: String) : IData<IFollowContent?> {
} }
} }
fun getChzzkChannelId(channelId: String): String? { fun getStreamInfo(userId: String) : IData<IStreamInfo?> {
val url = "https://api.chzzk.naver.com/polling/v3/channels/$channelId/live-status?includePlayerRecommendContent=false" 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)
.header("Content-Type", "application/json")
.get()
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
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 data = gson.fromJson(body, object: TypeToken<IData<LiveStatus?>>() {}) val follow = gson.fromJson(body, object: TypeToken<IData<IStreamInfo?>>() {})
return data.content?.chatChannelId return follow
} catch(e: Exception) { } catch(e: Exception) {
throw e throw e
} }
} }
} }

View File

@ -15,7 +15,7 @@ data class YoutubeVideo(
val length: Int val length: Int
) )
val regex = ".*(?:youtu.be/|v/|u/\\w/|embed/|watch\\?v=|&v=|music\\.youtube\\.com/.*?\\?v=)([^#&?]*).*".toRegex() val regex = ".*(?:youtu.be/|v/|u/\\w/|embed/|watch\\?v=|&v=)([^#&?]*).*".toRegex()
val durationRegex = """PT(\d+H)?(\d+M)?(\d+S)?""".toRegex() val durationRegex = """PT(\d+H)?(\d+M)?(\d+S)?""".toRegex()
val dotenv = dotenv { val dotenv = dotenv {

View File

@ -6,9 +6,5 @@ DB_PASS=chzzk
RUN_AGENT=false RUN_AGENT=false
YOUTUBE_API_KEY= YOUTUBE_API_KEY=
RAPID_KEY= RAPID_KEY=
HOST=http://localhost:8080
FRONTEND=http://localhost:3000
NAVER_CLIENT_ID=
NAVER_CLIENT_SECRET=
NID_AUT= NID_AUT=
NID_SES= NID_SES=

View File

@ -10,7 +10,7 @@ repositories {
mavenCentral() mavenCentral()
} }
val ktorVersion = "3.1.3" val ktorVersion = "2.3.12"
dependencies { dependencies {
implementation("io.ktor:ktor-server-core:$ktorVersion") implementation("io.ktor:ktor-server-core:$ktorVersion")
@ -18,30 +18,22 @@ dependencies {
implementation("io.ktor:ktor-server-websockets:$ktorVersion") implementation("io.ktor:ktor-server-websockets:$ktorVersion")
implementation("io.ktor:ktor-server-swagger:$ktorVersion") implementation("io.ktor:ktor-server-swagger:$ktorVersion")
implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion") implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("io.ktor:ktor-server-cors:$ktorVersion") implementation("io.ktor:ktor-server-cors:$ktorVersion")
implementation("io.ktor:ktor-server-swagger:$ktorVersion") implementation("io.ktor:ktor-server-swagger:$ktorVersion")
implementation("io.ktor:ktor-server-auth:$ktorVersion")
implementation("io.ktor:ktor-client-core:$ktorVersion") implementation("io.swagger.codegen.v3:swagger-codegen-generators:1.0.50")
implementation("io.ktor:ktor-client-cio:$ktorVersion")
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("io.swagger.codegen.v3:swagger-codegen-generators:1.0.54")
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core // https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0-RC")
// 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.21") implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0")
// https://mvnrepository.com/artifact/io.insert-koin/koin-core // https://mvnrepository.com/artifact/io.insert-koin/koin-core
implementation("io.insert-koin:koin-core:4.0.0") 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.12") implementation("ch.qos.logback:logback-classic:1.5.6")
// https://mvnrepository.com/artifact/io.github.cdimascio/dotenv-kotlin
implementation("io.github.cdimascio:dotenv-kotlin:6.4.2")
implementation(project(":common")) implementation(project(":common"))

View File

@ -1,49 +1,24 @@
package space.mori.chzzk_bot.webserver package space.mori.chzzk_bot.webserver
import applicationHttpClient
import io.github.cdimascio.dotenv.dotenv
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.serialization.kotlinx.* 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.auth.*
import io.ktor.server.engine.* import io.ktor.server.engine.*
import io.ktor.server.netty.* import io.ktor.server.netty.*
import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.cors.routing.* import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.plugins.swagger.* import io.ktor.server.plugins.swagger.*
import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.sessions.*
import io.ktor.server.websocket.* import io.ktor.server.websocket.*
import kotlinx.coroutines.delay
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.koin.java.KoinJavaComponent.inject
import space.mori.chzzk_bot.common.events.CoroutinesEventBus
import space.mori.chzzk_bot.common.events.UserRegisterEvent
import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.webserver.routes.* import space.mori.chzzk_bot.webserver.routes.*
import space.mori.chzzk_bot.webserver.utils.DiscordRatelimits
import java.math.BigInteger
import java.security.SecureRandom
import java.time.Duration import java.time.Duration
import kotlin.getValue
import kotlin.time.toKotlinDuration
val dotenv = dotenv { val server = embeddedServer(Netty, port = 8080) {
ignoreIfMissing = true
}
val redirects = mutableMapOf<String, String>()
val server = embeddedServer(Netty, port = 8080, ) {
install(WebSockets) { install(WebSockets) {
pingPeriod = Duration.ofSeconds(15).toKotlinDuration() pingPeriod = Duration.ofSeconds(15)
timeout = Duration.ofSeconds(100).toKotlinDuration() timeout = Duration.ofSeconds(15)
maxFrameSize = Long.MAX_VALUE maxFrameSize = Long.MAX_VALUE
masking = false masking = false
contentConverter = KotlinxWebsocketSerializationConverter(Json) contentConverter = KotlinxWebsocketSerializationConverter(Json)
@ -55,212 +30,22 @@ val server = embeddedServer(Netty, port = 8080, ) {
isLenient = true isLenient = true
}) })
} }
install(Sessions) { install(CORS) {
cookie<UserSession>("user_session", storage = MariadbSessionStorage()) {} anyHost()
} allowHeader(HttpHeaders.ContentType)
install(Authentication) {
oauth("auth-oauth-discord") {
urlProvider = { "${dotenv["HOST"]}/auth/callback/discord" }
providerLookup = { OAuthServerSettings.OAuth2ServerSettings(
name = "discord",
authorizeUrl = "https://discord.com/oauth2/authorize",
accessTokenUrl = "https://discord.com/api/oauth2/token",
clientId = dotenv["DISCORD_CLIENT_ID"],
clientSecret = dotenv["DISCORD_CLIENT_SECRET"],
requestMethod = HttpMethod.Post,
defaultScopes = listOf(),
extraAuthParameters = listOf(
Pair("permissions", "826781943872"),
Pair("response_type", "code"),
Pair("integration_type", "0"),
Pair("scope", "guilds bot identify")
),
onStateCreated = { call, state ->
call.request.queryParameters["redirectUrl"]?.let {
redirects[state] = it
}
}
)}
client = applicationHttpClient
}
} }
routing { routing {
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
route("/auth") {
// discord login
authenticate("auth-oauth-discord") {
get("/login/discord") {
}
get("/callback/discord") {
try {
val principal = call.principal<OAuthAccessTokenResponse.OAuth2>()
val session = call.sessions.get<UserSession>()
val user = session?.id?.let { UserService.getUser(it) }
if(principal != null && session != null && user != null) {
try {
val accessToken = principal.accessToken
val userInfo = getDiscordUser(accessToken)
val guilds = getUserGuilds(accessToken)
userInfo?.user?.id?.toLong()?.let { id -> UserService.updateUser(user, id) }
call.sessions.set(UserSession(
session.state,
session.id,
guilds.filter {
it.owner
}.map { it.id }
))
redirects[principal.state]?.let { redirect ->
call.respondRedirect(redirect)
return@get
}
call.respondRedirect(getFrontendURL(""))
} catch(e: Exception) {
println(e.toString())
call.respondRedirect(getFrontendURL(""))
}
} else {
call.respondRedirect(getFrontendURL(""))
}
} catch(e: Exception) {
println(e.stackTrace)
}
}
}
// naver login
get("/login") {
val state = generateSecureRandomState()
// 세션에 상태 값 저장
call.sessions.set(UserSession(
state,
"",
listOf(),
))
// OAuth 제공자의 인증 URL 구성
val authUrl = URLBuilder("https://chzzk.naver.com/account-interlock").apply {
parameters.append("clientId", dotenv["NAVER_CLIENT_ID"]) // 비표준 파라미터 이름
parameters.append("redirectUri", "${dotenv["HOST"]}/auth/callback")
parameters.append("state", state)
// 추가적인 파라미터가 필요하면 여기에 추가
}.build().toString()
// 사용자에게 인증 페이지로 리다이렉트
call.respondRedirect(authUrl)
}
get("/callback") {
val receivedState = call.parameters["state"]
val code = call.parameters["code"]
// 세션에서 상태 값 가져오기
val session = call.sessions.get<UserSession>()
if (session == null || session.state != receivedState) {
call.respond(HttpStatusCode.BadRequest, "Invalid state parameter")
return@get
}
if (code == null) {
call.respond(HttpStatusCode.BadRequest, "Missing code parameter")
return@get
}
try {
// Access Token 요청
val tokenRequest = TokenRequest(
grantType = "authorization_code",
state = session.state,
code = code,
clientId = dotenv["NAVER_CLIENT_ID"],
clientSecret = dotenv["NAVER_CLIENT_SECRET"]
)
val response = applicationHttpClient.post("https://openapi.chzzk.naver.com/auth/v1/token") {
contentType(ContentType.Application.Json)
setBody(tokenRequest)
}
val tokenResponse = response.body<TokenResponse>()
if(tokenResponse.content == null) {
call.respond(HttpStatusCode.InternalServerError, "Failed to obtain access token")
return@get
}
// Access Token 사용: 예를 들어, 사용자 정보 요청
val userInfo = getChzzkUser(tokenResponse.content.accessToken)
if(userInfo.content != null) {
var user = UserService.getUser(userInfo.content.channelId)
if(user == null) {
user = UserService.saveUser(userInfo.content.channelName , userInfo.content.channelId)
}
call.sessions.set(
UserSession(
session.state,
userInfo.content.channelId,
listOf()
)
)
UserService.setRefreshToken(user,
tokenResponse.content.accessToken,
tokenResponse.content.refreshToken ?: ""
)
dispatcher.post(UserRegisterEvent(user.token))
call.respondRedirect(getFrontendURL(""))
}
} catch (e: Exception) {
e.printStackTrace()
call.respond(HttpStatusCode.InternalServerError, "Failed to obtain access token")
}
}
// common: logout
get("/logout") {
call.sessions.clear<UserSession>()
call.response.status(HttpStatusCode.OK)
return@get
}
}
apiRoutes() apiRoutes()
apiSongRoutes() apiSongRoutes()
apiCommandRoutes()
apiTimerRoutes()
apiDiscordRoutes()
wsTimerRoutes() wsTimerRoutes()
wsSongRoutes() wsSongRoutes()
wsSongListRoutes() wsSongListRoutes()
swaggerUI("swagger-ui/index.html", "openapi/documentation.yaml") { swaggerUI("swagger-ui/index.html", "openapi/documentation.yaml") {
options { options {
version = "1.2.0" version = "1.2.0"
} }
} }
} }
install(CORS) {
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Patch)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Get)
allowHost(dotenv["FRONTEND"] ?: "localhost:3000", schemes=listOf("https"))
allowCredentials = true
allowNonSimpleContentTypes = true
}
} }
fun start() { fun start() {
@ -269,179 +54,4 @@ fun start() {
fun stop() { fun stop() {
server.stop() server.stop()
} }
fun getFrontendURL(path: String)
= "${if(dotenv["FRONTEND_HTTPS"].toBoolean()) "https://" else "http://" }${dotenv["FRONTEND"]}${path}"
@Serializable
data class UserSession(
val state: String,
val id: String,
val discordGuildList: List<String>,
)
@Serializable
data class TokenRequest(
val grantType: String,
val state: String,
val code: String,
val clientId: String,
val clientSecret: String
)
@Serializable
data class TokenResponse(
val code: Int,
val message: String?,
val content: TokenResponseBody?
)
@Serializable
data class TokenResponseBody(
val accessToken: String,
val tokenType: String,
val expiresIn: Int,
val refreshToken: String? = null
)
@Serializable
data class DiscordMeAPI(
val application: DiscordApplicationAPI,
val scopes: List<String>,
val user: DiscordUserAPI
)
@Serializable
data class DiscordApplicationAPI(
val id: String,
val name: String,
val icon: String,
val description: String,
val hook: Boolean,
val bot_public: Boolean,
val bot_require_code_grant: Boolean,
val verify_key: String
)
@Serializable
data class DiscordUserAPI(
val id: String,
val username: String,
val avatar: String,
val discriminator: String,
val global_name: String,
val public_flags: Int
)
@Serializable
data class DiscordGuildListAPI(
val id: String,
val name: String,
val icon: String?,
val banner: String?,
val owner: Boolean,
val permissions: Int,
val features: List<String>,
val roles: List<GuildRole>?
)
@Serializable
data class GuildRole(
val id: String,
val name: String,
val color: Int,
val mentionable: Boolean,
)
enum class ChannelType(val value: Int) {
GUILD_TEXT(0),
DM(1),
GUILD_VOICE(2),
GROUP_DM(3),
GUILD_CATEGORY(4),
GUILD_ANNOUNCEMENT(5),
ANNOUNCEMENT_THREAD(10),
PUBLIC_THREAD(11),
PRIVATE_THREAD(12),
GUILD_STAGE_VOICE(13),
GUILD_DIRECTORY(14),
GUILD_FORUM(15),
GUILD_MEDIA(16)
}
@Serializable
data class GuildChannel(
val id: String,
val type: Int,
val name: String?
)
suspend fun getDiscordUser(accessToken: String): DiscordMeAPI? {
if(DiscordRatelimits.isLimited()) {
delay(DiscordRatelimits.getRateReset())
}
val response: HttpResponse = applicationHttpClient.get("https://discord.com/api/oauth2/@me") {
headers {
append(HttpHeaders.Authorization, "Bearer $accessToken")
}
}
val rateLimit = response.headers["X-RateLimit-Limit"]?.toIntOrNull()
val remaining = response.headers["X-RateLimit-Remaining"]?.toIntOrNull()
val resetAfter = response.headers["X-RateLimit-Reset-After"]?.toDoubleOrNull()?.toLong()
DiscordRatelimits.setRateLimit(rateLimit, remaining, resetAfter)
return response.body<DiscordMeAPI?>()
}
suspend fun getUserGuilds(accessToken: String): List<DiscordGuildListAPI> {
if(DiscordRatelimits.isLimited()) {
delay(DiscordRatelimits.getRateReset())
}
val response = applicationHttpClient.get("https://discord.com/api/users/@me/guilds") {
headers {
append(HttpHeaders.Authorization, "Bearer $accessToken")
}
}
val rateLimit = response.headers["X-RateLimit-Limit"]?.toIntOrNull()
val remaining = response.headers["X-RateLimit-Remaining"]?.toIntOrNull()
val resetAfter = response.headers["X-RateLimit-Reset-After"]?.toDoubleOrNull()?.toLong()
DiscordRatelimits.setRateLimit(rateLimit, remaining, resetAfter)
return response.body<List<DiscordGuildListAPI>>()
}
@Serializable
data class ChzzkMeApi(
val channelId: String,
val channelName: String,
val nickname: String,
)
@Serializable
data class ChzzkApi<T>(
val code: Int,
val message: String?,
val content: T?
)
suspend fun getChzzkUser(accessToken: String): ChzzkApi<ChzzkMeApi> {
val response = applicationHttpClient.get("https://openapi.chzzk.naver.com/open/v1/users/me") {
headers {
append(HttpHeaders.Authorization, "Bearer $accessToken")
}
}
return response.body<ChzzkApi<ChzzkMeApi>>()
}
fun generateSecureRandomState(): String {
return BigInteger(130, SecureRandom()).toString(32)
}

View File

@ -1,41 +0,0 @@
package space.mori.chzzk_bot.webserver
import io.ktor.server.sessions.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
import space.mori.chzzk_bot.common.models.Session
import space.mori.chzzk_bot.common.models.Sessions as SessionTable
class MariadbSessionStorage: SessionStorage {
override suspend fun invalidate(id: String) {
return transaction {
val session = Session.find(
SessionTable.key eq id
).firstOrNull()
session?.delete()
}
}
override suspend fun read(id: String): String {
return transaction {
val session = Session.find(SessionTable.key eq id).firstOrNull()
?: throw NoSuchElementException("Session $id not found")
session.value
}
}
override suspend fun write(id: String, value: String) {
return transaction {
val session = Session.find(SessionTable.key eq id).firstOrNull()
if (session == null) {
Session.new {
this.key = id
this.value = value
}
} else {
session.value = value
}
}
}
}

View File

@ -1,17 +0,0 @@
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
@OptIn(ExperimentalSerializationApi::class)
val applicationHttpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
explicitNulls = false
})
}
}

View File

@ -1,168 +0,0 @@
package space.mori.chzzk_bot.webserver.routes
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sessions.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.transactions.transaction
import org.koin.java.KoinJavaComponent.inject
import space.mori.chzzk_bot.common.events.CommandReloadEvent
import space.mori.chzzk_bot.common.events.CoroutinesEventBus
import space.mori.chzzk_bot.common.services.CommandService
import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.webserver.UserSession
fun Routing.apiCommandRoutes() {
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
route("/commands") {
get("/{uid}") {
val uid = call.parameters["uid"]
if(uid == null) {
call.respond(HttpStatusCode.BadRequest, "UID is required")
return@get
}
val user = UserService.getUser(uid)
if(user == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@get
}
val commands = CommandService.getCommands(user)
call.respond(HttpStatusCode.OK, commands.map {
CommandsResponseDTO(it.command, it.content, it.failContent)
})
}
put("/{uid}") {
val uid = call.parameters["uid"]
val session = call.sessions.get<UserSession>()
val commandRequest = call.receive<CommandsRequestDTO>()
if(uid == null) {
call.respond(HttpStatusCode.BadRequest, "UID is required")
return@put
}
val user = UserService.getUser(uid)
if(user == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@put
}
val managers = transaction {
user.managers.toList()
}
if(!managers.any { it.token == session?.id } && user.token != session?.id) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@put
}
CommandService.saveCommand(user,
commandRequest.label,
commandRequest.content,
commandRequest.failContent ?: ""
)
CoroutineScope(Dispatchers.Default).launch {
for(i: Int in 0..3) {
dispatcher.post(CommandReloadEvent(user.token))
}
}
call.respond(HttpStatusCode.OK)
}
post("/{uid}") {
val uid = call.parameters["uid"]
val session = call.sessions.get<UserSession>()
val commandRequest = call.receive<CommandsRequestDTO>()
if(uid == null) {
call.respond(HttpStatusCode.BadRequest, "UID is required")
return@post
}
val user = UserService.getUser(uid)
if(user == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@post
}
val managers = transaction {
user.managers.toList()
}
if(!managers.any { it.token == session?.id } && user.token != session?.id) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@post
}
try {
CommandService.updateCommand(
user,
commandRequest.label,
commandRequest.content,
commandRequest.failContent ?: ""
)
CoroutineScope(Dispatchers.Default).launch {
for(i: Int in 0..3) {
dispatcher.post(CommandReloadEvent(user.token))
}
}
call.respond(HttpStatusCode.OK)
} catch(e: Exception) {
call.respond(HttpStatusCode.BadRequest)
}
}
delete("/{uid}") {
val uid = call.parameters["uid"]
val session = call.sessions.get<UserSession>()
val commandRequest = call.receive<CommandsRequestDTO>()
if(uid == null) {
call.respond(HttpStatusCode.BadRequest, "UID is required")
return@delete
}
val user = UserService.getUser(uid)
if(user == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@delete
}
val managers = transaction {
user.managers.toList()
}
if(!managers.any { it.token == session?.id } && user.token != session?.id) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@delete
}
try {
CommandService.removeCommand(user, commandRequest.label)
CoroutineScope(Dispatchers.Default).launch {
for(i: Int in 0..3) {
dispatcher.post(CommandReloadEvent(user.token))
}
}
call.respond(HttpStatusCode.OK)
} catch(e: Exception) {
call.respond(HttpStatusCode.BadRequest)
}
}
}
}
@Serializable
data class CommandsRequestDTO(
val label: String,
val content: String,
val failContent: String?
)
@Serializable
data class CommandsResponseDTO(
val label: String,
val content: String,
val failContent: String?
)

View File

@ -1,120 +0,0 @@
package space.mori.chzzk_bot.webserver.routes
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sessions.*
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.transactions.transaction
import org.koin.java.KoinJavaComponent.inject
import space.mori.chzzk_bot.common.events.CoroutinesEventBus
import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.webserver.UserSession
import space.mori.chzzk_bot.webserver.utils.DiscordGuildCache
fun Route.apiDiscordRoutes() {
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
route("/discord") {
get("/{uid}") {
val uid = call.parameters["uid"]
val session = call.sessions.get<UserSession>()
if(uid == null) {
call.respond(HttpStatusCode.BadRequest, "UID is required")
return@get
}
val user = UserService.getUser(uid)
if(user?.token == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@get
}
val managers = transaction {
user.managers.toList()
}
if(!managers.any { it.token == session?.id } && user.token != session?.id) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@get
}
if (user.discord == null) {
call.respond(HttpStatusCode.NotFound)
return@get
}
call.respond(HttpStatusCode.OK, GuildSettings(
user.liveAlertGuild.toString(),
user.liveAlertChannel.toString(),
user.liveAlertMessage
))
return@get
}
post("/{uid}") {
val uid = call.parameters["uid"]
val session = call.sessions.get<UserSession>()
val body: GuildSettings = call.receive()
if(uid == null) {
call.respond(HttpStatusCode.BadRequest, "UID is required")
return@post
}
val user = UserService.getUser(uid)
if(user?.token == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@post
}
val managers = transaction {
user.managers.toList()
}
if(!managers.any { it.token == session?.id } && user.token != session?.id) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@post
}
UserService.updateLiveAlert(user, body.guildId?.toLong() ?: 0L, body.channelId?.toLong() ?: 0L, body.message)
call.respond(HttpStatusCode.OK)
}
get("/guild/{gid}") {
val gid = call.parameters["gid"]
val session = call.sessions.get<UserSession>()
if(gid == null) {
call.respond(HttpStatusCode.BadRequest, "GID is required")
return@get
}
if(session == null) {
call.respond(HttpStatusCode.BadRequest, "Session is required")
return@get
}
val user = UserService.getUser(session.id)
if(user == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@get
}
val guild = DiscordGuildCache.getCachedGuilds(gid)
if(guild == null) {
call.respond(HttpStatusCode.NotFound)
return@get
}
call.respond(HttpStatusCode.OK, guild)
return@get
}
get("/guilds") {
val session = call.sessions.get<UserSession>()
if(session == null) {
call.respond(HttpStatusCode.BadRequest, "Session is required")
return@get
}
call.respond(HttpStatusCode.OK, DiscordGuildCache.getCachedGuilds(session.discordGuildList))
return@get
}
}
}
@Serializable
data class GuildSettings(
val guildId: String?,
val channelId: String?,
val message: String? = null,
)

View File

@ -1,20 +1,12 @@
package space.mori.chzzk_bot.webserver.routes package space.mori.chzzk_bot.webserver.routes
import io.ktor.http.* import io.ktor.http.*
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 io.ktor.server.sessions.*
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.transactions.transaction
import org.koin.java.KoinJavaComponent.inject
import space.mori.chzzk_bot.common.events.CoroutinesEventBus
import space.mori.chzzk_bot.common.services.SongConfigService import space.mori.chzzk_bot.common.services.SongConfigService
import space.mori.chzzk_bot.common.services.UserService import space.mori.chzzk_bot.common.utils.getStreamInfo
import space.mori.chzzk_bot.webserver.UserSession
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull
import space.mori.chzzk_bot.common.events.ChzzkUserFindEvent
import space.mori.chzzk_bot.common.events.ChzzkUserReceiveEvent
@Serializable @Serializable
data class GetUserDTO( data class GetUserDTO(
@ -33,30 +25,13 @@ data class GetSessionDTO(
val maxQueueSize: Int, val maxQueueSize: Int,
val maxUserSize: Int, val maxUserSize: Int,
val isStreamerOnly: Boolean, val isStreamerOnly: Boolean,
val isDisabled: Boolean
) )
fun Routing.apiRoutes() { fun Routing.apiRoutes() {
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
suspend fun getChzzkUserWithId(uid: String): ChzzkUserReceiveEvent? {
val completableDeferred = CompletableDeferred<ChzzkUserReceiveEvent>()
dispatcher.subscribe(ChzzkUserReceiveEvent::class) { event ->
if (event.uid == uid) {
completableDeferred.complete(event)
}
}
val user = withTimeoutOrNull(5000) {
dispatcher.post(ChzzkUserFindEvent(uid))
completableDeferred.await()
}
return user
}
route("/") { route("/") {
get { get {
call.respondText("Hello World!", status = call.respondText("Hello World!", status =
HttpStatusCode.OK) HttpStatusCode.OK)
} }
} }
route("/health") { route("/health") {
@ -67,83 +42,59 @@ fun Routing.apiRoutes() {
route("/user/{uid}") { route("/user/{uid}") {
get { get {
val uid = call.parameters["uid"] val uid = call.parameters["uid"]
if(uid == null) { if(uid == null) {
call.respondText("Require UID", status = HttpStatusCode.NotFound) call.respondText("Require UID", status = HttpStatusCode.NotFound)
return@get return@get
} }
val user = getChzzkUserWithId(uid) val user = getStreamInfo(uid)
if (user?.find == false) { if(user.content == null) {
call.respondText("User not found", status = HttpStatusCode.NotFound) call.respondText("User not found", status = HttpStatusCode.NotFound)
return@get return@get
} else { } else {
call.respond(HttpStatusCode.OK, GetUserDTO( call.respond(HttpStatusCode.OK, GetUserDTO(
user?.uid ?: "", user.content!!.channel.channelId,
user?.nickname ?: "", user.content!!.channel.channelName,
user?.isStreamOn ?: false, user.content!!.status == "OPEN",
user?.avatarUrl ?: "" user.content!!.channel.channelImageUrl
)) ))
} }
} }
} }
route("/user") { route("/user") {
get { get {
val session = call.sessions.get<UserSession>() call.respondText("Require UID", status = HttpStatusCode.NotFound)
}
if(session == null) { }
call.respondText("No session found", status = HttpStatusCode.Unauthorized) route("/session/{sid}") {
get {
val sid = call.parameters["sid"]
if(sid == null) {
call.respondText("Require SID", status = HttpStatusCode.NotFound)
return@get return@get
} }
var user = UserService.getUser(session.id) val user = SongConfigService.getUserByToken(sid)
val session = SongConfigService.getConfig(sid)
if(user == null) { if(user == null) {
user = UserService.saveUser("임시닉네임", session.id) call.respondText("User not found", status = HttpStatusCode.NotFound)
}
val songConfig = SongConfigService.getConfig(user)
val status = getChzzkUserWithId(user.token)
val returnUsers = mutableListOf<GetSessionDTO>()
if(status == null) {
call.respondText("No user found", status = HttpStatusCode.NotFound)
return@get 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
))
} }
}
if (user.username == "임시닉네임") { }
status.let { stats -> UserService.updateUser(user, stats.uid ?: "", stats.nickname ?: "") } route("/session") {
} get {
call.respondText("Require SID", status = HttpStatusCode.NotFound)
returnUsers.add(GetSessionDTO(
status.uid ?: user.token,
status.nickname ?: user.username,
status.isStreamOn == true,
status.avatarUrl ?: "",
songConfig.queueLimit,
songConfig.personalLimit,
songConfig.streamerOnly,
songConfig.disabled
))
val subordinates = transaction {
user.subordinates.toList()
}
returnUsers.addAll(subordinates.map {
val subStatus = getChzzkUserWithId(it.token)
return@map if (subStatus == null) {
null
} else {
GetSessionDTO(
subStatus.uid ?: "",
subStatus.nickname ?: "",
subStatus.isStreamOn == true,
subStatus.avatarUrl ?: "",
0,
0,
false,
false
)
}
}.filterNotNull())
call.respond(HttpStatusCode.OK, returnUsers)
} }
} }
} }

View File

@ -8,8 +8,6 @@ import kotlinx.serialization.Serializable
import space.mori.chzzk_bot.common.models.SongList import space.mori.chzzk_bot.common.models.SongList
import space.mori.chzzk_bot.common.services.SongListService import space.mori.chzzk_bot.common.services.SongListService
import space.mori.chzzk_bot.common.services.UserService import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.common.utils.YoutubeVideo
import space.mori.chzzk_bot.webserver.utils.CurrentSong
@Serializable @Serializable
data class SongsDTO( data class SongsDTO(
@ -20,12 +18,6 @@ data class SongsDTO(
val reqName: String val reqName: String
) )
@Serializable
data class SongsResponseDTO(
val current: SongsDTO? = null,
val next: List<SongsDTO> = emptyList()
)
fun SongList.toDTO(): SongsDTO = SongsDTO( fun SongList.toDTO(): SongsDTO = SongsDTO(
this.url, this.url,
this.name, this.name,
@ -34,14 +26,6 @@ fun SongList.toDTO(): SongsDTO = SongsDTO(
this.reqName this.reqName
) )
fun YoutubeVideo.toDTO(): SongsDTO = SongsDTO(
this.url,
this.name,
this.author,
this.length,
""
)
fun Routing.apiSongRoutes() { fun Routing.apiSongRoutes() {
route("/songs/{uid}") { route("/songs/{uid}") {
get { get {
@ -53,12 +37,7 @@ fun Routing.apiSongRoutes() {
} }
val songs = SongListService.getSong(user) val songs = SongListService.getSong(user)
call.respond(HttpStatusCode.OK, call.respond(HttpStatusCode.OK, songs.map { it.toDTO() })
SongsResponseDTO(
CurrentSong.getSong(user)?.toDTO(),
songs.map { it.toDTO() }
)
)
} }
} }
route("/songs") { route("/songs") {

View File

@ -1,79 +0,0 @@
package space.mori.chzzk_bot.webserver.routes
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sessions.*
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.transactions.transaction
import space.mori.chzzk_bot.common.events.TimerType
import space.mori.chzzk_bot.common.services.TimerConfigService
import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.webserver.UserSession
fun Routing.apiTimerRoutes() {
route("/timerapi") {
get("/{uid}") {
val uid = call.parameters["uid"]
val session = call.sessions.get<UserSession>()
if(uid == null) {
call.respond(HttpStatusCode.BadRequest, "UID is required")
return@get
}
val user = UserService.getUser(uid)
if(user == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@get
}
val managers = transaction {
user.managers.toList()
}
if(!managers.any { it.token == session?.id } && user.token != session?.id) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@get
}
val timerConfig = TimerConfigService.getConfig(user)
call.respond(HttpStatusCode.OK, TimerResponseDTO(timerConfig?.option ?: 0))
}
put("/{uid}") {
val uid = call.parameters["uid"]
val session = call.sessions.get<UserSession>()
val request = call.receive<TimerRequestDTO>()
if(uid == null) {
call.respond(HttpStatusCode.BadRequest, "UID is required")
return@put
}
val user = UserService.getUser(uid)
if(user == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@put
}
val managers = transaction {
user.managers.toList()
}
if(!managers.any { it.token == session?.id } && user.token != session?.id) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@put
}
TimerConfigService.saveOrUpdateConfig(user, TimerType.entries[request.option])
call.respond(HttpStatusCode.OK)
}
}
}
@Serializable
data class TimerRequestDTO(
val option: Int
)
@Serializable
data class TimerResponseDTO(
val option: Int
)

View File

@ -1,357 +1,210 @@
package space.mori.chzzk_bot.webserver.routes package space.mori.chzzk_bot.webserver.routes
import io.ktor.client.plugins.websocket.WebSocketException
import io.ktor.server.application.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.sessions.*
import io.ktor.server.websocket.* import io.ktor.server.websocket.*
import io.ktor.websocket.* import io.ktor.websocket.*
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
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.models.SongList import space.mori.chzzk_bot.common.models.Counters.withDefinition
import space.mori.chzzk_bot.common.models.User import space.mori.chzzk_bot.common.services.SongConfigService
import space.mori.chzzk_bot.common.services.SongListService import space.mori.chzzk_bot.common.services.SongListService
import space.mori.chzzk_bot.common.services.UserService import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.common.utils.YoutubeVideo
import space.mori.chzzk_bot.common.utils.getYoutubeVideo import space.mori.chzzk_bot.common.utils.getYoutubeVideo
import space.mori.chzzk_bot.webserver.UserSession
import space.mori.chzzk_bot.webserver.utils.CurrentSong
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
fun Routing.wsSongListRoutes() { fun Routing.wsSongListRoutes() {
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
val status = ConcurrentHashMap<String, SongType>()
val logger = LoggerFactory.getLogger("WSSongListRoutes") val logger = LoggerFactory.getLogger("WSSongListRoutes")
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java) val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
val songListScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// Manage all active sessions fun addSession(sid: String, session: WebSocketServerSession) {
val sessionHandlers = ConcurrentHashMap<String, SessionHandler>() sessions.computeIfAbsent(sid) { ConcurrentLinkedQueue() }.add(session)
}
// Handle application shutdown fun removeSession(sid: String, session: WebSocketServerSession) {
environment.monitor.subscribe(ApplicationStopped) { sessions[sid]?.remove(session)
sessionHandlers.values.forEach { if(sessions[sid]?.isEmpty() == true) {
songListScope.launch { sessions.remove(sid)
it.close(CloseReason(CloseReason.Codes.NORMAL, "Server shutting down"))
}
} }
} }
// WebSocket endpoint webSocket("/songlist/{sid}") {
webSocket("/songlist") { val sid = call.parameters["sid"]
val session = call.sessions.get<UserSession>() val session = sid?.let { SongConfigService.getConfig(it) }
val user: User? = session?.id?.let { UserService.getUser(it) } val user = sid?.let {SongConfigService.getUserByToken(sid) }
if (sid == null) {
if (user == null) { close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid SID"))
return@webSocket
}
if (user == null || session == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid SID")) close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid SID"))
return@webSocket return@webSocket
} }
val uid = user.token addSession(sid, this)
// Ensure only one session per user if(status[sid] == SongType.STREAM_OFF) {
sessionHandlers[uid]?.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Another session is already active.")) CoroutineScope(Dispatchers.Default).launch {
sendSerialized(SongResponse(
SongType.STREAM_OFF.value,
user.token,
null,
null,
null,
null,
null
))
}
removeSession(sid, this)
}
val handler = SessionHandler(uid, this, dispatcher, logger)
sessionHandlers[uid] = handler
// Initialize session
handler.initialize()
// Listen for incoming frames
try { try {
for (frame in incoming) { for (frame in incoming) {
when (frame) { when(frame) {
is Frame.Text -> handler.handleTextFrame(frame.readText()) 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)) is Frame.Ping -> send(Frame.Pong(frame.data))
else -> Unit else -> {
}
} }
} }
} catch (e: ClosedReceiveChannelException) { } catch(e: ClosedReceiveChannelException) {
logger.info("Session closed: ${e.message}") logger.error("Error in WebSocket: ${e.message}")
} catch (e: IOException) {
logger.error("IO error: ${e.message}")
} catch (e: Exception) {
logger.error("Unexpected error: ${e.message}")
} finally { } finally {
sessionHandlers.remove(uid) removeSession(sid, this)
handler.close(CloseReason(CloseReason.Codes.NORMAL, "Session ended"))
} }
} }
// Subscribe to SongEvents dispatcher.subscribe(SongEvent::class) {
dispatcher.subscribe(SongEvent::class) { event -> logger.debug("SongEvent: {} / {} {}", it.uid, it.type, it.name)
val handler = sessionHandlers[event.uid] CoroutineScope(Dispatchers.Default).launch {
songListScope.launch { val user = UserService.getUser(it.uid)
handler?.sendSongResponse(event) if(user != null) {
} val session = SongConfigService.getConfig(user)
} sessions[session.token ?: ""]?.forEach { ws ->
ws.sendSerialized(
// Subscribe to TimerEvents SongResponse(
dispatcher.subscribe(TimerEvent::class) { event -> it.type.value,
if (event.type == TimerType.STREAM_OFF) { it.uid,
val handler = sessionHandlers[event.uid] it.reqUid,
songListScope.launch { it.name,
handler?.sendTimerOff() it.author,
} it.time,
} it.url
} )
} )
class SessionHandler(
private val uid: String,
private val session: WebSocketServerSession,
private val dispatcher: CoroutinesEventBus,
private val logger: Logger
) {
private val ackMap = ConcurrentHashMap<String, CompletableDeferred<Boolean>>()
private val sessionMutex = Mutex()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
suspend fun initialize() {
// Send initial status if needed,
// For example, send STREAM_OFF if applicable
// This can be extended based on your requirements
}
suspend fun handleTextFrame(text: String) {
if (text.trim() == "ping") {
session.send("pong")
return
}
val data = try {
Json.decodeFromString<SongRequest>(text)
} catch (e: Exception) {
logger.warn("Failed to decode SongRequest: ${e.message}")
return
}
when (data.type) {
SongType.ACK.value -> handleAck(data.uid)
else -> handleSongRequest(data)
}
}
private fun handleAck(requestUid: String) {
ackMap[requestUid]?.complete(true)
ackMap.remove(requestUid)
}
private fun handleSongRequest(data: SongRequest) {
scope.launch {
SongRequestProcessor.process(data, uid, dispatcher, this@SessionHandler, logger)
}
}
suspend fun sendSongResponse(event: SongEvent) {
val response = SongResponse(
type = event.type.value,
uid = event.uid,
reqUid = event.reqUid,
current = event.current?.toSerializable(),
next = event.next?.toSerializable(),
delUrl = event.delUrl
)
sendWithRetry(response)
}
suspend fun sendTimerOff() {
val response = SongResponse(
type = TimerType.STREAM_OFF.value,
uid = uid,
reqUid = null,
current = null,
next = null,
delUrl = null
)
sendWithRetry(response)
}
private suspend fun sendWithRetry(res: SongResponse, maxRetries: Int = 5, delayMillis: Long = 3000L) {
var attempt = 0
while (attempt < maxRetries) {
try {
session.sendSerialized(res)
val ackDeferred = CompletableDeferred<Boolean>()
ackMap[res.uid] = ackDeferred
val ackReceived = withTimeoutOrNull(5000L) { ackDeferred.await() } ?: false
if (ackReceived) {
logger.debug("ACK received for message to $uid on attempt $attempt.")
return
} else {
logger.warn("ACK not received for message to $uid on attempt $attempt.")
} }
} catch (e: IOException) { }
logger.warn("Failed to send message to $uid on attempt $attempt: ${e.message}") }
if (e is WebSocketException) { }
close(CloseReason(CloseReason.Codes.PROTOCOL_ERROR, "WebSocket error")) dispatcher.subscribe(TimerEvent::class) {
return 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)
}
} }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.warn("Unexpected error while sending message to $uid on attempt $attempt: ${e.message}")
}
attempt++
delay(delayMillis)
}
logger.error("Failed to send message to $uid after $maxRetries attempts.")
}
suspend fun close(reason: CloseReason) {
try {
session.close(reason)
} catch (e: Exception) {
logger.warn("Error closing session: ${e.message}")
}
}
}
object SongRequestProcessor {
private val songMutex = Mutex()
suspend fun process(
data: SongRequest,
uid: String,
dispatcher: CoroutinesEventBus,
handler: SessionHandler,
logger: Logger
) {
val user = UserService.getUser(uid) ?: return
when (data.type) {
SongType.ADD.value -> handleAdd(data, user, dispatcher, handler, logger)
SongType.REMOVE.value -> handleRemove(data, user, dispatcher, logger)
SongType.NEXT.value -> handleNext(user, dispatcher, logger)
else -> {
// Handle other types if necessary
} }
} }
} }
private suspend fun handleAdd(
data: SongRequest,
user: User,
dispatcher: CoroutinesEventBus,
handler: SessionHandler,
logger: Logger
) {
val url = data.url ?: return
val youtubeVideo = getYoutubeVideo(url) ?: run {
logger.warn("Failed to fetch YouTube video for URL: $url")
return
}
songMutex.withLock {
SongListService.saveSong(
user,
user.token,
url,
youtubeVideo.name,
youtubeVideo.author,
youtubeVideo.length,
user.username
)
}
dispatcher.post(
SongEvent(
uid = user.token,
type = SongType.ADD,
reqUid = user.token,
current = CurrentSong.getSong(user),
next = youtubeVideo
)
)
}
private suspend fun handleRemove(
data: SongRequest,
user: User,
dispatcher: CoroutinesEventBus,
logger: Logger
) {
val url = data.url ?: return
songMutex.withLock {
val songs = SongListService.getSong(user)
val exactSong = songs.firstOrNull { it.url == url }
if (exactSong != null) {
SongListService.deleteSong(user, exactSong.uid, exactSong.name)
}
}
dispatcher.post(
SongEvent(
uid = user.token,
type = SongType.REMOVE,
delUrl = url,
reqUid = null,
current = null,
next = null,
)
)
}
private suspend fun handleNext(
user: User,
dispatcher: CoroutinesEventBus,
logger: Logger
) {
var song: SongList? = null
var youtubeVideo: YoutubeVideo? = null
songMutex.withLock {
val songList = SongListService.getSong(user)
if (songList.isNotEmpty()) {
song = songList[0]
SongListService.deleteSong(user, song.uid, song.name)
}
}
song?.let {
youtubeVideo = YoutubeVideo(
it.url,
it.name,
it.author,
it.time
)
}
dispatcher.post(
SongEvent(
uid = user.token,
type = SongType.NEXT,
current = null,
next = youtubeVideo,
reqUid = null,
delUrl = null
)
)
CurrentSong.setSong(user, youtubeVideo)
}
} }
@Serializable @Serializable
data class SongRequest( data class SongRequest(
val type: Int, val type: Int,
val uid: String, val uid: String,
val url: String? = null, val url: String?,
val maxQueue: Int? = null, val maxQueue: Int?,
val maxUserLimit: Int? = null, val maxUserLimit: Int?,
val isStreamerOnly: Boolean? = null, val isStreamerOnly: Boolean?,
val remove: Int? = null, val remove: Int?
val isDisabled: Boolean? = null )
)

View File

@ -1,38 +1,24 @@
package space.mori.chzzk_bot.webserver.routes package space.mori.chzzk_bot.webserver.routes
import io.ktor.server.application.ApplicationStopped
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.websocket.* import io.ktor.server.websocket.*
import io.ktor.websocket.* import io.ktor.websocket.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.koin.java.KoinJavaComponent.inject import org.koin.java.KoinJavaComponent.inject
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 space.mori.chzzk_bot.common.utils.YoutubeVideo
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
val songScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun Routing.wsSongRoutes() { fun Routing.wsSongRoutes() {
environment.monitor.subscribe(ApplicationStopped) {
songScope.cancel()
}
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>() val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
val status = ConcurrentHashMap<String, SongType>() val status = ConcurrentHashMap<String, SongType>()
val logger = LoggerFactory.getLogger("WSSongRoutes") val logger = LoggerFactory.getLogger("WSSongRoutes")
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
val ackMap = ConcurrentHashMap<String, ConcurrentHashMap<WebSocketServerSession, CompletableDeferred<Boolean>>>()
fun addSession(uid: String, session: WebSocketServerSession) { fun addSession(uid: String, session: WebSocketServerSession) {
sessions.computeIfAbsent(uid) { ConcurrentLinkedQueue() }.add(session) sessions.computeIfAbsent(uid) { ConcurrentLinkedQueue() }.add(session)
@ -40,174 +26,102 @@ fun Routing.wsSongRoutes() {
fun removeSession(uid: String, session: WebSocketServerSession) { fun removeSession(uid: String, session: WebSocketServerSession) {
sessions[uid]?.remove(session) sessions[uid]?.remove(session)
if (sessions[uid]?.isEmpty() == true) { if(sessions[uid]?.isEmpty() == true) {
sessions.remove(uid) sessions.remove(uid)
} }
} }
suspend fun sendWithRetry(
session: WebSocketServerSession,
message: SongResponse,
maxRetries: Int = 3,
delayMillis: Long = 2000L
): Boolean {
var attempt = 0
while (attempt < maxRetries) {
try {
session.sendSerialized(message)
val ackDeferred = CompletableDeferred<Boolean>()
ackMap.computeIfAbsent(message.uid) { ConcurrentHashMap() }[session] = ackDeferred
val ackReceived = withTimeoutOrNull(delayMillis) { ackDeferred.await() } ?: false
if (ackReceived) {
ackMap[message.uid]?.remove(session)
return true
} else {
attempt++
logger.warn("ACK not received for message to ${message.uid} on attempt $attempt.")
}
} catch (e: Exception) {
attempt++
logger.info("Failed to send message on attempt $attempt. Retrying in $delayMillis ms.")
e.printStackTrace()
delay(delayMillis)
}
}
return false
}
fun broadcastMessage(userId: String, message: SongResponse) {
val userSessions = sessions[userId]
userSessions?.forEach { session ->
songScope.launch {
val success = sendWithRetry(session, message)
if (!success) {
logger.info("Removing session for user $userId due to repeated failures.")
removeSession(userId, session)
}
}
}
}
webSocket("/song/{uid}") { webSocket("/song/{uid}") {
logger.info("WebSocket connection attempt received")
val uid = call.parameters["uid"] val uid = call.parameters["uid"]
val user = uid?.let { UserService.getUser(it) } val user = uid?.let { UserService.getUser(it) }
if (uid == null || user == null) { if (uid == null) {
logger.warn("Invalid UID: $uid")
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID")) close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID"))
return@webSocket 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 { try {
addSession(uid, this) for (frame in incoming) {
logger.info("WebSocket connection established for user: $uid") when(frame) {
is Frame.Text -> {
}
is Frame.Ping -> send(Frame.Pong(frame.data))
else -> {
// Start heartbeat
val heartbeatJob = songScope.launch {
while (true) {
try {
send(Frame.Ping(ByteArray(0)))
delay(30000) // 30 seconds
} catch (e: Exception) {
logger.error("Heartbeat failed for user $uid", e)
break
} }
} }
} }
} catch(e: ClosedReceiveChannelException) {
if (status[uid] == SongType.STREAM_OFF) { logger.error("Error in WebSocket: ${e.message}")
songScope.launch { } finally {
sendSerialized( removeSession(uid, this)
SongResponse(
SongType.STREAM_OFF.value,
uid,
null,
null,
null,
)
)
}
}
try {
for (frame in incoming) {
when (frame) {
is Frame.Text -> {
val text = frame.readText().trim()
if (text == "ping") {
send("pong")
} else {
val data = Json.decodeFromString<SongRequest>(text)
if (data.type == SongType.ACK.value) {
ackMap[data.uid]?.get(this)?.complete(true)
ackMap[data.uid]?.remove(this)
}
}
}
is Frame.Ping -> send(Frame.Pong(frame.data))
else -> {}
}
}
} catch (e: ClosedReceiveChannelException) {
logger.error("WebSocket connection closed for user $uid: ${e.message}")
} catch (e: Exception) {
logger.error("Unexpected error in WebSocket for user $uid", e)
} finally {
logger.info("Cleaning up WebSocket connection for user $uid")
removeSession(uid, this)
ackMap[uid]?.remove(this)
heartbeatJob.cancel()
}
} catch(e: Exception) {
logger.error("Unexpected error in WebSocket for user $uid", e)
} }
} }
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
dispatcher.subscribe(SongEvent::class) { dispatcher.subscribe(SongEvent::class) {
logger.debug("SongEvent: {} / {} {}", it.uid, it.type, it.current?.name) logger.debug("SongEvent: {} / {} {}", it.uid, it.type, it.name)
songScope.launch { CoroutineScope(Dispatchers.Default).launch {
broadcastMessage( sessions[it.uid]?.forEach { ws ->
it.uid, SongResponse( ws.sendSerialized(SongResponse(
it.type.value, it.type.value,
it.uid, it.uid,
it.reqUid, it.reqUid,
it.current?.toSerializable(), it.name,
it.next?.toSerializable(), it.author,
it.delUrl it.time,
) it.url
) ))
}
} }
} }
dispatcher.subscribe(TimerEvent::class) { dispatcher.subscribe(TimerEvent::class) {
if (it.type == TimerType.STREAM_OFF) { if(it.type == TimerType.STREAM_OFF) {
songScope.launch { CoroutineScope(Dispatchers.Default).launch {
broadcastMessage( sessions[it.uid]?.forEach { ws ->
it.uid, SongResponse( ws.sendSerialized(SongResponse(
it.type.value, it.type.value,
it.uid, it.uid,
null, null,
null, null,
null, null,
) null,
) null
))
}
} }
} }
} }
} }
@Serializable
data class SerializableYoutubeVideo(
val url: String,
val name: String,
val author: String,
val length: Int
)
fun YoutubeVideo.toSerializable() = SerializableYoutubeVideo(url, name, author, length)
@Serializable @Serializable
data class SongResponse( data class SongResponse(
val type: Int, val type: Int,
val uid: String, val uid: String,
val reqUid: String?, val reqUid: String?,
val current: SerializableYoutubeVideo? = null, val name: String?,
val next: SerializableYoutubeVideo? = null, val author: String?,
val delUrl: String? = null val time: Int?,
val url: String?
) )

View File

@ -1,42 +1,30 @@
package space.mori.chzzk_bot.webserver.routes package space.mori.chzzk_bot.webserver.routes
import io.ktor.server.application.ApplicationStopped
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.websocket.* import io.ktor.server.websocket.*
import io.ktor.websocket.* import io.ktor.websocket.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.koin.java.KoinJavaComponent.inject import org.koin.java.KoinJavaComponent.inject
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.TimerConfigService 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.webserver.utils.CurrentTimer
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
val timerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun Routing.wsTimerRoutes() { fun Routing.wsTimerRoutes() {
environment.monitor.subscribe(ApplicationStopped) {
timerScope.cancel()
}
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>() val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
val status = ConcurrentHashMap<String, TimerType>()
val logger = LoggerFactory.getLogger("WSTimerRoutes") val logger = LoggerFactory.getLogger("WSTimerRoutes")
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
val ackMap = ConcurrentHashMap<String, ConcurrentHashMap<WebSocketServerSession, CompletableDeferred<Boolean>>>()
fun addSession(uid: String, session: WebSocketServerSession) { fun addSession(uid: String, session: WebSocketServerSession) {
sessions.computeIfAbsent(uid) { ConcurrentLinkedQueue() }.add(session) sessions.computeIfAbsent(uid) { ConcurrentLinkedQueue() }.add(session)
} }
fun removeSession(uid: String, session: WebSocketServerSession) { fun removeSession(uid: String, session: WebSocketServerSession) {
sessions[uid]?.remove(session) sessions[uid]?.remove(session)
if(sessions[uid]?.isEmpty() == true) { if(sessions[uid]?.isEmpty() == true) {
@ -44,132 +32,67 @@ fun Routing.wsTimerRoutes() {
} }
} }
suspend fun sendWithRetry(
session: WebSocketServerSession,
message: TimerResponse,
maxRetries: Int = 3,
delayMillis: Long = 2000L
): Boolean {
var attempt = 0
while (attempt < maxRetries) {
try {
session.sendSerialized(message)
val ackDeferred = CompletableDeferred<Boolean>()
ackMap.computeIfAbsent(message.uid) { ConcurrentHashMap() }[session] = ackDeferred
val ackReceived = withTimeoutOrNull(delayMillis) { ackDeferred.await() } ?: false
if (ackReceived) {
ackMap[message.uid]?.remove(session)
return true
} else {
attempt++
logger.warn("ACK not received for message to ${message.uid} on attempt $attempt.")
}
} catch (e: Exception) {
attempt++
logger.info("Failed to send message on attempt $attempt. Retrying in $delayMillis ms.")
e.printStackTrace()
delay(delayMillis)
}
}
return false
}
fun broadcastMessage(uid: String, message: TimerResponse) {
val userSessions = sessions[uid]
userSessions?.forEach { session ->
timerScope.launch {
val success = sendWithRetry(session, message.copy(uid = uid))
if (!success) {
logger.info("Removing session for user $uid due to repeated failures.")
removeSession(uid, session)
}
}
}
}
webSocket("/timer/{uid}") { webSocket("/timer/{uid}") {
val uid = call.parameters["uid"] val uid = call.parameters["uid"]
val user = uid?.let { UserService.getUser(it) } val user = uid?.let { UserService.getUser(it) }
if (uid == null || user == null) { if (uid == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID"))
return@webSocket
}
if (user == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID")) close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID"))
return@webSocket return@webSocket
} }
addSession(uid, this)
val timer = CurrentTimer.getTimer(user)
if (timer?.type == TimerType.STREAM_OFF) { addSession(uid, this)
timerScope.launch {
sendSerialized(TimerResponse(TimerType.STREAM_OFF.value, null, uid)) if(status[uid] == TimerType.STREAM_OFF) {
CoroutineScope(Dispatchers.Default).launch {
sendSerialized(TimerResponse(TimerType.STREAM_OFF.value, null))
} }
} else { } else {
timerScope.launch { CoroutineScope(Dispatchers.Default).launch {
if(timer?.type == TimerType.STREAM_OFF) { sendSerialized(TimerResponse(
sendSerialized(TimerResponse(TimerType.STREAM_OFF.value, null, uid)) TimerConfigService.getConfig(user)?.option ?: TimerType.REMOVE.value,
} else { null
if (timer == null) { ))
sendSerialized(
TimerResponse(
TimerConfigService.getConfig(user)?.option ?: TimerType.REMOVE.value,
null,
uid
)
)
} else {
sendSerialized(
TimerResponse(
timer.type.value,
timer.time,
uid
)
)
}
}
} }
} }
try { try {
for (frame in incoming) { for (frame in incoming) {
when(frame) { when(frame) {
is Frame.Text -> { is Frame.Text -> {
val text = frame.readText().trim()
if(text == "ping") {
send("pong")
} else {
val data = Json.decodeFromString<TimerRequest>(text)
if (data.type == TimerType.ACK.value) {
ackMap[data.uid]?.get(this)?.complete(true)
ackMap[data.uid]?.remove(this)
}
}
} }
is Frame.Ping -> send(Frame.Pong(frame.data)) is Frame.Ping -> send(Frame.Pong(frame.data))
else -> {} else -> {
}
} }
} }
} catch(e: ClosedReceiveChannelException) { } catch(e: ClosedReceiveChannelException) {
logger.error("Error in WebSocket: ${e.message}") logger.error("Error in WebSocket: ${e.message}")
} finally { } finally {
removeSession(uid, this) removeSession(uid, this)
ackMap[uid]?.remove(this)
} }
} }
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
dispatcher.subscribe(TimerEvent::class) { dispatcher.subscribe(TimerEvent::class) {
logger.debug("TimerEvent: {} / {}", it.uid, it.type) logger.debug("TimerEvent: {} / {}", it.uid, it.type)
val user = UserService.getUser(it.uid) status[it.uid] = it.type
CurrentTimer.setTimer(user!!, it) CoroutineScope(Dispatchers.Default).launch {
timerScope.launch { sessions[it.uid]?.forEach { ws ->
broadcastMessage(it.uid, TimerResponse(it.type.value, it.time ?: "", it.uid)) ws.sendSerialized(TimerResponse(it.type.value, it.time ?: ""))
}
} }
} }
} }
@Serializable @Serializable
data class TimerResponse( data class TimerResponse(
val type: Int, val type: Int,
val time: String?, val time: String?
val uid: String
)
@Serializable
data class TimerRequest(
val type: Int,
val uid: String
) )

View File

@ -1,19 +0,0 @@
package space.mori.chzzk_bot.webserver.utils
import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.utils.YoutubeVideo
import java.util.concurrent.ConcurrentHashMap
object CurrentSong {
private val currentSong = ConcurrentHashMap<String, YoutubeVideo>()
fun setSong(user: User, song: YoutubeVideo?) {
if(song == null) {
currentSong.remove(user.token ?: "")
} else {
currentSong[user.token ?: ""] = song
}
}
fun getSong(user: User) = currentSong[user.token ?: ""]
}

View File

@ -1,19 +0,0 @@
package space.mori.chzzk_bot.webserver.utils
import space.mori.chzzk_bot.common.events.TimerEvent
import space.mori.chzzk_bot.common.models.User
import java.util.concurrent.ConcurrentHashMap
object CurrentTimer {
private val currentTimer = ConcurrentHashMap<String, TimerEvent>()
fun setTimer(user: User, timer: TimerEvent?) {
if(timer == null) {
currentTimer.remove(user.token ?: "")
} else {
currentTimer[user.token ?: ""] = timer
}
}
fun getTimer(user: User) = currentTimer[user.token ?: ""]
}

View File

@ -1,188 +0,0 @@
package space.mori.chzzk_bot.webserver.utils
import applicationHttpClient
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.webserver.*
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap
object DiscordGuildCache {
private val cache = ConcurrentHashMap<String, CachedGuilds>()
private const val EXP_SECONDS = 600L
private val mutex = Mutex()
private val logger = LoggerFactory.getLogger(this::class.java)
suspend fun getCachedGuilds(guildId: String): Guild? {
val now = Instant.now()
var guild = cache[guildId]
if(guild == null || guild.timestamp.plusSeconds(EXP_SECONDS).isBefore(now) || !guild.isBotAvailable) {
mutex.withLock {
if(guild == null || guild!!.timestamp.plusSeconds(EXP_SECONDS).isBefore(now) || !guild!!.isBotAvailable) {
fetchAllGuilds()
guild = cache[guildId]
}
}
}
try {
if(guild == null) return null
if (guild!!.guild.roles.isEmpty()) {
val roles = fetchGuildRoles(guildId)
guild!!.guild.roles.addAll(roles)
}
if (guild!!.guild.channel.isEmpty()) {
val channels = fetchGuildChannels(guildId)
guild!!.guild.channel.addAll(channels)
}
} catch(e: Exception) {
logger.info("guild fetch is failed. ${e.stackTraceToString()}")
return null
}
return cache[guildId]?.guild
}
suspend fun getCachedGuilds(guildId: List<String>): List<Guild> {
return guildId.mapNotNull { getCachedGuilds(it) }
}
private suspend fun fetchGuilds(beforeGuildId: String? = null): List<DiscordGuildListAPI> {
if(DiscordRatelimits.isLimited()) {
delay(DiscordRatelimits.getRateReset())
}
val result = applicationHttpClient.get("https://discord.com/api/users/@me/guilds") {
headers {
append(HttpHeaders.Authorization, "Bot ${dotenv["DISCORD_TOKEN"]}")
}
parameter("limit", 200)
if (beforeGuildId != null) {
parameter("before", beforeGuildId)
}
}
val rateLimit = result.headers["X-RateLimit-Limit"]?.toIntOrNull()
val remaining = result.headers["X-RateLimit-Remaining"]?.toIntOrNull()
val resetAfter = result.headers["X-RateLimit-Reset-After"]?.toDoubleOrNull()?.toLong()?.plus(1L)
DiscordRatelimits.setRateLimit(rateLimit, remaining, resetAfter)
return result.body<List<DiscordGuildListAPI>>()
}
private suspend fun fetchGuildRoles(guildId: String): MutableList<GuildRole> {
if(DiscordRatelimits.isLimited()) {
delay(DiscordRatelimits.getRateReset())
}
try {
val result = applicationHttpClient.get("https://discord.com/api/guilds/${guildId}/roles") {
headers {
append(HttpHeaders.Authorization, "Bot ${dotenv["DISCORD_TOKEN"]}")
}
}
val rateLimit = result.headers["X-RateLimit-Limit"]?.toIntOrNull()
val remaining = result.headers["X-RateLimit-Remaining"]?.toIntOrNull()
val resetAfter = result.headers["X-RateLimit-Reset-After"]?.toDoubleOrNull()?.toLong()?.plus(1L)
DiscordRatelimits.setRateLimit(rateLimit, remaining, resetAfter)
if (result.status != HttpStatusCode.OK) {
logger.error("Failed to fetch data from Discord API. Status: ${result.status} ${result.bodyAsText()}")
return mutableListOf()
}
val parsed = result.body<MutableList<GuildRole>>()
return parsed
} catch(e: Exception) {
logger.info("fetchGuildRoles error: ${e.stackTraceToString()}")
return mutableListOf()
}
}
private suspend fun fetchGuildChannels(guildId: String): MutableList<GuildChannel> {
if(DiscordRatelimits.isLimited()) {
delay(DiscordRatelimits.getRateReset())
}
try {
val result = applicationHttpClient.get("https://discord.com/api/guilds/${guildId}/channels") {
headers {
append(HttpHeaders.Authorization, "Bot ${dotenv["DISCORD_TOKEN"]}")
}
}
val rateLimit = result.headers["X-RateLimit-Limit"]?.toIntOrNull()
val remaining = result.headers["X-RateLimit-Remaining"]?.toIntOrNull()
val resetAfter = result.headers["X-RateLimit-Reset-After"]?.toDoubleOrNull()?.toLong()?.plus(1L)
DiscordRatelimits.setRateLimit(rateLimit, remaining, resetAfter)
if (result.status != HttpStatusCode.OK) {
logger.error("Failed to fetch data from Discord API. Status: ${result.status} ${result.bodyAsText()}")
return mutableListOf()
}
val parsed = result.body<List<GuildChannel>>().filter { it.type == ChannelType.GUILD_TEXT.value }.toMutableList()
return parsed
} catch(e: Exception) {
logger.info("fetchGuildRoles error: ${e.stackTraceToString()}")
return mutableListOf()
}
}
private suspend fun fetchAllGuilds() {
var lastGuildId: String? = null
while (true) {
try {
val guilds = fetchGuilds(lastGuildId)
if (guilds.isEmpty()) {
break
}
guilds.forEach {
cache[it.id] = CachedGuilds(
Guild(it.id, it.name, it.icon, it.banner, it.roles?.toMutableList() ?: mutableListOf(), mutableListOf()),
Instant.now().plusSeconds(EXP_SECONDS),
true
)
}
lastGuildId = guilds.last().id
if(guilds.size <= 200) break
} catch(e: Exception) {
logger.info("Exception in discord caches. ${e.stackTraceToString()}")
return
}
}
}
fun addGuild(guilds: Map<String, Guild>) {
cache.putAll(guilds.map {
it.key to CachedGuilds(it.value, Instant.now().plusSeconds(EXP_SECONDS))
})
}
}
data class CachedGuilds(
val guild: Guild,
val timestamp: Instant = Instant.now(),
val isBotAvailable: Boolean = false,
)
@Serializable
data class Guild(
val id: String,
val name: String,
val icon: String?,
val banner: String?,
var roles: MutableList<GuildRole>,
var channel: MutableList<GuildChannel>
)

View File

@ -1,36 +0,0 @@
package space.mori.chzzk_bot.webserver.utils
import java.time.Duration
import java.time.Instant
object DiscordRatelimits {
private var rateLimit = RateLimit(0, 5, Instant.now())
fun isLimited(): Boolean {
return rateLimit.remainin == 0
}
fun getRateReset(): Long {
val now = Instant.now()
val resetInstant = rateLimit.resetAfter
return if (resetInstant.isAfter(now)) {
Duration.between(now, resetInstant).toMillis()
} else {
0L // 이미 Rate Limit이 해제된 경우, 대기 시간은 0
}
}
private fun setRateLimit(rateLimit: RateLimit) {
this.rateLimit = rateLimit
}
fun setRateLimit(limit: Int?, remaining: Int?, resetAfter: Long?) {
return setRateLimit(RateLimit(limit ?: 0, remaining ?: 0, Instant.now().plusSeconds(resetAfter ?: 0L)))
}
}
data class RateLimit(
val limit: Int,
val remainin: Int,
val resetAfter: Instant,
)