From 67f9cac26fa4a0a16916057cb5cabe9469bf30da Mon Sep 17 00:00:00 2001 From: Caadiq Date: Mon, 22 Dec 2025 14:57:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=9C=EB=B2=84=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EB=AA=A8=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Essentials 닉네임 동기화 기능 추가 - AuthCommand 명령어 추가 - 플레이어 데이터 저장소 개선 --- .../com/beemer/essentials/util/ChatUtils.kt | 47 +++++- .../co/caadiq/serverstatus/ServerStatusMod.kt | 45 +++--- .../serverstatus/command/AuthCommand.kt | 153 ++++++++++++++++++ .../serverstatus/config/PlayerDataStore.kt | 46 ++++++ .../serverstatus/network/HttpApiServer.kt | 142 ++++++++-------- 5 files changed, 337 insertions(+), 96 deletions(-) create mode 100644 ServerStatus/src/main/kotlin/co/caadiq/serverstatus/command/AuthCommand.kt diff --git a/Essentials/src/main/kotlin/com/beemer/essentials/util/ChatUtils.kt b/Essentials/src/main/kotlin/com/beemer/essentials/util/ChatUtils.kt index 0170721..13c376a 100644 --- a/Essentials/src/main/kotlin/com/beemer/essentials/util/ChatUtils.kt +++ b/Essentials/src/main/kotlin/com/beemer/essentials/util/ChatUtils.kt @@ -5,13 +5,19 @@ import com.beemer.essentials.nickname.NicknameDataStore import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatter +import net.minecraft.ChatFormatting +import net.minecraft.network.chat.ClickEvent import net.minecraft.network.chat.Component +import net.minecraft.network.chat.MutableComponent +import net.minecraft.network.chat.Style import net.minecraft.server.level.ServerPlayer object ChatUtils { private const val SECTION = '\u00a7' private val legacyPattern = Regex("&([0-9A-FK-ORa-fk-or])") private val hexPattern = Regex("&#([A-Fa-f0-9]{6})") + private val urlPattern = + Regex("(https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)", RegexOption.IGNORE_CASE) private val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") private fun translateChatColors(text: String?): String { @@ -56,6 +62,42 @@ object ChatUtils { return translateChatColors(withPlaceholders) } + /** 문자열에서 URL을 찾아 클릭 가능한 Component로 변환 */ + private fun parseWithUrls(text: String): MutableComponent { + val result: MutableComponent = Component.empty() + var lastEnd = 0 + + urlPattern.findAll(text).forEach { match -> + // URL 앞의 일반 텍스트 추가 + if (match.range.first > lastEnd) { + result.append(Component.literal(text.substring(lastEnd, match.range.first))) + } + + // URL 부분 - 클릭 가능하게 + 밑줄 + 파란색 + val url = match.value + val urlComponent = + Component.literal(url) + .withStyle( + Style.EMPTY + .withClickEvent( + ClickEvent(ClickEvent.Action.OPEN_URL, url) + ) + .withColor(ChatFormatting.AQUA) + .withUnderlined(true) + ) + result.append(urlComponent) + + lastEnd = match.range.last + 1 + } + + // 마지막 URL 이후의 텍스트 추가 + if (lastEnd < text.length) { + result.append(Component.literal(text.substring(lastEnd))) + } + + return result + } + fun broadcastJoin(player: ServerPlayer, firstTime: Boolean) { val key = if (firstTime) "first-time-join" else "join" val template = ChatConfig.getFormat(key) ?: defaultJoinTemplate(firstTime) @@ -72,7 +114,10 @@ object ChatUtils { fun broadcastChat(player: ServerPlayer, rawMessage: String) { val template = ChatConfig.getFormat("chat") ?: "%name% &7&l: &f%message%" val formatted = chatFormatting(template, player, rawMessage) - player.server.playerList.broadcastSystemMessage(Component.literal(formatted), false) + + // URL을 클릭 가능한 링크로 변환 + val component = parseWithUrls(formatted) + player.server.playerList.broadcastSystemMessage(component, false) } private fun defaultJoinTemplate(firstTime: Boolean): String = diff --git a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/ServerStatusMod.kt b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/ServerStatusMod.kt index 7535abc..4e989b3 100644 --- a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/ServerStatusMod.kt +++ b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/ServerStatusMod.kt @@ -1,30 +1,30 @@ package co.caadiq.serverstatus +import co.caadiq.serverstatus.command.AuthCommand import co.caadiq.serverstatus.config.ModConfig import co.caadiq.serverstatus.config.PlayerDataStore +import co.caadiq.serverstatus.data.PlayerStatsCollector import co.caadiq.serverstatus.data.PlayerTracker import co.caadiq.serverstatus.data.ServerDataCollector import co.caadiq.serverstatus.data.WorldDataCollector -import co.caadiq.serverstatus.data.PlayerStatsCollector import co.caadiq.serverstatus.network.HttpApiServer import net.neoforged.bus.api.IEventBus +import net.neoforged.bus.api.SubscribeEvent import net.neoforged.fml.ModContainer import net.neoforged.fml.common.Mod import net.neoforged.fml.event.lifecycle.FMLDedicatedServerSetupEvent import net.neoforged.neoforge.common.NeoForge +import net.neoforged.neoforge.event.RegisterCommandsEvent import org.apache.logging.log4j.LogManager -/** - * 메인 모드 클래스 - * 서버 상태 정보를 HTTP API로 제공 - */ +/** 메인 모드 클래스 서버 상태 정보를 HTTP API로 제공 */ @Mod(ServerStatusMod.MOD_ID) class ServerStatusMod(modBus: IEventBus, container: ModContainer) { - + companion object { const val MOD_ID = "serverstatus" val LOGGER = LogManager.getLogger(MOD_ID) - + lateinit var config: ModConfig private set lateinit var playerDataStore: PlayerDataStore @@ -38,38 +38,43 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) { lateinit var httpApiServer: HttpApiServer private set } - + init { LOGGER.info("[$MOD_ID] 모드 초기화 중...") - + // 설정 초기화 config = ModConfig.load() playerDataStore = PlayerDataStore.load() - + // 서버 전용 이벤트 등록 modBus.addListener(::onServerSetup) - - // 플레이어 이벤트 리스너 등록 + + // 플레이어 이벤트 및 명령어 리스너 등록 NeoForge.EVENT_BUS.register(PlayerTracker) - + NeoForge.EVENT_BUS.register(this) + LOGGER.info("[$MOD_ID] 모드 초기화 완료") } - - /** - * 전용 서버 설정 이벤트 - */ + + /** 명령어 등록 이벤트 */ + @SubscribeEvent + fun onRegisterCommands(event: RegisterCommandsEvent) { + AuthCommand.register(event.dispatcher) + } + + /** 전용 서버 설정 이벤트 */ private fun onServerSetup(event: FMLDedicatedServerSetupEvent) { LOGGER.info("[$MOD_ID] 서버 설정 중...") - + // 데이터 수집기 초기화 serverDataCollector = ServerDataCollector() worldDataCollector = WorldDataCollector() playerStatsCollector = PlayerStatsCollector() - + // HTTP API 서버 시작 httpApiServer = HttpApiServer(config.httpPort) httpApiServer.start() - + LOGGER.info("[$MOD_ID] HTTP API 서버 시작됨 (포트: ${config.httpPort})") } } diff --git a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/command/AuthCommand.kt b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/command/AuthCommand.kt new file mode 100644 index 0000000..a0144a8 --- /dev/null +++ b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/command/AuthCommand.kt @@ -0,0 +1,153 @@ +package co.caadiq.serverstatus.command + +import co.caadiq.serverstatus.ServerStatusMod +import com.mojang.brigadier.CommandDispatcher +import com.mojang.brigadier.arguments.StringArgumentType +import com.mojang.brigadier.context.CommandContext +import java.net.HttpURLConnection +import java.net.URI +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import net.minecraft.commands.CommandSourceStack +import net.minecraft.commands.Commands +import net.minecraft.network.chat.Component +import net.minecraft.server.level.ServerPlayer + +/** /인증 명령어 처리 웹사이트 계정 연동을 위한 명령어 */ +object AuthCommand { + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + // 웹 서버 연동 API URL (환경 또는 설정에서 가져올 수 있음) + private const val WEB_API_URL = "http://minecraft-status/link/verify" + + /** 명령어 등록 */ + fun register(dispatcher: CommandDispatcher) { + // /인증 <토큰> + dispatcher.register( + Commands.literal("인증") + .then( + Commands.argument("token", StringArgumentType.word()).executes { + context -> + executeAuth(context) + } + ) + ) + + ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] /인증 명령어 등록됨") + } + + /** 인증 명령어 실행 */ + private fun executeAuth(context: CommandContext): Int { + val source = context.source + val player = source.player + + // 플레이어만 사용 가능 + if (player == null) { + source.sendFailure(Component.literal("§c플레이어만 이 명령어를 사용할 수 있습니다.")) + return 0 + } + + val token = StringArgumentType.getString(context, "token").uppercase() + + // 토큰 검증 (6자리 영숫자) + if (!token.matches(Regex("^[A-Z0-9]{6}$"))) { + source.sendFailure(Component.literal("§c유효하지 않은 인증 코드입니다.")) + return 0 + } + + // 비동기로 웹 API 호출 + Thread { + try { + val result = verifyWithWebServer(token, player) + + // UI 스레드에서 메시지 전송 + player.server.execute { + if (result.success) { + player.sendSystemMessage( + Component.literal("§a§l✓ 계정 연동 완료!\n§7웹사이트에서 프로필이 업데이트됩니다.") + ) + } else { + player.sendSystemMessage( + Component.literal( + "§c✗ 연동 실패: ${result.error ?: "알 수 없는 오류"}" + ) + ) + } + } + } catch (e: Exception) { + ServerStatusMod.LOGGER.error( + "[${ServerStatusMod.MOD_ID}] 인증 오류: ${e.message}" + ) + player.server.execute { + player.sendSystemMessage(Component.literal("§c✗ 연동 처리 중 오류가 발생했습니다.")) + } + } + } + .start() + + // 즉시 응답 + source.sendSuccess({ Component.literal("§e인증 처리 중...") }, false) + return 1 + } + + /** 웹 서버에 연동 요청 */ + private fun verifyWithWebServer(token: String, player: ServerPlayer): VerifyResponse { + val requestBody = + json.encodeToString( + VerifyRequest( + token = token, + uuid = player.stringUUID, + name = player.name.string + ) + ) + + val url = URI(WEB_API_URL).toURL() + val conn = url.openConnection() as HttpURLConnection + + try { + conn.requestMethod = "POST" + conn.doOutput = true + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8") + conn.connectTimeout = 5000 + conn.readTimeout = 5000 + + // 요청 전송 + conn.outputStream.use { os -> os.write(requestBody.toByteArray(Charsets.UTF_8)) } + + // 응답 처리 + val responseCode = conn.responseCode + val responseBody = + if (responseCode in 200..299) { + conn.inputStream.bufferedReader().use { it.readText() } + } else { + conn.errorStream?.bufferedReader()?.use { it.readText() } ?: "" + } + + ServerStatusMod.LOGGER.info( + "[${ServerStatusMod.MOD_ID}] 인증 응답: $responseCode - $responseBody" + ) + + return try { + json.decodeFromString(responseBody) + } catch (e: Exception) { + VerifyResponse(success = responseCode in 200..299, error = responseBody) + } + } finally { + conn.disconnect() + } + } + + @Serializable data class VerifyRequest(val token: String, val uuid: String, val name: String) + + @Serializable + data class VerifyResponse( + val success: Boolean = false, + val message: String? = null, + val error: String? = null + ) +} diff --git a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/config/PlayerDataStore.kt b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/config/PlayerDataStore.kt index d45f31e..f2732a6 100644 --- a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/config/PlayerDataStore.kt +++ b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/config/PlayerDataStore.kt @@ -62,6 +62,24 @@ data class PlayerDataStore( private val configDir = FMLPaths.CONFIGDIR.get().resolve(ServerStatusMod.MOD_ID) private val dataPath = configDir.resolve("players.json") + // Essentials 닉네임 파일 경로 + private val essentialsNicknamePath = FMLPaths.CONFIGDIR.get().resolve("essentials/nicknames.json") + + /** + * Essentials 닉네임 조회 + */ + private fun loadEssentialsNicknames(): Map { + return try { + if (essentialsNicknamePath.exists()) { + json.decodeFromString>(essentialsNicknamePath.readText()) + } else { + emptyMap() + } + } catch (e: Exception) { + emptyMap() + } + } + /** * 플레이어 데이터 로드 */ @@ -79,6 +97,34 @@ data class PlayerDataStore( } } + /** + * 특정 플레이어의 Essentials 닉네임 가져오기 (없으면 저장된 이름) + */ + fun getDisplayName(uuid: String): String { + val essentialsNicks = loadEssentialsNicknames() + return essentialsNicks[uuid] ?: players[uuid]?.name ?: "Unknown" + } + + /** + * 모든 플레이어 닉네임 Essentials와 동기화 + */ + fun syncNicknamesFromEssentials() { + val essentialsNicks = loadEssentialsNicknames() + var synced = 0 + players.forEach { (uuid, player) -> + essentialsNicks[uuid]?.let { nick -> + if (player.name != nick) { + player.name = nick + synced++ + } + } + } + if (synced > 0) { + save() + ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] Essentials 닉네임 동기화: ${synced}명") + } + } + /** * 데이터 저장 */ diff --git a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/network/HttpApiServer.kt b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/network/HttpApiServer.kt index 1b3e2ab..4e75c2e 100644 --- a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/network/HttpApiServer.kt +++ b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/network/HttpApiServer.kt @@ -1,101 +1,90 @@ package co.caadiq.serverstatus.network import co.caadiq.serverstatus.ServerStatusMod -import co.caadiq.serverstatus.data.AdvancementsInfo -import co.caadiq.serverstatus.data.PlayerStats import co.caadiq.serverstatus.data.WorldInfo import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpHandler import com.sun.net.httpserver.HttpServer -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import java.net.InetSocketAddress import java.nio.charset.StandardCharsets import java.util.concurrent.Executors +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json -/** - * HTTP API 서버 - * Java 내장 HttpServer 사용 - 외부 의존성 없음 - */ +/** HTTP API 서버 Java 내장 HttpServer 사용 - 외부 의존성 없음 */ class HttpApiServer(private val port: Int) { - - private val json = Json { + + private val json = Json { prettyPrint = false ignoreUnknownKeys = true encodeDefaults = true } - + private var server: HttpServer? = null - - /** - * 서버 시작 - */ + + /** 서버 시작 */ fun start() { try { server = HttpServer.create(InetSocketAddress(port), 0) server?.executor = Executors.newFixedThreadPool(4) - + // 엔드포인트 등록 server?.createContext("/status", StatusHandler()) server?.createContext("/players", PlayersHandler()) server?.createContext("/player", PlayerHandler()) server?.createContext("/worlds", WorldsHandler()) - + server?.start() - ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] HTTP API 서버 시작: http://0.0.0.0:$port") + ServerStatusMod.LOGGER.info( + "[${ServerStatusMod.MOD_ID}] HTTP API 서버 시작: http://0.0.0.0:$port" + ) } catch (e: Exception) { ServerStatusMod.LOGGER.error("[${ServerStatusMod.MOD_ID}] HTTP 서버 시작 실패: ${e.message}") } } - - /** - * 서버 종료 - */ + + /** 서버 종료 */ fun stop() { server?.stop(1) } - - /** - * CORS 헤더 추가 및 응답 전송 - */ + + /** CORS 헤더 추가 및 응답 전송 */ private fun sendJsonResponse(exchange: HttpExchange, response: String, statusCode: Int = 200) { exchange.responseHeaders.add("Content-Type", "application/json; charset=utf-8") exchange.responseHeaders.add("Access-Control-Allow-Origin", "*") exchange.responseHeaders.add("Access-Control-Allow-Methods", "GET, OPTIONS") exchange.responseHeaders.add("Access-Control-Allow-Headers", "Content-Type") - + val bytes = response.toByteArray(StandardCharsets.UTF_8) exchange.sendResponseHeaders(statusCode, bytes.size.toLong()) exchange.responseBody.use { it.write(bytes) } } - - /** - * PlayerData를 PlayerDetail로 변환 - */ + + /** PlayerData를 PlayerDetail로 변환 (Essentials 닉네임 우선 적용) */ private fun toPlayerDetail(player: co.caadiq.serverstatus.config.PlayerData): PlayerDetail { + // Essentials에서 닉네임 조회 (없으면 저장된 이름 사용) + val displayName = ServerStatusMod.playerDataStore.getDisplayName(player.uuid) return PlayerDetail( - uuid = player.uuid, - name = player.name, - firstJoin = player.firstJoin, - lastJoin = player.lastJoin, - lastLeave = player.lastLeave, - isOnline = player.isOnline, - currentSessionMs = player.getCurrentSessionMs(), - totalPlayTimeMs = player.totalPlayTimeMs + uuid = player.uuid, + name = displayName, + firstJoin = player.firstJoin, + lastJoin = player.lastJoin, + lastLeave = player.lastLeave, + isOnline = player.isOnline, + currentSessionMs = player.getCurrentSessionMs(), + totalPlayTimeMs = player.totalPlayTimeMs ) } - - /** - * GET /status - 서버 상태 - */ + + /** GET /status - 서버 상태 */ private inner class StatusHandler : HttpHandler { override fun handle(exchange: HttpExchange) { if (exchange.requestMethod == "OPTIONS") { sendJsonResponse(exchange, "", 204) return } - + try { val status = ServerStatusMod.serverDataCollector.collectStatus() val response = json.encodeToString(status) @@ -106,19 +95,18 @@ class HttpApiServer(private val port: Int) { } } } - - /** - * GET /players - 전체 플레이어 목록 - */ + + /** GET /players - 전체 플레이어 목록 */ private inner class PlayersHandler : HttpHandler { override fun handle(exchange: HttpExchange) { if (exchange.requestMethod == "OPTIONS") { sendJsonResponse(exchange, "", 204) return } - + try { - val players = ServerStatusMod.playerDataStore.getAllPlayers().map { toPlayerDetail(it) } + val players = + ServerStatusMod.playerDataStore.getAllPlayers().map { toPlayerDetail(it) } val response = json.encodeToString(AllPlayersResponse(players)) sendJsonResponse(exchange, response) } catch (e: Exception) { @@ -127,11 +115,10 @@ class HttpApiServer(private val port: Int) { } } } - + /** - * GET /player/{uuid} - 특정 플레이어 정보 - * GET /player/{uuid}/stats - 플레이어 통계 - * GET /player/{uuid}/advancements - 플레이어 도전과제 + * GET /player/{uuid} - 특정 플레이어 정보 GET /player/{uuid}/stats - 플레이어 통계 GET + * /player/{uuid}/advancements - 플레이어 도전과제 */ private inner class PlayerHandler : HttpHandler { override fun handle(exchange: HttpExchange) { @@ -139,39 +126,49 @@ class HttpApiServer(private val port: Int) { sendJsonResponse(exchange, "", 204) return } - + try { val path = exchange.requestURI.path val parts = path.removePrefix("/player/").split("/") val uuid = parts.getOrNull(0)?.takeIf { it.isNotBlank() } val subPath = parts.getOrNull(1) - + if (uuid == null) { sendJsonResponse(exchange, """{"error": "UUID required"}""", 400) return } - + when (subPath) { "stats" -> { // 온라인이면 실시간 통계, 오프라인이면 저장된 통계 val isOnline = ServerStatusMod.playerDataStore.isPlayerOnline(uuid) - val stats = if (isOnline) { - ServerStatusMod.playerStatsCollector.collectStats(uuid) - } else { - ServerStatusMod.playerDataStore.getSavedStats(uuid) - } + val stats = + if (isOnline) { + ServerStatusMod.playerStatsCollector.collectStats(uuid) + } else { + ServerStatusMod.playerDataStore.getSavedStats(uuid) + } if (stats != null) { sendJsonResponse(exchange, json.encodeToString(stats)) } else { - sendJsonResponse(exchange, """{"error": "Player stats not available"}""", 404) + sendJsonResponse( + exchange, + """{"error": "Player stats not available"}""", + 404 + ) } } "advancements" -> { - val advancements = ServerStatusMod.playerStatsCollector.collectAdvancements(uuid) + val advancements = + ServerStatusMod.playerStatsCollector.collectAdvancements(uuid) if (advancements != null) { sendJsonResponse(exchange, json.encodeToString(advancements)) } else { - sendJsonResponse(exchange, """{"error": "Player not online or not found"}""", 404) + sendJsonResponse( + exchange, + """{"error": "Player not online or not found"}""", + 404 + ) } } else -> { @@ -190,17 +187,15 @@ class HttpApiServer(private val port: Int) { } } } - - /** - * GET /worlds - 월드 정보 - */ + + /** GET /worlds - 월드 정보 */ private inner class WorldsHandler : HttpHandler { override fun handle(exchange: HttpExchange) { if (exchange.requestMethod == "OPTIONS") { sendJsonResponse(exchange, "", 204) return } - + try { val worlds = ServerStatusMod.worldDataCollector.collectWorlds() val response = json.encodeToString(WorldsResponse(worlds)) @@ -213,7 +208,4 @@ class HttpApiServer(private val port: Int) { } } -@Serializable -data class WorldsResponse( - val worlds: List -) +@Serializable data class WorldsResponse(val worlds: List)