29 Commits

Author SHA1 Message Date
dalbodeule
b2f449bf65 [hotfix] ChzzkClient.refreshAccessToken added. 2025-06-04 15:48:28 +09:00
dalbodeule
5e3a350e15 Merge branch 'develop'
# Conflicts:
#	chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/ChzzkHandler.kt
#	chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/utils/accessTokenRefresh.kt
2025-06-04 15:46:43 +09:00
dalbodeule
8a0a507e5b [feature] some logic fixed. 2025-06-04 15:42:31 +09:00
dalbodeule
1c4b818a85 Revert "Merge pull request #133 from dalbodeule/develop"
This reverts commit 83b5eaf345, reversing
changes made to a99f3b342a.
2025-05-27 13:18:52 +09:00
JinU Choi
83b5eaf345 Merge pull request #133 from dalbodeule/develop
[feature] accessToken refresh logic fix.
2025-05-27 13:13:24 +09:00
dalbodeule
b0be81df20 [feature] accessToken refresh logic fix. 2025-05-27 13:11:17 +09:00
JinU Choi
a99f3b342a Merge pull request #132 from dalbodeule/develop
[feature] manager detect logic fixed.
2025-05-20 11:21:51 +09:00
dalbodeule
a9d3ad436b [feature] manager detect logic fixed. 2025-05-20 11:17:36 +09:00
JinU Choi
53757476a7 Merge pull request #131 from dalbodeule/debug
[feature] timer debugs. (2x)
2025-05-18 09:36:27 +09:00
dalbodeule
27810c0b7f [feature] timer debugs. (2x) 2025-05-18 09:31:36 +09:00
JinU Choi
7257100adc Merge pull request #130 from dalbodeule/develop
[feature] timer debugs.
2025-05-18 09:27:14 +09:00
dalbodeule
f29370a31f [feature] timer debugs. 2025-05-18 09:24:11 +09:00
JinU Choi
2c0c887ba1 Merge pull request #129 from dalbodeule/develop
[feature] song list websocket service fixed.
2025-05-18 08:56:41 +09:00
dalbodeule
5223cbe2b2 [feature] song list websocket service fixed. 2025-05-18 08:55:01 +09:00
JinU Choi
11f9895198 Merge pull request #128 from dalbodeule/develop
[feature] thumbnail, etc. fixed
2025-05-18 08:17:15 +09:00
dalbodeule
a18b83fcc8 [feature] thumbnail, etc. fixed 2025-05-18 08:14:46 +09:00
JinU Choi
30d5edc5fe Merge pull request #127 from dalbodeule/develop
[feature] text size limited 100, 100ms delay added.
2025-05-17 14:47:18 +09:00
dalbodeule
0709b8f526 [feature] text size limited 100, 100ms delay added. 2025-05-17 14:37:39 +09:00
dalbodeule
1465716e72 [hotfix] hotfix on register and activate logics. 2025-05-16 00:54:43 +09:00
dalbodeule
d0292e0aa6 [hotfix] hotfix on alert embed tags. 2025-05-16 00:42:28 +09:00
dalbodeule
b2ffd18126 [hotfix] hotfix on lateinit botuid is not initialized 2025-05-16 00:38:26 +09:00
dalbodeule
5fa04a6725 [hotfix] hotfix on manage users. 2025-05-16 00:29:50 +09:00
dalbodeule
f65c446bed [hotfix] hotfix on some codes. 2025-05-16 00:25:28 +09:00
JinU Choi
729a88a2b3 Merge pull request #126 from dalbodeule/develop
[refactor] user and live stream handling logic
2025-05-16 00:01:07 +09:00
dalbodeule
a896269087 [refactor] user and live stream handling logic
Replaced ChzzkUserCache with event-based user fetching for cleaner architecture. Integrated new ChzzkUserFindEvent and ChzzkUserReceiveEvent to handle user data retrieval. Removed old utility methods and streamlined live stream status checks with updated APIs.
2025-05-15 04:57:17 +09:00
JinU Choi
d92ad1cc51 Merge pull request #125 from dalbodeule/develop
[feature] chzzk api applied.
2025-05-14 15:51:36 +09:00
dalbodeule
8d54d21620 [refactor] some refactor on UserService.setRefreshToken 2025-05-14 15:49:04 +09:00
dalbodeule
101db7d20c [refactor] chzzk4j update to 0.1.1 2025-05-13 21:31:17 +09:00
dalbodeule
3c3b9a79a2 debug chzzk login 4 2025-05-13 21:25:57 +09:00
18 changed files with 484 additions and 318 deletions

View File

@@ -28,7 +28,7 @@ repositories {
dependencies { dependencies {
// https://mvnrepository.com/artifact/ch.qos.logback/logback-classic // https://mvnrepository.com/artifact/ch.qos.logback/logback-classic
implementation("ch.qos.logback:logback-classic:1.5.12") implementation("ch.qos.logback:logback-classic:1.5.13")
// 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")

View File

@@ -16,10 +16,10 @@ dependencies {
} }
// https://mvnrepository.com/artifact/io.github.R2turnTrue/chzzk4j // https://mvnrepository.com/artifact/io.github.R2turnTrue/chzzk4j
implementation("io.github.R2turnTrue:chzzk4j:0.0.12") implementation("io.github.R2turnTrue:chzzk4j:0.1.1")
// 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.13")
// 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")

View File

@@ -1,29 +1,34 @@
package space.mori.chzzk_bot.chatbot.chzzk package space.mori.chzzk_bot.chatbot.chzzk
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.future.await
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject import org.koin.java.KoinJavaComponent.inject
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.Connector.chzzk
import space.mori.chzzk_bot.chatbot.chzzk.Connector.getChannel import space.mori.chzzk_bot.chatbot.chzzk.Connector.getChannel
import space.mori.chzzk_bot.chatbot.discord.Discord import space.mori.chzzk_bot.chatbot.discord.Discord
import space.mori.chzzk_bot.chatbot.utils.refreshAccessToken
import space.mori.chzzk_bot.common.events.* import space.mori.chzzk_bot.common.events.*
import space.mori.chzzk_bot.common.models.User import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.services.LiveStatusService import space.mori.chzzk_bot.common.services.LiveStatusService
import space.mori.chzzk_bot.common.services.TimerConfigService import space.mori.chzzk_bot.common.services.TimerConfigService
import space.mori.chzzk_bot.common.services.UserService import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.common.utils.* import space.mori.chzzk_bot.common.utils.*
import xyz.r2turntrue.chzzk4j.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.time.LocalDateTime
import java.nio.charset.Charset
object ChzzkHandler { object ChzzkHandler {
private val handlers = mutableListOf<UserHandler>() private val handlers = mutableListOf<UserHandler>()
@@ -33,23 +38,23 @@ object ChzzkHandler {
private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java) 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 = null)) handlers.add(UserHandler(chzzkChannel, logger, user, streamStartTime = LocalDateTime.now()))
} }
fun enable() { fun enable() {
botUid = chzzk.loggedUser.userId botUid = Connector.client.fetchLoggedUser().userId
UserService.getAllUsers().map { UserService.getAllUsers().map {
if(!it.isDisabled) if(!it.isDisabled)
try { try {
chzzk.getChannel(it.token)?.let { token -> addUser(token, it) } Connector.getChannel(it.token)?.let { token -> addUser(token, it) }
} catch(e: Exception) { } catch(e: Exception) {
logger.info("Exception: ${it.token}(${it.username}) not found. ${e.stackTraceToString()}") logger.info("Exception: ${it.token}(${it.username}) not found. ${e.stackTraceToString()}")
} }
} }
handlers.forEach { handler -> handlers.forEach { handler ->
val streamInfo = getStreamInfo(handler.listener.channelId) val streamInfo = Connector.getLive(handler.channel.channelId)
if (streamInfo.content?.status == "OPEN") handler.isActive(true, streamInfo) if (streamInfo?.isOnline == true) handler.isActive(true, streamInfo)
} }
dispatcher.subscribe(UserRegisterEvent::class) { dispatcher.subscribe(UserRegisterEvent::class) {
@@ -108,15 +113,15 @@ object ChzzkHandler {
handlers.forEach { handlers.forEach {
if (!running) return@forEach if (!running) return@forEach
try { try {
val streamInfo = getStreamInfo(it.channel.channelId) val streamInfo = Connector.getLive(it.channel.channelId)
if (streamInfo.content?.status == "OPEN" && !it.isActive) { if (streamInfo?.isOnline == true && !it.isActive) {
try { try {
it.isActive(true, streamInfo) it.isActive(true, streamInfo)
} catch(e: Exception) { } catch(e: Exception) {
logger.info("Exception: ${e.stackTraceToString()}") logger.info("Exception: ${e.stackTraceToString()}")
} }
} }
if (streamInfo.content?.status == "CLOSE" && it.isActive) it.isActive(false, streamInfo) if (streamInfo?.isOnline == false && it.isActive) it.isActive(false, streamInfo)
} catch (e: SocketTimeoutException) { } catch (e: SocketTimeoutException) {
logger.info("Thread 1 Timeout: ${it.channel.channelName} / ${e.stackTraceToString()}") logger.info("Thread 1 Timeout: ${it.channel.channelName} / ${e.stackTraceToString()}")
} catch (e: Exception) { } catch (e: Exception) {
@@ -136,19 +141,19 @@ object ChzzkHandler {
handlers.forEach { handlers.forEach {
if (!running) return@forEach if (!running) return@forEach
try { try {
val streamInfo = getStreamInfo(it.channel.channelId) val streamInfo = Connector.getLive(it.channel.channelId)
if (streamInfo.content?.status == "OPEN" && !it.isActive) { if (streamInfo?.isOnline == true && !it.isActive) {
try { try {
it.isActive(true, streamInfo) it.isActive(true, streamInfo)
} catch(e: Exception) { } catch(e: Exception) {
logger.info("Exception: ${e.stackTraceToString()}") logger.info("Exception: ${e.stackTraceToString()}")
} }
} }
if (streamInfo.content?.status == "CLOSE" && it.isActive) it.isActive(false, streamInfo) if (streamInfo?.isOnline == false && it.isActive) it.isActive(false, streamInfo)
} catch (e: SocketTimeoutException) { } catch (e: SocketTimeoutException) {
logger.info("Thread 2 Timeout: ${it.channel.channelName} / ${e.stackTraceToString()}") logger.info("Thread 1 Timeout: ${it.channel.channelName} / ${e.stackTraceToString()}")
} catch (e: Exception) { } catch (e: Exception) {
logger.info("Thread 2 Exception: ${it.channel.channelName} / ${e.stackTraceToString()}") logger.info("Thread 1 Exception: ${it.channel.channelName} / ${e.stackTraceToString()}")
} finally { } finally {
Thread.sleep(5000) Thread.sleep(5000)
} }
@@ -192,6 +197,7 @@ object ChzzkHandler {
} }
} }
@OptIn(DelicateCoroutinesApi::class)
class UserHandler( class UserHandler(
val channel: ChzzkChannel, val channel: ChzzkChannel,
val logger: Logger, val logger: Logger,
@@ -199,7 +205,9 @@ class UserHandler(
var streamStartTime: LocalDateTime?, var streamStartTime: LocalDateTime?,
) { ) {
var messageHandler: MessageHandler var messageHandler: MessageHandler
var listener: ChzzkChat var client: ChzzkClient
var listener: ChzzkUserSession
var chatChannelId: String?
private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java) private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
private var _isActive: Boolean private var _isActive: Boolean
@@ -209,35 +217,54 @@ class UserHandler(
} }
init { init {
listener = chzzk.chat(channel.channelId) val user = UserService.getUser(channel.channelId)
.withAutoReconnect(true)
.withChatListener(object : ChatEventListener {
override fun onConnect(chat: ChzzkChat, isReconnecting: Boolean) {
logger.info("${channel.channelName} - ${channel.channelId} / reconnected: $isReconnecting")
}
override fun onError(ex: Exception) { if(user?.accessToken == null || user.refreshToken == null) {
logger.info("ChzzkChat error. ${channel.channelName} - ${channel.channelId}") throw RuntimeException("AccessToken or RefreshToken is not valid.")
logger.info(ex.stackTraceToString()) }
} try {
val tokens = Connector.client.refreshAccessToken(user.refreshToken!!)
client = Connector.getClient(tokens.first, tokens.second)
UserService.setRefreshToken(user, tokens.first, tokens.second)
chatChannelId = getChzzkChannelId(channel.channelId)
override fun onChat(msg: ChatMessage) { client.loginAsync().join()
if(!_isActive) return listener = ChzzkSessionBuilder(client).buildUserSession()
messageHandler.handle(msg, user) listener.createAndConnectAsync().join()
} messageHandler = MessageHandler(this@UserHandler)
override fun onConnectionClosed(code: Int, reason: String?, remote: Boolean, tryingToReconnect: Boolean) { listener.on(SessionChatMessageEvent::class.java) {
logger.info("ChzzkChat closed. ${channel.channelName} - ${channel.channelId}") messageHandler.handle(it.message, user)
logger.info("Reason: $reason / $tryingToReconnect") }
}
}) GlobalScope.launch {
.build() val timer = TimerConfigService.getConfig(user)
messageHandler = MessageHandler(this@UserHandler) if (timer?.option == TimerType.UPTIME.value)
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) {
logger.error("Exception(${user.username}): ${e.stackTraceToString()}")
throw RuntimeException("Exception: ${e.stackTraceToString()}")
}
} }
internal fun disable() { internal fun disable() {
listener.closeAsync() listener.disconnectAsync().join()
_isActive = false
} }
internal fun reloadCommand() { internal fun reloadCommand() {
@@ -251,7 +278,7 @@ class UserHandler(
internal val isActive: Boolean internal val isActive: Boolean
get() = _isActive get() = _isActive
internal fun isActive(value: Boolean, status: IData<IStreamInfo?>) { internal fun isActive(value: Boolean, status: ChzzkLiveDetail) {
if(value) { if(value) {
CoroutineScope(Dispatchers.Default).launch { CoroutineScope(Dispatchers.Default).launch {
logger.info("${user.username} is live.") logger.info("${user.username} is live.")
@@ -259,9 +286,9 @@ class UserHandler(
reloadUser(UserService.getUser(user.id.value)!!) reloadUser(UserService.getUser(user.id.value)!!)
logger.info("ChzzkChat connecting... ${channel.channelName} - ${channel.channelId}") logger.info("ChzzkChat connecting... ${channel.channelName} - ${channel.channelId}")
listener.connectAsync().await() listener.subscribeAsync(ChzzkSessionSubscriptionType.CHAT).join()
streamStartTime = status.content?.openDate?.let { convertChzzkDateToLocalDateTime(it) } streamStartTime = LocalDateTime.now()
if(!_isActive) { if(!_isActive) {
_isActive = true _isActive = true
@@ -285,7 +312,7 @@ class UserHandler(
delay(5000L) delay(5000L)
try { try {
if(!user.isDisableStartupMsg) if(!user.isDisableStartupMsg)
listener.sendChat("${user.username} 님! 오늘도 열심히 방송하세요!") sendChat("${user.username} 님! 오늘도 열심히 방송하세요!")
Discord.sendDiscord(user, status) Discord.sendDiscord(user, status)
} catch(e: Exception) { } catch(e: Exception) {
logger.info("Stream on logic has some error: ${e.stackTraceToString()}") logger.info("Stream on logic has some error: ${e.stackTraceToString()}")
@@ -295,7 +322,7 @@ class UserHandler(
} else { } else {
logger.info("${user.username} is offline.") logger.info("${user.username} is offline.")
streamStartTime = null streamStartTime = null
listener.closeAsync() listener.disconnectAsync().join()
_isActive = false _isActive = false
CoroutineScope(Dispatchers.Default).launch { CoroutineScope(Dispatchers.Default).launch {
@@ -319,4 +346,22 @@ class UserHandler(
} }
} }
} }
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,24 +1,66 @@
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 xyz.r2turntrue.chzzk4j.Chzzk import space.mori.chzzk_bot.common.events.ChzzkUserFindEvent
import xyz.r2turntrue.chzzk4j.ChzzkBuilder import space.mori.chzzk_bot.common.events.ChzzkUserReceiveEvent
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 chzzk: Chzzk = ChzzkBuilder() val adapter = ChzzkLegacyLoginAdapter(dotenv["NID_AUT"], dotenv["NID_SES"])
.withAuthorization(dotenv["NID_AUT"], dotenv["NID_SES"]) val client: ChzzkClient = ChzzkClientBuilder(dotenv["NAVER_CLIENT_ID"], dotenv["NAVER_CLIENT_SECRET"])
.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? = chzzk.getChannel(channelId) fun getChannel(channelId: String): ChzzkChannel? = client.fetchChannel(channelId)
fun getLive(channelId: String): ChzzkLiveDetail? = client.fetchLiveDetail(channelId)
init { init {
logger.info("chzzk logged: ${chzzk.isLoggedIn} / ${chzzk.loggedUser?.nickname ?: "----"}") logger.info("chzzk logged: ${client.isLoggedIn}")
client.loginAsync().join()
dispatcher.subscribe(ChzzkUserFindEvent::class) { event ->
GlobalScope.launch {
val user = getChannel(event.uid)
dispatcher.post(ChzzkUserReceiveEvent(
find = user != null,
uid = user?.channelId,
nickname = user?.channelName,
isStreamOn = user?.isBroadcasting,
avatarUrl = user?.channelImageUrl
))
}
}
} }
fun getClient(accessToken: String, refreshToken: String): ChzzkClient {
val adapter = ChzzkSimpleUserLoginAdapter(accessToken, refreshToken)
val client = ChzzkClientBuilder(dotenv["NAVER_CLIENT_ID"], dotenv["NAVER_CLIENT_SECRET"])
.withLoginAdapter(adapter)
.build()
return client
}
} }

View File

@@ -13,15 +13,16 @@ import space.mori.chzzk_bot.common.utils.getUptime
import space.mori.chzzk_bot.common.utils.getYoutubeVideo import space.mori.chzzk_bot.common.utils.getYoutubeVideo
import xyz.r2turntrue.chzzk4j.chat.ChatMessage import xyz.r2turntrue.chzzk4j.chat.ChatMessage
import xyz.r2turntrue.chzzk4j.chat.ChzzkChat import xyz.r2turntrue.chzzk4j.chat.ChzzkChat
import xyz.r2turntrue.chzzk4j.session.ChzzkUserSession
import xyz.r2turntrue.chzzk4j.session.message.SessionChatMessage
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
class MessageHandler( class MessageHandler(
private val handler: UserHandler private val handler: UserHandler
) { ) {
private val commands = mutableMapOf<String, (msg: ChatMessage, user: User) -> Unit>() private val commands = mutableMapOf<String, (msg: SessionChatMessage, 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:([^>]+)>")
@@ -71,85 +72,90 @@ 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(Pair(it.content, it.failContent), user, msg, listener, msg.profile?.nickname ?: "") val result = replaceCounters(
listener.sendChat(result) Pair(it.content, it.failContent),
user,
msg,
msg.profile?.nickname ?: ""
)
handler.sendChat(result)
} }
} }
} }
private fun commandListCommand(msg: ChatMessage, user: User) { private fun commandListCommand(msg: SessionChatMessage, user: User) {
listener.sendChat("리스트는 여기입니다. https://nabot.mori.space/commands/${user.token}") handler.sendChat("리스트는 여기입니다. https://nabot.mori.space/commands/${user.token}")
} }
private fun manageAddCommand(msg: ChatMessage, user: User) { private fun manageAddCommand(msg: SessionChatMessage, user: User) {
if (msg.profile?.userRoleCode == "common_user") { if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
listener.sendChat("매니저만 명령어를 추가할 수 있습니다.") 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) {
listener.sendChat("명령어 추가 형식은 '!명령어추가 명령어 내용'입니다.") handler.sendChat("명령어 추가 형식은 '!명령어추가 명령어 내용'입니다.")
return return
} }
if (commands.containsKey(parts[1])) { if (commands.containsKey(parts[1])) {
listener.sendChat("${parts[1]} 명령어는 이미 있는 명령어입니다.") handler.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, "")
listener.sendChat("명령어 '$command' 추가되었습니다.") handler.sendChat("명령어 '$command' 추가되었습니다.")
} }
private fun manageUpdateCommand(msg: ChatMessage, user: User) { private fun manageUpdateCommand(msg: SessionChatMessage, user: User) {
if (msg.profile?.userRoleCode == "common_user") { if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
listener.sendChat("매니저만 명령어를 추가할 수 있습니다.") 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) {
listener.sendChat("명령어 수정 형식은 '!명령어수정 명령어 내용'입니다.") handler.sendChat("명령어 수정 형식은 '!명령어수정 명령어 내용'입니다.")
return return
} }
if (!commands.containsKey(parts[1])) { if (!commands.containsKey(parts[1])) {
listener.sendChat("${parts[1]} 명령어는 없는 명령어입니다.") handler.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, "")
listener.sendChat("명령어 '$command' 수정되었습니다.") handler.sendChat("명령어 '$command' 수정되었습니다.")
ChzzkHandler.reloadCommand(channel) ChzzkHandler.reloadCommand(channel)
} }
private fun manageRemoveCommand(msg: ChatMessage, user: User) { private fun manageRemoveCommand(msg: SessionChatMessage, user: User) {
if (msg.profile?.userRoleCode == "common_user") { if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
listener.sendChat("매니저만 명령어를 삭제할 수 있습니다.") handler.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) {
listener.sendChat("명령어 삭제 형식은 '!명령어삭제 명령어'입니다.") handler.sendChat("명령어 삭제 형식은 '!명령어삭제 명령어'입니다.")
return return
} }
val command = parts[1] val command = parts[1]
CommandService.removeCommand(user, command) CommandService.removeCommand(user, command)
listener.sendChat("명령어 '$command' 삭제되었습니다.") handler.sendChat("명령어 '$command' 삭제되었습니다.")
ChzzkHandler.reloadCommand(channel) ChzzkHandler.reloadCommand(channel)
} }
private fun timerCommand(msg: ChatMessage, user: User) { private fun timerCommand(msg: SessionChatMessage, user: User) {
if (msg.profile?.userRoleCode == "common_user") { if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
listener.sendChat("매니저만 이 명령어를 사용할 수 있습니다.") handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return return
} }
val parts = msg.content.split(" ", limit = 3) val parts = msg.content.split(" ", limit = 3)
if (parts.size < 2) { if (parts.size < 2) {
listener.sendChat("타이머 명령어 형식을 잘 찾아봐주세요!") handler.sendChat("타이머 명령어 형식을 잘 찾아봐주세요!")
return return
} }
@@ -178,13 +184,13 @@ class MessageHandler(
when (parts[2]) { when (parts[2]) {
"업타임" -> { "업타임" -> {
TimerConfigService.saveOrUpdateConfig(user, TimerType.UPTIME) TimerConfigService.saveOrUpdateConfig(user, TimerType.UPTIME)
listener.sendChat("기본 타이머 설정이 업타임으로 바뀌었습니다.") handler.sendChat("기본 타이머 설정이 업타임으로 바뀌었습니다.")
} }
"삭제" -> { "삭제" -> {
TimerConfigService.saveOrUpdateConfig(user, TimerType.REMOVE) TimerConfigService.saveOrUpdateConfig(user, TimerType.REMOVE)
listener.sendChat("기본 타이머 설정이 삭제로 바뀌었습니다.") handler.sendChat("기본 타이머 설정이 삭제로 바뀌었습니다.")
} }
else -> listener.sendChat("!타이머 설정 (업타임/삭제) 형식으로 써주세요!") else -> handler.sendChat("!타이머 설정 (업타임/삭제) 형식으로 써주세요!")
} }
} }
else -> { else -> {
@@ -198,9 +204,9 @@ class MessageHandler(
dispatcher.post(TimerEvent(user.token, TimerType.TIMER, timestamp.toString())) dispatcher.post(TimerEvent(user.token, TimerType.TIMER, timestamp.toString()))
} }
} catch (e: NumberFormatException) { } catch (e: NumberFormatException) {
listener.sendChat("!타이머/숫자 형식으로 적어주세요! 단위: 분") handler.sendChat("!타이머/숫자 형식으로 적어주세요! 단위: 분")
} catch (e: Exception) { } catch (e: Exception) {
listener.sendChat("타이머 설정 중 오류가 발생했습니다.") handler.sendChat("타이머 설정 중 오류가 발생했습니다.")
logger.error("Error processing timer command: ${e.message}", e) logger.error("Error processing timer command: ${e.message}", e)
} }
} }
@@ -208,21 +214,21 @@ class MessageHandler(
} }
// songs // songs
private fun songAddCommand(msg: ChatMessage, user: User) { private fun songAddCommand(msg: SessionChatMessage, user: User) {
if(SongConfigService.getConfig(user).disabled) { if(SongConfigService.getConfig(user).disabled) {
return return
} }
val parts = msg.content.split(" ", limit = 2) val parts = msg.content.split(" ", limit = 2)
if (parts.size < 2) { if (parts.size < 2) {
listener.sendChat("유튜브 URL을 입력해주세요!") handler.sendChat("유튜브 URL을 입력해주세요!")
return return
} }
val config = SongConfigService.getConfig(user) val config = SongConfigService.getConfig(user)
if(config.streamerOnly && msg.profile?.userRoleCode == "common_user") { if(config.streamerOnly && msg.profile.badges.none { it.imageUrl.contains("manager") }) {
listener.sendChat("매니저만 이 명령어를 사용할 수 있습니다.") handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return return
} }
@@ -230,34 +236,34 @@ class MessageHandler(
val songs = SongListService.getSong(user) val songs = SongListService.getSong(user)
if(songs.size >= config.queueLimit) { if(songs.size >= config.queueLimit) {
listener.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!") handler.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!")
return return
} }
if(songs.filter { it.uid == msg.userId }.size >= config.personalLimit) { if(songs.filter { it.uid == msg.senderChannelId }.size >= config.personalLimit) {
listener.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!") handler.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!")
return return
} }
try { try {
val video = getYoutubeVideo(url) val video = getYoutubeVideo(url)
if (video == null) { if (video == null) {
listener.sendChat("유튜브에서 찾을 수 없어요!") handler.sendChat("유튜브에서 찾을 수 없어요!")
return return
} }
if (songs.any { it.url == video.url }) { if (songs.any { it.url == video.url }) {
listener.sendChat("같은 노래가 이미 신청되어 있습니다.") handler.sendChat("같은 노래가 이미 신청되어 있습니다.")
return return
} }
if (video.length > 600) { if (video.length > 600) {
listener.sendChat("10분이 넘는 노래는 신청할 수 없습니다.") handler.sendChat("10분이 넘는 노래는 신청할 수 없습니다.")
return return
} }
SongListService.saveSong( SongListService.saveSong(
user, user,
msg.userId, msg.senderChannelId,
video.url, video.url,
video.name, video.name,
video.author, video.author,
@@ -269,31 +275,31 @@ class MessageHandler(
SongEvent( SongEvent(
user.token, user.token,
SongType.ADD, SongType.ADD,
msg.userId, msg.senderChannelId,
null, null,
video, video,
) )
) )
} }
listener.sendChat("노래가 추가되었습니다. ${video.name} - ${video.author}") handler.sendChat("노래가 추가되었습니다. ${video.name} - ${video.author}")
} catch(e: Exception) { } catch(e: Exception) {
listener.sendChat("유튜브 영상 주소로 다시 신청해주세요!") handler.sendChat("유튜브 영상 주소로 다시 신청해주세요!")
logger.info(e.stackTraceToString()) logger.info(e.stackTraceToString())
} }
} }
private fun songListCommand(msg: ChatMessage, user: User) { private fun songListCommand(msg: SessionChatMessage, user: User) {
if(SongConfigService.getConfig(user).disabled) { if(SongConfigService.getConfig(user).disabled) {
return return
} }
listener.sendChat("리스트는 여기입니다. https://nabot.mori.space/songs/${user.token}") handler.sendChat("리스트는 여기입니다. https://nabot.mori.space/songs/${user.token}")
} }
private fun songStartCommand(msg: ChatMessage, user: User) { private fun songStartCommand(msg: SessionChatMessage, user: User) {
if (msg.profile?.userRoleCode == "common_user") { if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
listener.sendChat("매니저만 이 명령어를 사용할 수 있습니다.") handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return return
} }
@@ -306,28 +312,28 @@ class MessageHandler(
} }
} }
} else { } else {
listener.sendChat("나봇 홈페이지의 노래목록 페이지를 이용해주세요! 디스코드 연동을 하시면 DM으로 바로 전송됩니다.") handler.sendChat("나봇 홈페이지의 노래목록 페이지를 이용해주세요! 디스코드 연동을 하시면 DM으로 바로 전송됩니다.")
} }
} }
internal fun handle(msg: ChatMessage, user: User) { internal fun handle(msg: SessionChatMessage, user: User) {
if(msg.userId == ChzzkHandler.botUid) return 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: ChatMessage, listener: ChzzkChat, userName: String): String { private fun replaceCounters(chat: Pair<String, String>, user: User, msg: SessionChatMessage, 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.userId, user) val dailyCounter = CounterService.getDailyCounterValue(name, msg.senderChannelId, user)
if (dailyCounter.second) { if (dailyCounter.second) {
CounterService.updateDailyCounterValue(name, msg.userId, 1, user).first.toString() CounterService.updateDailyCounterValue(name, msg.senderChannelId, 1, user).first.toString()
} else { } else {
isFail = true isFail = true
dailyCounter.first.toString() dailyCounter.first.toString()
@@ -339,7 +345,7 @@ class MessageHandler(
result = chat.second result = chat.second
result = dailyCounterPattern.replace(result) { matchResult -> result = dailyCounterPattern.replace(result) { matchResult ->
val name = matchResult.groupValues[1] val name = matchResult.groupValues[1]
val dailyCounter = CounterService.getDailyCounterValue(name, msg.userId, user) val dailyCounter = CounterService.getDailyCounterValue(name, msg.senderChannelId, user)
dailyCounter.first.toString() dailyCounter.first.toString()
} }
} }
@@ -347,15 +353,15 @@ class MessageHandler(
// Replace followPattern // Replace followPattern
result = followPattern.replace(result) { _ -> result = followPattern.replace(result) { _ ->
try { try {
val followingDate = getFollowDate(listener.chatId, msg.userId) val followingDate = handler.chatChannelId?.let { getFollowDate(it, msg.senderChannelId) }
.content?.streamingProperty?.following?.followDate ?.content?.streamingProperty?.following?.followDate ?: LocalDateTime.now().minusDays(1).toString()
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)
} ?: 0 } + 1
period.toString() period.toString()
} catch (e: Exception) { } catch (e: Exception) {
@@ -383,7 +389,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.userId, 1, user).toString() CounterService.updatePersonalCounterValue(name, msg.senderChannelId, 1, user).toString()
} }
// Replace namePattern // Replace namePattern
@@ -391,5 +397,4 @@ class MessageHandler(
return result return result
} }
} }

View File

@@ -12,11 +12,12 @@ import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEve
import net.dv8tion.jda.api.hooks.ListenerAdapter import net.dv8tion.jda.api.hooks.ListenerAdapter
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.common.utils.IData
import space.mori.chzzk_bot.common.utils.IStreamInfo
import space.mori.chzzk_bot.chatbot.discord.commands.* import space.mori.chzzk_bot.chatbot.discord.commands.*
import space.mori.chzzk_bot.common.models.User import space.mori.chzzk_bot.common.models.User
import xyz.r2turntrue.chzzk4j.types.channel.live.ChzzkLiveDetail
import 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
@@ -33,20 +34,26 @@ class Discord: ListenerAdapter() {
return bot.getGuildById(guildId)?.getTextChannelById(channelId) return bot.getGuildById(guildId)?.getTextChannelById(channelId)
} }
fun sendDiscord(user: User, status: IData<IStreamInfo?>) { fun sendDiscord(user: User, status: ChzzkLiveDetail) {
if(status.content == null) return
if(user.liveAlertMessage != null && user.liveAlertGuild != null && user.liveAlertChannel != null) { if(user.liveAlertMessage != null && user.liveAlertGuild != null && user.liveAlertChannel != null) {
val channel = getChannel(user.liveAlertGuild ?: 0, user.liveAlertChannel ?: 0) val channel = getChannel(user.liveAlertGuild ?: 0, user.liveAlertChannel ?: 0)
?: throw RuntimeException("${user.liveAlertChannel} is not valid.") ?: throw RuntimeException("${user.liveAlertChannel} is not valid.")
val embed = EmbedBuilder() val embed = EmbedBuilder()
embed.setTitle(status.content!!.liveTitle, "https://chzzk.naver.com/live/${user.token}") embed.setTitle(status.title, "https://chzzk.naver.com/live/${user.token}")
embed.setDescription("${user.username} 님이 방송을 시작했습니다.") embed.setDescription("${user.username} 님이 방송을 시작했습니다.")
embed.setTimestamp(Instant.now()) embed.setTimestamp(Instant.now())
embed.setAuthor(user.username, "https://chzzk.naver.com/live/${user.token}", status.content!!.channel.channelImageUrl) embed.setAuthor(user.username, "https://chzzk.naver.com/live/${user.token}")
embed.addField("카테고리", status.content!!.liveCategoryValue, true) embed.addField("카테고리", status.liveCategoryValue, true)
embed.addField("태그", status.content!!.tags.joinToString(", "), true) embed.addField("태그", status.tags.joinToString(", ") { it.trim() }, true)
embed.setImage(status.content!!.liveImageUrl.replace("{type}", "1080")) status.defaultThumbnailImageUrl.getOrNull()?.let { embed.setImage(it) }
?: Resolution.entries.reversed().forEach {
val thumbnail = status.getLiveImageUrl(it)
if (thumbnail != null) {
embed.setImage(thumbnail)
return@forEach
}
}
channel.sendMessage( channel.sendMessage(
MessageCreateBuilder() MessageCreateBuilder()

View File

@@ -0,0 +1,55 @@
package space.mori.chzzk_bot.chatbot.utils
import com.google.gson.Gson
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
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://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 this.apiClientId,
"clientSecret" to this.apiSecret
)).toRequestBody())
.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

@@ -23,7 +23,7 @@ dependencies {
api("com.zaxxer:HikariCP:6.1.0") api("com.zaxxer:HikariCP:6.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.12") implementation("ch.qos.logback:logback-classic:1.5.13")
// 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.5.0")

View File

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

View File

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

@@ -15,6 +15,8 @@ object Users: IntIdTable("users") {
val liveAlertMessage = text("live_alert_message").nullable() val liveAlertMessage = text("live_alert_message").nullable()
val isDisableStartupMsg = bool("is_disable_startup_msg").default(false) val isDisableStartupMsg = bool("is_disable_startup_msg").default(false)
val isDisabled = bool("is_disabled").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) {
@@ -29,6 +31,9 @@ class User(id: EntityID<Int>) : IntEntity(id) {
var isDisableStartupMsg by Users.isDisableStartupMsg var isDisableStartupMsg by Users.isDisableStartupMsg
var isDisabled by Users.isDisabled 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 managers by User.via(UserManagers.user, UserManagers.manager)

View File

@@ -97,4 +97,19 @@ object UserService {
user user
} }
} }
fun setAccessToken(user: User, accessToken: String): User {
return transaction {
user.accessToken = accessToken
user
}
}
fun setRefreshToken(user: User, accessToken: String, refreshToken: String): User {
return transaction {
user.accessToken = accessToken
user.refreshToken = refreshToken
user
}
}
} }

View File

@@ -52,47 +52,18 @@ data class NicknameColor(
val colorCode: String = "" val colorCode: String = ""
) )
// Stream info data class LiveStatus(
data class IStreamInfo( val liveTitle: String,
val liveId: Int = 0, val status: String,
val liveTitle: String = "", val concurrentUserCount: Int,
val status: String = "", val accumulateCount: Int,
val liveImageUrl: String = "", val paidPromotion: Boolean,
val defaultThumbnailImageUrl: String? = null, val adult: Boolean,
val concurrentUserCount: Int = 0, val krOnlyViewing: Boolean,
val accumulateCount: Int = 0, val openDate: String,
val openDate: String = "", val closeDate: String?,
val closeDate: String = "", val clipActive: Boolean,
val adult: Boolean = false, val chatChannelId: String
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 추가
@@ -128,38 +99,21 @@ fun getFollowDate(chatID: String, userId: String) : IData<IFollowContent?> {
} }
} }
fun getStreamInfo(userId: String) : IData<IStreamInfo?> { fun getChzzkChannelId(channelId: String): String? {
val url = "https://api.chzzk.naver.com/service/v3/channels/${userId}/live-detail" val url = "https://api.chzzk.naver.com/polling/v3/channels/$channelId/live-status?includePlayerRecommendContent=false"
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 follow = gson.fromJson(body, object: TypeToken<IData<IStreamInfo?>>() {}) val data = gson.fromJson(body, object: TypeToken<IData<LiveStatus?>>() {})
return follow return data.content?.chatChannelId
} catch(e: Exception) {
throw e
}
}
}
fun getUserInfo(userId: String): IData<Channel?> {
val url = "https://api.chzzk.naver.com/service/v1/channels/${userId}"
val request = Request.Builder()
.url(url)
.build()
client.newCall(request).execute().use { response ->
try {
if(!response.isSuccessful) throw IOException("Unexpected code ${response.code}")
val body = response.body?.string()
val channel = gson.fromJson(body, object: TypeToken<IData<Channel?>>() {})
return channel
} catch(e: Exception) { } catch(e: Exception) {
throw e throw e
} }

View File

@@ -10,7 +10,7 @@ repositories {
mavenCentral() mavenCentral()
} }
val ktorVersion = "3.0.1" val ktorVersion = "3.1.3"
dependencies { dependencies {
implementation("io.ktor:ktor-server-core:$ktorVersion") implementation("io.ktor:ktor-server-core:$ktorVersion")

View File

@@ -22,12 +22,16 @@ import io.ktor.server.websocket.*
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.koin.java.KoinJavaComponent.inject
import 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.common.services.UserService
import space.mori.chzzk_bot.webserver.routes.* import space.mori.chzzk_bot.webserver.routes.*
import space.mori.chzzk_bot.webserver.utils.DiscordRatelimits import space.mori.chzzk_bot.webserver.utils.DiscordRatelimits
import java.math.BigInteger import java.math.BigInteger
import java.security.SecureRandom import java.security.SecureRandom
import java.time.Duration import java.time.Duration
import kotlin.getValue
import kotlin.time.toKotlinDuration import kotlin.time.toKotlinDuration
val dotenv = dotenv { val dotenv = dotenv {
@@ -81,6 +85,8 @@ val server = embeddedServer(Netty, port = 8080, ) {
} }
} }
routing { routing {
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
route("/auth") { route("/auth") {
// discord login // discord login
authenticate("auth-oauth-discord") { authenticate("auth-oauth-discord") {
@@ -191,6 +197,12 @@ val server = embeddedServer(Netty, port = 8080, ) {
val userInfo = getChzzkUser(tokenResponse.content.accessToken) val userInfo = getChzzkUser(tokenResponse.content.accessToken)
if(userInfo.content != null) { 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( call.sessions.set(
UserSession( UserSession(
session.state, session.state,
@@ -198,6 +210,13 @@ val server = embeddedServer(Netty, port = 8080, ) {
listOf() listOf()
) )
) )
UserService.setRefreshToken(user,
tokenResponse.content.accessToken,
tokenResponse.content.refreshToken ?: ""
)
dispatcher.post(UserRegisterEvent(user.token))
call.respondRedirect(getFrontendURL("")) call.respondRedirect(getFrontendURL(""))
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -11,7 +11,10 @@ import space.mori.chzzk_bot.common.events.CoroutinesEventBus
import space.mori.chzzk_bot.common.services.SongConfigService import space.mori.chzzk_bot.common.services.SongConfigService
import space.mori.chzzk_bot.common.services.UserService import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.webserver.UserSession import space.mori.chzzk_bot.webserver.UserSession
import space.mori.chzzk_bot.webserver.utils.ChzzkUserCache import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull
import space.mori.chzzk_bot.common.events.ChzzkUserFindEvent
import space.mori.chzzk_bot.common.events.ChzzkUserReceiveEvent
@Serializable @Serializable
data class GetUserDTO( data class GetUserDTO(
@@ -36,6 +39,20 @@ data class GetSessionDTO(
fun Routing.apiRoutes() { fun Routing.apiRoutes() {
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java) 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 =
@@ -50,21 +67,21 @@ fun Routing.apiRoutes() {
route("/user/{uid}") { route("/user/{uid}") {
get { get {
val uid = call.parameters["uid"] val uid = call.parameters["uid"]
if(uid == null) { if(uid == null) {
call.respondText("Require UID", status = HttpStatusCode.NotFound) call.respondText("Require UID", status = HttpStatusCode.NotFound)
return@get return@get
} }
val user = ChzzkUserCache.getCachedUser(uid) val user = getChzzkUserWithId(uid)
if(user?.content == null) { if (user?.find == false) {
call.respondText("User not found", status = HttpStatusCode.NotFound) call.respondText("User not found", status = HttpStatusCode.NotFound)
return@get return@get
} else { } else {
call.respond(HttpStatusCode.OK, GetUserDTO( call.respond(HttpStatusCode.OK, GetUserDTO(
user.content!!.channel.channelId, user?.uid ?: "",
user.content!!.channel.channelName, user?.nickname ?: "",
user.content!!.status == "OPEN", user?.isStreamOn ?: false,
user.content!!.channel.channelImageUrl user?.avatarUrl ?: ""
)) ))
} }
} }
@@ -82,7 +99,7 @@ fun Routing.apiRoutes() {
user = UserService.saveUser("임시닉네임", session.id) user = UserService.saveUser("임시닉네임", session.id)
} }
val songConfig = SongConfigService.getConfig(user) val songConfig = SongConfigService.getConfig(user)
val status = ChzzkUserCache.getCachedUser(session.id) val status = getChzzkUserWithId(user.token)
val returnUsers = mutableListOf<GetSessionDTO>() val returnUsers = mutableListOf<GetSessionDTO>()
if(status == null) { if(status == null) {
@@ -91,14 +108,14 @@ fun Routing.apiRoutes() {
} }
if (user.username == "임시닉네임") { if (user.username == "임시닉네임") {
status.content?.channel?.let { it1 -> UserService.updateUser(user, it1.channelId, it1.channelName) } status.let { stats -> UserService.updateUser(user, stats.uid ?: "", stats.nickname ?: "") }
} }
returnUsers.add(GetSessionDTO( returnUsers.add(GetSessionDTO(
status.content?.channel?.channelId ?: user.username, status.uid ?: user.token,
status.content?.channel?.channelName ?: user.token, status.nickname ?: user.username,
status.content?.status == "OPEN", status.isStreamOn == true,
status.content?.channel?.channelImageUrl ?: "", status.avatarUrl ?: "",
songConfig.queueLimit, songConfig.queueLimit,
songConfig.personalLimit, songConfig.personalLimit,
songConfig.streamerOnly, songConfig.streamerOnly,
@@ -109,15 +126,15 @@ fun Routing.apiRoutes() {
user.subordinates.toList() user.subordinates.toList()
} }
returnUsers.addAll(subordinates.map { returnUsers.addAll(subordinates.map {
val subStatus = ChzzkUserCache.getCachedUser(it.token) val subStatus = getChzzkUserWithId(it.token)
return@map if (subStatus?.content == null) { return@map if (subStatus == null) {
null null
} else { } else {
GetSessionDTO( GetSessionDTO(
subStatus.content!!.channel.channelId, subStatus.uid ?: "",
subStatus.content!!.channel.channelName, subStatus.nickname ?: "",
subStatus.content!!.status == "OPEN", subStatus.isStreamOn == true,
subStatus.content!!.channel.channelImageUrl, subStatus.avatarUrl ?: "",
0, 0,
0, 0,
false, false,

View File

@@ -37,9 +37,10 @@ fun Routing.wsSongRoutes() {
fun addSession(uid: String, session: WebSocketServerSession) { fun addSession(uid: String, session: WebSocketServerSession) {
sessions.computeIfAbsent(uid) { ConcurrentLinkedQueue() }.add(session) sessions.computeIfAbsent(uid) { ConcurrentLinkedQueue() }.add(session)
} }
fun removeSession(uid: String, session: WebSocketServerSession) { fun removeSession(uid: String, session: WebSocketServerSession) {
sessions[uid]?.remove(session) sessions[uid]?.remove(session)
if(sessions[uid]?.isEmpty() == true) { if (sessions[uid]?.isEmpty() == true) {
sessions.remove(uid) sessions.remove(uid)
} }
} }
@@ -88,78 +89,111 @@ fun Routing.wsSongRoutes() {
} }
webSocket("/song/{uid}") { webSocket("/song/{uid}") {
logger.info("WebSocket connection attempt received")
val uid = call.parameters["uid"] val uid = call.parameters["uid"]
val user = uid?.let { UserService.getUser(it) } val user = uid?.let { UserService.getUser(it) }
if (uid == null || user == null) { if (uid == null || user == null) {
logger.warn("Invalid UID: $uid")
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID")) close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID"))
return@webSocket return@webSocket
} }
addSession(uid, this)
if(status[uid] == SongType.STREAM_OFF) {
songScope.launch {
sendSerialized(SongResponse(
SongType.STREAM_OFF.value,
uid,
null,
null,
null,
))
}
}
try { try {
for (frame in incoming) { addSession(uid, this)
when(frame) { logger.info("WebSocket connection established for user: $uid")
is Frame.Text -> {
val text = frame.readText().trim() // Start heartbeat
if(text == "ping") { val heartbeatJob = songScope.launch {
send("pong") while (true) {
} else { try {
val data = Json.decodeFromString<SongRequest>(text) send(Frame.Ping(ByteArray(0)))
if (data.type == SongType.ACK.value) { delay(30000) // 30 seconds
ackMap[data.uid]?.get(this)?.complete(true) } catch (e: Exception) {
ackMap[data.uid]?.remove(this) logger.error("Heartbeat failed for user $uid", e)
} break
}
} }
is Frame.Ping -> send(Frame.Pong(frame.data))
else -> {}
} }
} }
} catch(e: ClosedReceiveChannelException) {
logger.error("Error in WebSocket: ${e.message}") if (status[uid] == SongType.STREAM_OFF) {
} finally { songScope.launch {
removeSession(uid, this) sendSerialized(
ackMap[uid]?.remove(this) SongResponse(
SongType.STREAM_OFF.value,
uid,
null,
null,
null,
)
)
}
}
try {
for (frame in incoming) {
when (frame) {
is Frame.Text -> {
val text = frame.readText().trim()
if (text == "ping") {
send("pong")
} else {
val data = Json.decodeFromString<SongRequest>(text)
if (data.type == SongType.ACK.value) {
ackMap[data.uid]?.get(this)?.complete(true)
ackMap[data.uid]?.remove(this)
}
}
}
is Frame.Ping -> send(Frame.Pong(frame.data))
else -> {}
}
}
} catch (e: ClosedReceiveChannelException) {
logger.error("WebSocket connection closed for user $uid: ${e.message}")
} catch (e: Exception) {
logger.error("Unexpected error in WebSocket for user $uid", e)
} finally {
logger.info("Cleaning up WebSocket connection for user $uid")
removeSession(uid, this)
ackMap[uid]?.remove(this)
heartbeatJob.cancel()
}
} catch(e: Exception) {
logger.error("Unexpected error in WebSocket for user $uid", e)
} }
} }
dispatcher.subscribe(SongEvent::class) { dispatcher.subscribe(SongEvent::class) {
logger.debug("SongEvent: {} / {} {}", it.uid, it.type, it.current?.name) logger.debug("SongEvent: {} / {} {}", it.uid, it.type, it.current?.name)
songScope.launch { songScope.launch {
broadcastMessage(it.uid, SongResponse( broadcastMessage(
it.type.value, it.uid, SongResponse(
it.uid, it.type.value,
it.reqUid, it.uid,
it.current?.toSerializable(), it.reqUid,
it.next?.toSerializable(), it.current?.toSerializable(),
it.delUrl it.next?.toSerializable(),
)) it.delUrl
)
)
} }
} }
dispatcher.subscribe(TimerEvent::class) { dispatcher.subscribe(TimerEvent::class) {
if(it.type == TimerType.STREAM_OFF) { if (it.type == TimerType.STREAM_OFF) {
songScope.launch { songScope.launch {
broadcastMessage(it.uid, SongResponse( broadcastMessage(
it.type.value, it.uid, SongResponse(
it.uid, it.type.value,
null, it.uid,
null, null,
null, null,
)) null,
)
)
} }
} }
} }
} }
@Serializable @Serializable
data class SerializableYoutubeVideo( data class SerializableYoutubeVideo(
val url: String, val url: String,

View File

@@ -1,50 +0,0 @@
package space.mori.chzzk_bot.webserver.utils
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.common.utils.IData
import space.mori.chzzk_bot.common.utils.IStreamInfo
import space.mori.chzzk_bot.common.utils.getStreamInfo
import space.mori.chzzk_bot.common.utils.getUserInfo
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap
object ChzzkUserCache {
private val cache = ConcurrentHashMap<String, CachedUser>()
private const val EXP_SECONDS = 600L
private val mutex = Mutex()
private val logger = LoggerFactory.getLogger(this::class.java)
suspend fun getCachedUser(id: String): IData<IStreamInfo?>? {
val now = Instant.now()
var user = cache[id]
if(user == null || user.timestamp.plusSeconds(EXP_SECONDS).isBefore(now)) {
mutex.withLock {
if(user == null || user.timestamp.plusSeconds(EXP_SECONDS)?.isBefore(now) != false) {
var findUser = getStreamInfo(id)
if(findUser.content == null) {
val userInfo = getUserInfo(id)
if(userInfo.content == null) return null
findUser = IData(200, null, IStreamInfo(
channel = userInfo.content!!
))
}
user = CachedUser(findUser)
user.let { cache[id] = user }
}
}
}
return cache[id]?.user
}
}
data class CachedUser(
val user: IData<IStreamInfo?>,
val timestamp: Instant = Instant.now(),
)