diff options
| author | Sho Sakuma <me@m1sk9.dev> | 2026-01-31 17:50:11 +0900 |
|---|---|---|
| committer | Sho Sakuma <me@m1sk9.dev> | 2026-01-31 17:50:17 +0900 |
| commit | cdb03c1e628004820b0d2b70682f52f4f97c4663 (patch) | |
| tree | 2b2e9b9e89bde694c913b06d3c59b9a169f2f68e | |
| parent | ec963f344264edfca80c8e1487206997fc1033ff (diff) | |
| download | LunaticChat-cdb03c1e628004820b0d2b70682f52f4f97c4663.tar.gz LunaticChat-cdb03c1e628004820b0d2b70682f52f4f97c4663.tar.bz2 LunaticChat-cdb03c1e628004820b0d2b70682f52f4f97c4663.zip | |
feat: Add channel logging
7 files changed, 335 insertions, 0 deletions
diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/chat/channel/ChannelMessageLogEntry.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/chat/channel/ChannelMessageLogEntry.kt new file mode 100644 index 0000000..7cd4eb6 --- /dev/null +++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/chat/channel/ChannelMessageLogEntry.kt @@ -0,0 +1,51 @@ +package dev.m1sk9.lunaticChat.engine.chat.channel + +import kotlinx.serialization.Serializable +import java.time.Instant +import java.util.UUID + +/** + * Represents a single channel message log entry in NDJSON format. + * + * This data class is designed for append-only logging to daily rotated files, + * compatible with Grafana Loki and other log aggregation systems. + * + * @property timestamp ISO-8601 formatted UTC timestamp of when the message was sent + * @property playerId UUID of the player who sent the message + * @property playerName Display name of the player + * @property channelId Unique identifier of the channel + * @property message The chat message content + */ +@Serializable +data class ChannelMessageLogEntry( + val timestamp: String, + val playerId: String, + val playerName: String, + val channelId: String, + val message: String, +) { + companion object { + /** + * Creates a new log entry with the current timestamp. + * + * @param playerId UUID of the player + * @param playerName Display name of the player + * @param channelId Channel identifier + * @param message Message content + * @return New ChannelMessageLogEntry instance + */ + fun create( + playerId: UUID, + playerName: String, + channelId: String, + message: String, + ): ChannelMessageLogEntry = + ChannelMessageLogEntry( + timestamp = Instant.now().toString(), + playerId = playerId.toString(), + playerName = playerName, + channelId = channelId, + message = message, + ) + } +} diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt index daba3dd..e6ea864 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt @@ -5,6 +5,7 @@ import dev.m1sk9.lunaticChat.paper.chat.ChatModeManager import dev.m1sk9.lunaticChat.paper.chat.ChatModeStorage import dev.m1sk9.lunaticChat.paper.chat.channel.ChannelManager import dev.m1sk9.lunaticChat.paper.chat.channel.ChannelMembershipManager +import dev.m1sk9.lunaticChat.paper.chat.channel.ChannelMessageLogger import dev.m1sk9.lunaticChat.paper.chat.channel.ChannelStorage import dev.m1sk9.lunaticChat.paper.chat.handler.ChannelMessageHandler import dev.m1sk9.lunaticChat.paper.chat.handler.ChannelNotificationHandler @@ -49,6 +50,7 @@ class ServiceInitializer( private var chatModeManager: ChatModeManager? = null private var channelMessageHandler: ChannelMessageHandler? = null private var channelNotificationHandler: ChannelNotificationHandler? = null + private var channelMessageLogger: ChannelMessageLogger? = null /** * Initializes all services in dependency order. @@ -227,6 +229,27 @@ class ServiceInitializer( chatMode.initialize() chatModeManager = chatMode + // Initialize channel message logger if enabled + val messageLogger = + if (configuration.features.channelChat.messageLogging.enabled) { + val logsDir = plugin.dataFolder.resolve("logs/channelchat").toPath() + ChannelMessageLogger( + logsDirectory = logsDir, + plugin = plugin, + logger = + io.ktor.util.logging + .KtorSimpleLogger("ChannelMessageLogger"), + maxFileSizeBytes = configuration.features.channelChat.messageLogging.maxFileSizeMB * 1024L * 1024L, + ).also { + channelMessageLogger = it + logger.info( + "Channel message logging enabled (retention: ${configuration.features.channelChat.messageLogging.retentionDays} days)", + ) + } + } else { + null + } + val messageHandler = ChannelMessageHandler( configuration = configuration, @@ -234,6 +257,7 @@ class ServiceInitializer( channelManager = manager, romanjiConverter = romajiConverter, languageManager = languageManager, + messageLogger = messageLogger, logger = io.ktor.util.logging .KtorSimpleLogger("ChannelMessageHandler"), @@ -275,6 +299,26 @@ class ServiceInitializer( saveInterval, ) } + + // Schedule cleanup of old channel message logs + if (configuration.features.channelChat.messageLogging.enabled && + configuration.features.channelChat.messageLogging.retentionDays > 0 && + channelMessageLogger != null + ) { + val cleanupInterval = 24 * 60 * 60 * 20L // 24 hours in ticks + val initialDelay = 5 * 60 * 20L // 5 minutes after startup + + plugin.server.scheduler.runTaskTimerAsynchronously( + plugin, + Runnable { + channelMessageLogger?.cleanupOldLogs( + configuration.features.channelChat.messageLogging.retentionDays, + ) + }, + initialDelay, + cleanupInterval, + ) + } } /** @@ -285,5 +329,6 @@ class ServiceInitializer( conversionCache?.saveToDisk() services.channelManager?.saveToDisk() services.chatModeManager?.shutdown() + channelMessageLogger?.flushSync() } } diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMessageLogger.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMessageLogger.kt new file mode 100644 index 0000000..d14b097 --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMessageLogger.kt @@ -0,0 +1,191 @@ +package dev.m1sk9.lunaticChat.paper.chat.channel + +import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelMessageLogEntry +import io.ktor.util.logging.Logger +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.bukkit.plugin.Plugin +import java.io.BufferedWriter +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.time.LocalDate +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.concurrent.ConcurrentLinkedQueue +import kotlin.io.path.deleteIfExists +import kotlin.io.path.fileSize +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name + +/** + * Asynchronous logger for channel messages. + * + * Writes messages in NDJSON format to daily rotated log files. + * Uses a concurrent queue and scheduled flushing to minimize performance impact. + * + * @property logsDirectory Directory where log files are stored + * @property plugin Bukkit plugin instance for scheduling tasks + * @property logger Logger for diagnostic messages + * @property maxFileSizeBytes Maximum size of a single log file + */ +class ChannelMessageLogger( + private val logsDirectory: Path, + private val plugin: Plugin, + private val logger: Logger, + private val maxFileSizeBytes: Long, +) { + private val pendingEntries = ConcurrentLinkedQueue<ChannelMessageLogEntry>() + private val json = Json { encodeDefaults = true } + private var flushTaskId: Int? = null + + companion object { + private const val LOG_FILE_PREFIX = "channel-messages-" + private const val LOG_FILE_EXTENSION = ".json" + private val DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + private const val FLUSH_INTERVAL_TICKS = 20L // 1 second + } + + init { + // Ensure logs directory exists + try { + Files.createDirectories(logsDirectory) + logger.info("Channel message logger initialized at: $logsDirectory") + schedulePeriodicFlush() + } catch (e: Exception) { + logger.error("Failed to initialize channel message logger", e) + } + } + + /** + * Queues a message for asynchronous logging. + * + * @param entry The log entry to write + */ + fun logMessage(entry: ChannelMessageLogEntry) { + pendingEntries.offer(entry) + } + + /** + * Schedules periodic flushing of pending log entries. + */ + private fun schedulePeriodicFlush() { + flushTaskId = + plugin.server.scheduler + .runTaskTimerAsynchronously( + plugin, + Runnable { flushPendingEntries() }, + FLUSH_INTERVAL_TICKS, + FLUSH_INTERVAL_TICKS, + ).taskId + } + + /** + * Flushes all pending entries to the current day's log file. + */ + private fun flushPendingEntries() { + if (pendingEntries.isEmpty()) { + return + } + + val entries = mutableListOf<ChannelMessageLogEntry>() + while (true) { + val entry = pendingEntries.poll() ?: break + entries.add(entry) + } + + if (entries.isEmpty()) { + return + } + + try { + val logFile = getCurrentLogFile() + + // Check file size before writing + if (Files.exists(logFile) && logFile.fileSize() >= maxFileSizeBytes) { + logger.warn("Log file ${logFile.name} exceeded maximum size, skipping flush") + return + } + + BufferedWriter( + Files.newBufferedWriter( + logFile, + StandardOpenOption.CREATE, + StandardOpenOption.APPEND, + ), + ).use { writer -> + for (entry in entries) { + val jsonLine = json.encodeToString(entry) + writer.write(jsonLine) + writer.newLine() + } + } + } catch (e: Exception) { + logger.error("Failed to flush log entries", e) + // Re-queue entries for retry + entries.forEach { pendingEntries.offer(it) } + } + } + + /** + * Synchronously flushes all pending entries. + * Should be called during plugin shutdown. + */ + fun flushSync() { + // Cancel scheduled task + flushTaskId?.let { plugin.server.scheduler.cancelTask(it) } + + // Flush remaining entries + flushPendingEntries() + logger.info("Channel message logger flushed all pending entries") + } + + /** + * Deletes log files older than the specified retention period. + * + * @param retentionDays Number of days to retain log files + */ + fun cleanupOldLogs(retentionDays: Int) { + if (retentionDays <= 0) { + return + } + + try { + val cutoffDate = LocalDate.now(ZoneOffset.UTC).minusDays(retentionDays.toLong()) + val logFiles = logsDirectory.listDirectoryEntries("$LOG_FILE_PREFIX*$LOG_FILE_EXTENSION") + + var deletedCount = 0 + for (logFile in logFiles) { + val fileName = logFile.name + val dateStr = fileName.removePrefix(LOG_FILE_PREFIX).removeSuffix(LOG_FILE_EXTENSION) + + try { + val fileDate = LocalDate.parse(dateStr, DATE_FORMATTER) + if (fileDate.isBefore(cutoffDate)) { + logFile.deleteIfExists() + deletedCount++ + logger.info("Deleted old log file: $fileName") + } + } catch (e: Exception) { + logger.warn("Failed to parse date from log file: $fileName", e) + } + } + + if (deletedCount > 0) { + logger.info("Cleaned up $deletedCount old log file(s)") + } + } catch (e: Exception) { + logger.error("Failed to cleanup old logs", e) + } + } + + /** + * Gets the log file path for the current UTC date. + */ + private fun getCurrentLogFile(): Path { + val currentDate = LocalDate.now(ZoneOffset.UTC) + val dateStr = currentDate.format(DATE_FORMATTER) + val fileName = "$LOG_FILE_PREFIX$dateStr$LOG_FILE_EXTENSION" + return logsDirectory.resolve(fileName) + } +} diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt index 12426cf..810d1ca 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt @@ -1,6 +1,8 @@ package dev.m1sk9.lunaticChat.paper.chat.handler +import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelMessageLogEntry import dev.m1sk9.lunaticChat.paper.chat.channel.ChannelManager +import dev.m1sk9.lunaticChat.paper.chat.channel.ChannelMessageLogger import dev.m1sk9.lunaticChat.paper.common.SpyPermissionManager import dev.m1sk9.lunaticChat.paper.common.playChannelReceiveNotification import dev.m1sk9.lunaticChat.paper.common.playMessageSendNotification @@ -20,6 +22,7 @@ class ChannelMessageHandler( private val channelManager: ChannelManager, private val romanjiConverter: RomanjiConverter?, private val languageManager: LanguageManager, + private val messageLogger: ChannelMessageLogger?, private val logger: Logger, ) { fun sendChannelMessage( @@ -92,6 +95,19 @@ class ChannelMessageHandler( } logger.info("Channel Message from ${player.name} in ${context.channel.name}: $message") + + // Log message to file if logging is enabled + messageLogger?.let { + val logEntry = + ChannelMessageLogEntry.create( + playerId = player.uniqueId, + playerName = player.name, + channelId = context.channelId, + message = message, + ) + it.logMessage(logEntry) + } + return true } diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt index 5b93ac5..9a64428 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt @@ -1,8 +1,12 @@ package dev.m1sk9.lunaticChat.paper.config.key +import kotlinx.serialization.Serializable + +@Serializable data class ChannelChatFeatureConfig( val enabled: Boolean, val maxChannelsPerServer: Int = 0, val maxMembersPerChannel: Int = 0, val maxMembershipPerPlayer: Int = 0, + val messageLogging: ChannelMessageLoggingConfig = ChannelMessageLoggingConfig(), ) diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelMessageLoggingConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelMessageLoggingConfig.kt new file mode 100644 index 0000000..f036620 --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelMessageLoggingConfig.kt @@ -0,0 +1,20 @@ +package dev.m1sk9.lunaticChat.paper.config.key + +import kotlinx.serialization.Serializable + +/** + * Configuration for channel message logging feature. + * + * Enables file-based logging of all channel messages in NDJSON format, + * compatible with log aggregation systems like Grafana Loki. + * + * @property enabled Whether message logging is enabled + * @property retentionDays Number of days to retain log files (0 = keep forever) + * @property maxFileSizeMB Maximum size of a single log file in megabytes + */ +@Serializable +data class ChannelMessageLoggingConfig( + val enabled: Boolean = true, + val retentionDays: Int = 30, + val maxFileSizeMB: Int = 100, +) diff --git a/platform-paper/src/main/resources/config.yml b/platform-paper/src/main/resources/config.yml index 8efa1fa..0889070 100644 --- a/platform-paper/src/main/resources/config.yml +++ b/platform-paper/src/main/resources/config.yml @@ -57,6 +57,14 @@ features: maxMembersPerChannel: 0 # Maximum number of channels a single player can join. Set to 0 for unlimited. maxMembershipPerPlayer: 0 + # Channel message logging configuration + messageLogging: + # If enabled, all channel messages will be logged to NDJSON files for analysis and archival. + enabled: true + # Number of days to retain log files. Set to 0 to keep logs indefinitely. + retentionDays: 30 + # Maximum size of a single log file in megabytes. Files exceeding this size will stop accepting new entries. + maxFileSizeMB: 100 # ---------------------------------------------- # --------- Message Format Settings -------- |
