summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSho Sakuma <me@m1sk9.dev>2026-01-31 17:50:11 +0900
committerSho Sakuma <me@m1sk9.dev>2026-01-31 17:50:17 +0900
commitcdb03c1e628004820b0d2b70682f52f4f97c4663 (patch)
tree2b2e9b9e89bde694c913b06d3c59b9a169f2f68e
parentec963f344264edfca80c8e1487206997fc1033ff (diff)
downloadLunaticChat-cdb03c1e628004820b0d2b70682f52f4f97c4663.tar.gz
LunaticChat-cdb03c1e628004820b0d2b70682f52f4f97c4663.tar.bz2
LunaticChat-cdb03c1e628004820b0d2b70682f52f4f97c4663.zip
feat: Add channel logging
-rw-r--r--engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/chat/channel/ChannelMessageLogEntry.kt51
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt45
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMessageLogger.kt191
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt16
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt4
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelMessageLoggingConfig.kt20
-rw-r--r--platform-paper/src/main/resources/config.yml8
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 --------