Compare commits

..

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

73 changed files with 772 additions and 3693 deletions

2
.idea/.gitignore generated vendored
View File

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

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
@ -32,15 +32,6 @@
- [x] !명령어추가 \[명령어] \[내용] - [x] !명령어추가 \[명령어] \[내용]
- [x] !명령어수정 \[명령어] \[내용] - [x] !명령어수정 \[명령어] \[내용]
- [x] !명령어삭제 \[명령어] - [x] !명령어삭제 \[명령어]
### 타이머 명령어 (on Chzzk chat, 매니저/스트리머 전용)
- [x] !시간 \[숫자: 분]
- [x] !시간 업타임
- [x] !시간 삭제
### 플레이리스트 명령어 (on Chzzk chat)
- [x] !노래추가 \[유튜브 주소]
- [x] !노래목록
- [ ] !노래삭제 \[번호]
- [ ] !노래설정 \[내용] \[켜기/끄기]
### Envs ### Envs
- DISCORD_TOKEN - DISCORD_TOKEN
@ -79,5 +70,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")
@ -27,22 +27,24 @@ repositories {
} }
dependencies { dependencies {
// https://mvnrepository.com/artifact/net.dv8tion/JDA
implementation("net.dv8tion:JDA:5.0.1") {
exclude(module = "opus-java")
}
// https://mvnrepository.com/artifact/ch.qos.logback/logback-classic // https://mvnrepository.com/artifact/ch.qos.logback/logback-classic
implementation("ch.qos.logback:logback-classic:1.5.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
implementation("io.insert-koin:koin-core:4.0.0")
kotlin("stdlib") kotlin("stdlib")

View File

@ -11,33 +11,30 @@ repositories {
dependencies { dependencies {
// https://mvnrepository.com/artifact/net.dv8tion/JDA // https://mvnrepository.com/artifact/net.dv8tion/JDA
api("net.dv8tion:JDA:5.2.1") { implementation("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
implementation("io.insert-koin:koin-core:4.0.0")
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
listOf(project(":common")).forEach { listOf(project(":common")).forEach {

View File

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

@ -1,84 +1,30 @@
package space.mori.chzzk_bot.chatbot.chzzk package space.mori.chzzk_bot.chatbot.chzzk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.Connector.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.models.User import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.services.LiveStatusService
import space.mori.chzzk_bot.common.services.TimerConfigService
import space.mori.chzzk_bot.common.services.UserService import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.common.utils.* import xyz.r2turntrue.chzzk4j.chat.ChatEventListener
import xyz.r2turntrue.chzzk4j.ChzzkClient import xyz.r2turntrue.chzzk4j.chat.ChatMessage
import xyz.r2turntrue.chzzk4j.session.ChzzkSessionBuilder import xyz.r2turntrue.chzzk4j.chat.ChzzkChat
import xyz.r2turntrue.chzzk4j.session.ChzzkSessionSubscriptionType
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.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
@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))
} }
fun enable() { fun enable() {
botUid = Connector.client.fetchLoggedUser().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 ->
val streamInfo = Connector.getLive(handler.channel.channelId)
if (streamInfo?.isOnline == true) 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 +52,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,77 +80,41 @@ object ChzzkHandler {
} }
} }
@OptIn(DelicateCoroutinesApi::class)
class UserHandler( class UserHandler(
val channel: ChzzkChannel, val channel: ChzzkChannel,
val logger: Logger, private val logger: Logger,
private var user: User, private var user: User,
var streamStartTime: LocalDateTime?, private var _isActive: Boolean = false
) { ) {
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 var listener: ChzzkChat = chzzk.chat(channel.channelId)
private var _isActive: Boolean .withAutoReconnect(true)
get() = LiveStatusService.getLiveStatus(user)?.status ?: false .withChatListener(object : ChatEventListener {
set(value) { override fun onConnect(chat: ChzzkChat, isReconnecting: Boolean) {
LiveStatusService.updateOrCreate(user, value) logger.info("ChzzkChat connected. ${channel.channelName} - ${channel.channelId} / reconnected: $isReconnecting")
} messageHandler = MessageHandler(channel, logger, chat)
init {
val user = UserService.getUser(channel.channelId)
if(user?.accessToken == null || user.refreshToken == null) {
throw RuntimeException("AccessToken or RefreshToken is not valid.")
}
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,90 +128,21 @@ 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>) {
_isActive = value
if(value) { if(value) {
CoroutineScope(Dispatchers.Default).launch { logger.info("${user.username} is live.")
logger.info("${user.username} is live.")
reloadUser(UserService.getUser(user.id.value)!!) logger.info("ChzzkChat connecting... ${channel.channelName} - ${channel.channelId}")
listener.connectBlocking()
logger.info("ChzzkChat connecting... ${channel.channelName} - ${channel.channelId}") Discord.sendDiscord(user, status)
listener.subscribeAsync(ChzzkSessionSubscriptionType.CHAT).join()
streamStartTime = LocalDateTime.now() listener.sendChat("${user.username} 님의 방송이 감지되었습니다.")
if(!_isActive) {
_isActive = true
when(TimerConfigService.getConfig(UserService.getUser(channel.channelId)!!)?.option) {
TimerType.UPTIME.value -> dispatcher.post(
TimerEvent(
channel.channelId,
TimerType.UPTIME,
getUptime(streamStartTime!!)
)
)
else -> dispatcher.post(
TimerEvent(
channel.channelId,
TimerType.REMOVE,
""
)
)
}
delay(5000L)
try {
if(!user.isDisableStartupMsg)
sendChat("${user.username} 님! 오늘도 열심히 방송하세요!")
Discord.sendDiscord(user, status)
} catch(e: Exception) {
logger.info("Stream on logic has some error: ${e.stackTraceToString()}")
}
}
}
} else { } else {
logger.info("${user.username} is offline.") logger.info("${user.username} is offline.")
streamStartTime = null listener.closeAsync()
listener.disconnectAsync().join()
_isActive = false
CoroutineScope(Dispatchers.Default).launch {
val events = listOf(
TimerEvent(
channel.channelId,
TimerType.STREAM_OFF,
null
),
SongEvent(
channel.channelId,
SongType.STREAM_OFF,
null,
null,
null,
null,
)
)
events.forEach { dispatcher.post(it) }
}
} }
} }
}
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

@ -1,28 +1,24 @@
package space.mori.chzzk_bot.chatbot.chzzk package space.mori.chzzk_bot.chatbot.chzzk
import kotlinx.coroutines.CoroutineScope import org.slf4j.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject
import space.mori.chzzk_bot.chatbot.discord.Discord.Companion.bot
import space.mori.chzzk_bot.common.events.*
import space.mori.chzzk_bot.common.models.User import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.services.* import space.mori.chzzk_bot.common.services.CommandService
import space.mori.chzzk_bot.common.utils.getFollowDate import space.mori.chzzk_bot.common.services.CounterService
import space.mori.chzzk_bot.common.utils.getUptime import space.mori.chzzk_bot.common.services.UserService
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.types.channel.ChzzkChannel
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
class MessageHandler( class MessageHandler(
private val handler: UserHandler private val channel: ChzzkChannel,
private val logger: Logger,
private val listener: ChzzkChat
) { ) {
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:([^>]+)>")
@ -31,38 +27,15 @@ class MessageHandler(
private val followPattern = Regex("<following>") private val followPattern = Regex("<following>")
private val daysPattern = """<days:(\d{4})-(\d{2})-(\d{2})>""".toRegex() private val daysPattern = """<days:(\d{4})-(\d{2})-(\d{2})>""".toRegex()
private val channel = handler.channel
private val logger = handler.logger
private val listener = handler.listener
private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
init { init {
reloadCommand() reloadCommand()
dispatcher.subscribe(SongEvent::class) {
if(it.type == SongType.STREAM_OFF) {
val user = UserService.getUser(channel.channelId)
if(! user?.let { usr -> SongListService.getSong(usr) }.isNullOrEmpty()) {
SongListService.deleteUser(user)
}
}
}
} }
internal fun reloadCommand() { internal fun reloadCommand() {
val user = UserService.getUser(channel.channelId) val user = UserService.getUser(channel.channelId)
?: throw RuntimeException("User not found. it's bug? ${channel.channelName} - ${channel.channelId}") ?: throw RuntimeException("User not found. it's bug? ${channel.channelName} - ${channel.channelId}")
val commands = CommandService.getCommands(user) val commands = CommandService.getCommands(user)
val manageCommands = mapOf( val manageCommands = mapOf("!명령어추가" to this::manageAddCommand, "!명령어삭제" to this::manageRemoveCommand, "!명령어수정" to this::manageUpdateCommand)
"!명령어" to this::commandListCommand,
"!명령어추가" to this::manageAddCommand,
"!명령어삭제" to this::manageRemoveCommand,
"!명령어수정" to this::manageUpdateCommand,
"!시간" to this::timerCommand,
"!신청곡" to this::songAddCommand,
"!노래목록" to this::songListCommand,
"!노래시작" to this::songStartCommand,
)
manageCommands.forEach { (commandName, command) -> manageCommands.forEach { (commandName, command) ->
this.commands[commandName] = command this.commands[commandName] = command
@ -72,268 +45,87 @@ 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)
} }
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)
} }
private fun timerCommand(msg: SessionChatMessage, user: User) { internal fun handle(msg: ChatMessage, user: User) {
if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return
}
val parts = msg.content.split(" ", limit = 3)
if (parts.size < 2) {
handler.sendChat("타이머 명령어 형식을 잘 찾아봐주세요!")
return
}
val command = parts[1]
when (parts[1]) {
"업타임" -> {
logger.debug("${user.token} / 업타임")
CoroutineScope(Dispatchers.Default).launch {
dispatcher.post(
TimerEvent(
user.token,
TimerType.UPTIME,
getUptime(handler.streamStartTime!!)
)
)
}
}
"삭제" -> {
logger.debug("${user.token} / 삭제")
CoroutineScope(Dispatchers.Default).launch {
dispatcher.post(TimerEvent(user.token, TimerType.REMOVE, ""))
}
}
"설정" -> {
when (parts[2]) {
"업타임" -> {
TimerConfigService.saveOrUpdateConfig(user, TimerType.UPTIME)
handler.sendChat("기본 타이머 설정이 업타임으로 바뀌었습니다.")
}
"삭제" -> {
TimerConfigService.saveOrUpdateConfig(user, TimerType.REMOVE)
handler.sendChat("기본 타이머 설정이 삭제로 바뀌었습니다.")
}
else -> handler.sendChat("!타이머 설정 (업타임/삭제) 형식으로 써주세요!")
}
}
else -> {
logger.debug("${user.token} / 그외")
try {
val time = command.toInt()
val currentTime = LocalDateTime.now()
val timestamp = currentTime.plus(time.toLong(), ChronoUnit.MINUTES)
CoroutineScope(Dispatchers.Default).launch {
dispatcher.post(TimerEvent(user.token, TimerType.TIMER, timestamp.toString()))
}
} catch (e: NumberFormatException) {
handler.sendChat("!타이머/숫자 형식으로 적어주세요! 단위: 분")
} catch (e: Exception) {
handler.sendChat("타이머 설정 중 오류가 발생했습니다.")
logger.error("Error processing timer command: ${e.message}", e)
}
}
}
}
// songs
private fun songAddCommand(msg: SessionChatMessage, user: User) {
if(SongConfigService.getConfig(user).disabled) {
return
}
val parts = msg.content.split(" ", limit = 2)
if (parts.size < 2) {
handler.sendChat("유튜브 URL을 입력해주세요!")
return
}
val config = SongConfigService.getConfig(user)
if(config.streamerOnly && msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return
}
val url = parts[1]
val songs = SongListService.getSong(user)
if(songs.size >= config.queueLimit) {
handler.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!")
return
}
if(songs.filter { it.uid == msg.senderChannelId }.size >= config.personalLimit) {
handler.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!")
return
}
try {
val video = getYoutubeVideo(url)
if (video == null) {
handler.sendChat("유튜브에서 찾을 수 없어요!")
return
}
if (songs.any { it.url == video.url }) {
handler.sendChat("같은 노래가 이미 신청되어 있습니다.")
return
}
if (video.length > 600) {
handler.sendChat("10분이 넘는 노래는 신청할 수 없습니다.")
return
}
SongListService.saveSong(
user,
msg.senderChannelId,
video.url,
video.name,
video.author,
video.length,
msg.profile?.nickname ?: ""
)
CoroutineScope(Dispatchers.Default).launch {
dispatcher.post(
SongEvent(
user.token,
SongType.ADD,
msg.senderChannelId,
null,
video,
)
)
}
handler.sendChat("노래가 추가되었습니다. ${video.name} - ${video.author}")
} catch(e: Exception) {
handler.sendChat("유튜브 영상 주소로 다시 신청해주세요!")
logger.info(e.stackTraceToString())
}
}
private fun songListCommand(msg: SessionChatMessage, user: User) {
if(SongConfigService.getConfig(user).disabled) {
return
}
handler.sendChat("리스트는 여기입니다. https://nabot.mori.space/songs/${user.token}")
}
private fun songStartCommand(msg: SessionChatMessage, user: User) {
if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return
}
if(user.discord != null) {
bot.retrieveUserById(user.discord!!).queue { discordUser ->
discordUser?.openPrivateChannel()?.queue { channel ->
channel.sendMessage("여기로 접속해주세요! ||https://nabot.mori.space/songlist||.")
.queue()
}
}
} else {
handler.sendChat("나봇 홈페이지의 노래목록 페이지를 이용해주세요! 디스코드 연동을 하시면 DM으로 바로 전송됩니다.")
}
}
internal fun handle(msg: SessionChatMessage, user: User) {
if(msg.senderChannelId == 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,23 +137,23 @@ 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()
} }
} }
// Replace followPattern // Replace followPattern
result = followPattern.replace(result) { _ -> result = followPattern.replace(result) { matchResult ->
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 +181,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 +189,5 @@ class MessageHandler(
return result return result
} }
}
}

View File

@ -1,11 +0,0 @@
package space.mori.chzzk_bot.chatbot.chzzk
import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.services.CommandService
import xyz.r2turntrue.chzzk4j.chat.ChatMessage
class SongModule {
companion object {
}
}

View File

@ -6,18 +6,18 @@ import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.JDABuilder import net.dv8tion.jda.api.JDABuilder
import net.dv8tion.jda.api.entities.Activity import net.dv8tion.jda.api.entities.Activity
import net.dv8tion.jda.api.entities.Guild import net.dv8tion.jda.api.entities.Guild
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel
import net.dv8tion.jda.api.events.guild.GuildJoinEvent import net.dv8tion.jda.api.events.guild.GuildJoinEvent
import net.dv8tion.jda.api.events.guild.member.GuildMemberRemoveEvent
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.hooks.ListenerAdapter import net.dv8tion.jda.api.hooks.ListenerAdapter
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.IData
import space.mori.chzzk_bot.chatbot.chzzk.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 +30,22 @@ 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) =
return bot.getGuildById(guildId)?.getTextChannelById(channelId) 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(user.liveAlertMessage != "" && user.liveAlertGuild != null && user.liveAlertChannel != null) {
val channel = getChannel(user.liveAlertGuild ?: 0, user.liveAlertChannel ?: 0) val channel = getChannel(user.liveAlertGuild!!, user.liveAlertChannel!!) ?: throw RuntimeException("${user.liveAlertChannel} is not valid.")
?: 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.setUrl(status.content.channel.channelImageUrl)
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,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.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) {
user = manager.user
ManagerService.updateManager(user, event.user.idLong, event.user.effectiveName)
}
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,51 @@
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.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) {
user = manager.user
ManagerService.updateManager(user, event.user.idLong, event.user.effectiveName)
}
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,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.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"
override val command = Commands.slash(name, "치지직 계정을 등록합니다.")
.addOptions(
OptionData(
OptionType.STRING,
"chzzk_id",
"36da10b7c35800f298e9c565a396bafd 형식으로 입력해주세요.",
true
)
)
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
val chzzkID = event.getOption("chzzk_id")?.asString
if(chzzkID == null) {
event.hook.sendMessage("치지직 계정은 필수 입력입니다.").queue()
return
}
val chzzkChannel = Connector.getChannel(chzzkID)
if (chzzkChannel == null) {
event.hook.sendMessage("치지직 계정을 찾을 수 없습니다.").queue()
return
}
try {
val user = UserService.saveUser(chzzkChannel.channelName, chzzkChannel.channelId, event.user.idLong)
event.hook.sendMessage("등록이 완료되었습니다. `${chzzkChannel.channelId}` - `${chzzkChannel.channelName}`")
ChzzkHandler.addUser(chzzkChannel, user)
} catch(e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -0,0 +1,55 @@
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.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) {
user = manager.user
ManagerService.updateManager(user, event.user.idLong, event.user.effectiveName)
}
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,57 @@
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.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) {
user = manager.user
ManagerService.updateManager(user, event.user.idLong, event.user.effectiveName)
}
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,31 +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
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// https://mvnrepository.com/artifact/com.google.code.gson/gson
implementation("com.google.code.gson:gson:2.11.0")
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
} }

View File

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

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

View File

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

View File

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

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

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

View File

@ -0,0 +1,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

@ -1,26 +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
import org.jetbrains.exposed.sql.ReferenceOption
object SongConfigs: IntIdTable("song_config") {
val user = reference("user", Users, onDelete = ReferenceOption.CASCADE)
val token = varchar("token", 64).nullable()
val streamerOnly = bool("streamer_only").default(false)
val queueLimit = integer("queue_limit").default(50)
val personalLimit = integer("personal_limit").default(5)
val disabled = bool("disabled").default(false)
}
class SongConfig(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<SongConfig>(SongConfigs)
var user by User referencedOn SongConfigs.user
var token by SongConfigs.token
var streamerOnly by SongConfigs.streamerOnly
var queueLimit by SongConfigs.queueLimit
var personalLimit by SongConfigs.personalLimit
var disabled by SongConfigs.disabled
}

View File

@ -1,33 +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
import org.jetbrains.exposed.sql.javatime.datetime
import java.time.LocalDateTime
object SongLists: IntIdTable("song_list") {
val user = reference("user", Users)
val uid = varchar("uid", 64)
val url = varchar("url", 128)
val name = text("name")
val reqName = varchar("req_name", 80)
val author = text("author")
val time = integer("time")
val created_at = datetime("created_at").default(LocalDateTime.now())
}
class SongList(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<SongList>(SongLists)
var url by SongLists.url
var name by SongLists.name
var author by SongLists.author
var time by SongLists.time
var created_at by SongLists.created_at
var user by User referencedOn SongLists.user
var uid by SongLists.uid
var reqName by SongLists.reqName
}

View File

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

View File

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

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

View File

@ -1,30 +0,0 @@
package space.mori.chzzk_bot.common.services
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
import space.mori.chzzk_bot.common.models.LiveStatus
import space.mori.chzzk_bot.common.models.LiveStatuses
import space.mori.chzzk_bot.common.models.User
object LiveStatusService {
fun updateOrCreate(user: User, status: Boolean): LiveStatus {
return transaction {
return@transaction when(val liveStatus = LiveStatus.find(LiveStatuses.user eq user.id).firstOrNull()) {
null -> LiveStatus.new {
this.user = user
this.status = status
}
else -> {
liveStatus.status = status
liveStatus
}
}
}
}
fun getLiveStatus(user: User): LiveStatus? {
return transaction {
LiveStatus.find(LiveStatuses.user eq user.id).firstOrNull()
}
}
}

View File

@ -0,0 +1,70 @@
package space.mori.chzzk_bot.common.services
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.transactions.transaction
import space.mori.chzzk_bot.common.models.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 {
if (user.liveAlertGuild == null)
throw RuntimeException("${user.username} has no liveAlertGuild")
val manager = getUser(user.liveAlertGuild!!, discordId)
if (manager == null)
throw RuntimeException("$name isn't manager.")
manager.lastUserName = name
return manager
}
fun getUser(guildId: Long, discordId: Long): Manager? {
return transaction {
val manager = Manager.find(
(Managers.discordGuildId eq guildId) and (Managers.managerId eq discordId)
)
manager.firstOrNull()
}
}
fun getAllUsers(guildId: Long): List<Manager> {
return transaction {
Manager.find(Managers.discordGuildId eq guildId).toList()
}
}
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

@ -1,73 +0,0 @@
package space.mori.chzzk_bot.common.services
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
import space.mori.chzzk_bot.common.models.SongConfig
import space.mori.chzzk_bot.common.models.SongConfigs
import space.mori.chzzk_bot.common.models.User
object SongConfigService {
private fun initConfig(user: User): SongConfig {
return transaction {
SongConfig.new {
this.user = user
}
}
}
fun getConfig(user: User): SongConfig {
return transaction {
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
if (songConfig == null) {
songConfig = initConfig(user)
}
songConfig
}
}
fun updatePersonalLimit(user: User, limit: Int): SongConfig {
return transaction {
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
if (songConfig == null) {
songConfig = initConfig(user)
}
songConfig.personalLimit = limit
songConfig
}
}
fun updateQueueLimit(user: User, limit: Int): SongConfig {
return transaction {
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
if (songConfig == null) {
songConfig = initConfig(user)
}
songConfig.queueLimit = limit
songConfig
}
}
fun updateStreamerOnly(user: User, config: Boolean): SongConfig {
return transaction {
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
if (songConfig == null) {
songConfig = initConfig(user)
}
songConfig.streamerOnly = config
songConfig
}
}
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,63 +0,0 @@
package space.mori.chzzk_bot.common.services
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.transactions.transaction
import space.mori.chzzk_bot.common.models.SongList
import space.mori.chzzk_bot.common.models.SongLists
import space.mori.chzzk_bot.common.models.User
object SongListService {
fun saveSong(user: User, uid: String, url: String, name: String, author: String, time: Int, reqName: String) {
return transaction {
SongList.new {
this.user = user
this.uid = uid
this.url = url
this.name = name
this.author = author
this.time = time
this.reqName = reqName
}
}
}
fun getSong(user: User, uid: String): List<SongList> {
return transaction {
SongList.find(
(SongLists.user eq user.id) and
(SongLists.uid eq uid)
).toList()
}
}
fun getSong(user: User): List<SongList> {
return transaction {
SongList.find(SongLists.user eq user.id).toList().sortedBy { it.created_at }
}
}
fun deleteSong(user: User, uid: String, name: String): SongList {
return transaction {
val songRow = SongList.find(
(SongLists.user eq user.id) and
(SongLists.uid eq uid) and
(SongLists.name eq name)
).firstOrNull()
songRow ?: throw RuntimeException("Song not found! ${user.username} / $uid / $name")
songRow.delete()
songRow
}
}
fun deleteUser(user: User): Boolean {
return transaction {
val songRow = SongList.find(SongLists.user eq user.id).toList()
songRow.forEach { it.delete() }
true
}
}
}

View File

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

View File

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

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

View File

@ -1,9 +0,0 @@
package space.mori.chzzk_bot.common.utils
fun getRandomString(length: Int): String {
val charPool = ('a'..'z') + ('0'..'9')
return (1..length)
.map { kotlin.random.Random.nextInt(0, charPool.size) }
.map(charPool::get)
.joinToString("")
}

View File

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

View File

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

View File

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

View File

@ -4,11 +4,5 @@ DB_URL=jdbc:mariadb://localhost:3306/chzzk
DB_USER=chzzk DB_USER=chzzk
DB_PASS=chzzk DB_PASS=chzzk
RUN_AGENT=false RUN_AGENT=false
YOUTUBE_API_KEY=
RAPID_KEY=
HOST=http://localhost:8080
FRONTEND=http://localhost:3000
NAVER_CLIENT_ID=
NAVER_CLIENT_SECRET=
NID_AUT= NID_AUT=
NID_SES= NID_SES=

View File

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

View File

@ -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,19 @@ 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
implementation("io.insert-koin:koin-core:4.0.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.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,266 +1,38 @@
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.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.webserver.routes.apiRoutes
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.utils.DiscordRatelimits
import java.math.BigInteger
import java.security.SecureRandom
import java.time.Duration
import kotlin.getValue
import kotlin.time.toKotlinDuration
val dotenv = dotenv {
ignoreIfMissing = true
}
val redirects = mutableMapOf<String, String>()
val server = embeddedServer(Netty, port = 8080, ) {
install(WebSockets) {
pingPeriod = Duration.ofSeconds(15).toKotlinDuration()
timeout = Duration.ofSeconds(100).toKotlinDuration()
maxFrameSize = Long.MAX_VALUE
masking = false
contentConverter = KotlinxWebsocketSerializationConverter(Json)
}
val server = embeddedServer(Netty, port = 8080) {
install(WebSockets)
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { json(Json {
prettyPrint = true prettyPrint = true
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()
apiCommandRoutes()
apiTimerRoutes()
apiDiscordRoutes()
wsTimerRoutes()
wsSongRoutes()
wsSongListRoutes()
swaggerUI("swagger-ui/index.html", "openapi/documentation.yaml") { swaggerUI("swagger-ui/index.html", "openapi/documentation.yaml") {
options { options {
version = "1.2.0" version = "1.1.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 +41,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,62 +1,14 @@
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 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.UserService
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
data class GetUserDTO(
val uid: String,
val nickname: String,
val isStreamOn: Boolean,
val avatarUrl: String
)
@Serializable
data class GetSessionDTO(
val uid: String,
val nickname: String,
val isStreamOn: Boolean,
val avatarUrl: String,
val maxQueueSize: Int,
val maxUserSize: Int,
val isStreamerOnly: Boolean,
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") {
@ -64,86 +16,4 @@ fun Routing.apiRoutes() {
call.respondText("OK", status= HttpStatusCode.OK) call.respondText("OK", status= HttpStatusCode.OK)
} }
} }
route("/user/{uid}") {
get {
val uid = call.parameters["uid"]
if(uid == null) {
call.respondText("Require UID", status = HttpStatusCode.NotFound)
return@get
}
val user = getChzzkUserWithId(uid)
if (user?.find == false) {
call.respondText("User not found", status = HttpStatusCode.NotFound)
return@get
} else {
call.respond(HttpStatusCode.OK, GetUserDTO(
user?.uid ?: "",
user?.nickname ?: "",
user?.isStreamOn ?: false,
user?.avatarUrl ?: ""
))
}
}
}
route("/user") {
get {
val session = call.sessions.get<UserSession>()
if(session == null) {
call.respondText("No session found", status = HttpStatusCode.Unauthorized)
return@get
}
var user = UserService.getUser(session.id)
if(user == null) {
user = UserService.saveUser("임시닉네임", session.id)
}
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
}
if (user.username == "임시닉네임") {
status.let { stats -> UserService.updateUser(user, stats.uid ?: "", stats.nickname ?: "") }
}
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

@ -1,69 +0,0 @@
package space.mori.chzzk_bot.webserver.routes
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
import space.mori.chzzk_bot.common.models.SongList
import space.mori.chzzk_bot.common.services.SongListService
import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.common.utils.YoutubeVideo
import space.mori.chzzk_bot.webserver.utils.CurrentSong
@Serializable
data class SongsDTO(
val url: String,
val name: String,
val author: String,
val time: Int,
val reqName: String
)
@Serializable
data class SongsResponseDTO(
val current: SongsDTO? = null,
val next: List<SongsDTO> = emptyList()
)
fun SongList.toDTO(): SongsDTO = SongsDTO(
this.url,
this.name,
this.author,
this.time,
this.reqName
)
fun YoutubeVideo.toDTO(): SongsDTO = SongsDTO(
this.url,
this.name,
this.author,
this.length,
""
)
fun Routing.apiSongRoutes() {
route("/songs/{uid}") {
get {
val uid = call.parameters["uid"]
val user = uid?.let { it1 -> UserService.getUser(it1) }
if (user == null) {
call.respondText("No user found", status = HttpStatusCode.NotFound)
return@get
}
val songs = SongListService.getSong(user)
call.respond(HttpStatusCode.OK,
SongsResponseDTO(
CurrentSong.getSong(user)?.toDTO(),
songs.map { it.toDTO() }
)
)
}
}
route("/songs") {
get {
call.respondText("Require UID", status= HttpStatusCode.BadRequest)
}
}
}

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 +0,0 @@
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.sessions.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.koin.java.KoinJavaComponent.inject
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.common.events.*
import space.mori.chzzk_bot.common.models.SongList
import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.services.SongListService
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.webserver.UserSession
import space.mori.chzzk_bot.webserver.utils.CurrentSong
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
fun Routing.wsSongListRoutes() {
val logger = LoggerFactory.getLogger("WSSongListRoutes")
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
val songListScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// Manage all active sessions
val sessionHandlers = ConcurrentHashMap<String, SessionHandler>()
// Handle application shutdown
environment.monitor.subscribe(ApplicationStopped) {
sessionHandlers.values.forEach {
songListScope.launch {
it.close(CloseReason(CloseReason.Codes.NORMAL, "Server shutting down"))
}
}
}
// WebSocket endpoint
webSocket("/songlist") {
val session = call.sessions.get<UserSession>()
val user: User? = session?.id?.let { UserService.getUser(it) }
if (user == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid SID"))
return@webSocket
}
val uid = user.token
// Ensure only one session per user
sessionHandlers[uid]?.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Another session is already active."))
val handler = SessionHandler(uid, this, dispatcher, logger)
sessionHandlers[uid] = handler
// Initialize session
handler.initialize()
// Listen for incoming frames
try {
for (frame in incoming) {
when (frame) {
is Frame.Text -> handler.handleTextFrame(frame.readText())
is Frame.Ping -> send(Frame.Pong(frame.data))
else -> Unit
}
}
} catch (e: ClosedReceiveChannelException) {
logger.info("Session closed: ${e.message}")
} catch (e: IOException) {
logger.error("IO error: ${e.message}")
} catch (e: Exception) {
logger.error("Unexpected error: ${e.message}")
} finally {
sessionHandlers.remove(uid)
handler.close(CloseReason(CloseReason.Codes.NORMAL, "Session ended"))
}
}
// Subscribe to SongEvents
dispatcher.subscribe(SongEvent::class) { event ->
val handler = sessionHandlers[event.uid]
songListScope.launch {
handler?.sendSongResponse(event)
}
}
// Subscribe to TimerEvents
dispatcher.subscribe(TimerEvent::class) { event ->
if (event.type == TimerType.STREAM_OFF) {
val handler = sessionHandlers[event.uid]
songListScope.launch {
handler?.sendTimerOff()
}
}
}
}
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"))
return
}
} 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
data class SongRequest(
val type: Int,
val uid: String,
val url: String? = null,
val maxQueue: Int? = null,
val maxUserLimit: Int? = null,
val isStreamerOnly: Boolean? = null,
val remove: Int? = null,
val isDisabled: Boolean? = null
)

View File

@ -1,213 +0,0 @@
package space.mori.chzzk_bot.webserver.routes
import io.ktor.server.application.ApplicationStopped
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.koin.java.KoinJavaComponent.inject
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.common.events.*
import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.common.utils.YoutubeVideo
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
val songScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun Routing.wsSongRoutes() {
environment.monitor.subscribe(ApplicationStopped) {
songScope.cancel()
}
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
val status = ConcurrentHashMap<String, SongType>()
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) {
sessions.computeIfAbsent(uid) { ConcurrentLinkedQueue() }.add(session)
}
fun removeSession(uid: String, session: WebSocketServerSession) {
sessions[uid]?.remove(session)
if (sessions[uid]?.isEmpty() == true) {
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}") {
logger.info("WebSocket connection attempt received")
val uid = call.parameters["uid"]
val user = uid?.let { UserService.getUser(it) }
if (uid == null || user == null) {
logger.warn("Invalid UID: $uid")
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID"))
return@webSocket
}
try {
addSession(uid, this)
logger.info("WebSocket connection established for user: $uid")
// 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
}
}
}
if (status[uid] == SongType.STREAM_OFF) {
songScope.launch {
sendSerialized(
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)
}
}
dispatcher.subscribe(SongEvent::class) {
logger.debug("SongEvent: {} / {} {}", it.uid, it.type, it.current?.name)
songScope.launch {
broadcastMessage(
it.uid, SongResponse(
it.type.value,
it.uid,
it.reqUid,
it.current?.toSerializable(),
it.next?.toSerializable(),
it.delUrl
)
)
}
}
dispatcher.subscribe(TimerEvent::class) {
if (it.type == TimerType.STREAM_OFF) {
songScope.launch {
broadcastMessage(
it.uid, SongResponse(
it.type.value,
it.uid,
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
data class SongResponse(
val type: Int,
val uid: String,
val reqUid: String?,
val current: SerializableYoutubeVideo? = null,
val next: SerializableYoutubeVideo? = null,
val delUrl: String? = null
)

View File

@ -1,175 +0,0 @@
package space.mori.chzzk_bot.webserver.routes
import io.ktor.server.application.ApplicationStopped
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.koin.java.KoinJavaComponent.inject
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.common.events.*
import space.mori.chzzk_bot.common.services.TimerConfigService
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.ConcurrentLinkedQueue
val timerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun Routing.wsTimerRoutes() {
environment.monitor.subscribe(ApplicationStopped) {
timerScope.cancel()
}
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
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) {
sessions.computeIfAbsent(uid) { ConcurrentLinkedQueue() }.add(session)
}
fun removeSession(uid: String, session: WebSocketServerSession) {
sessions[uid]?.remove(session)
if(sessions[uid]?.isEmpty() == true) {
sessions.remove(uid)
}
}
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}") {
val uid = call.parameters["uid"]
val user = uid?.let { UserService.getUser(it) }
if (uid == null || user == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID"))
return@webSocket
}
addSession(uid, this)
val timer = CurrentTimer.getTimer(user)
if (timer?.type == TimerType.STREAM_OFF) {
timerScope.launch {
sendSerialized(TimerResponse(TimerType.STREAM_OFF.value, null, uid))
}
} else {
timerScope.launch {
if(timer?.type == TimerType.STREAM_OFF) {
sendSerialized(TimerResponse(TimerType.STREAM_OFF.value, null, uid))
} else {
if (timer == null) {
sendSerialized(
TimerResponse(
TimerConfigService.getConfig(user)?.option ?: TimerType.REMOVE.value,
null,
uid
)
)
} else {
sendSerialized(
TimerResponse(
timer.type.value,
timer.time,
uid
)
)
}
}
}
}
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<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))
else -> {}
}
}
} catch(e: ClosedReceiveChannelException) {
logger.error("Error in WebSocket: ${e.message}")
} finally {
removeSession(uid, this)
ackMap[uid]?.remove(this)
}
}
dispatcher.subscribe(TimerEvent::class) {
logger.debug("TimerEvent: {} / {}", it.uid, it.type)
val user = UserService.getUser(it.uid)
CurrentTimer.setTimer(user!!, it)
timerScope.launch {
broadcastMessage(it.uid, TimerResponse(it.type.value, it.time ?: "", it.uid))
}
}
}
@Serializable
data class TimerResponse(
val type: Int,
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,
)

View File

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