A Minecraft Fabric mod that connects the game with ATProtocol ⛏️
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(sessions): implement play session tracking

Track when players join and leave servers, creating player.session records
with join/leave times, duration, and quit reason. Uses ServerPlayConnectionEvents
for join/leave detection. Respects syncSessions consent. Flushes all open
sessions on server stop with "server_stop" quit reason.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>

+285 -6
+68 -6
src/main/kotlin/com/jollywhoppers/Atprotoconnect.kt
··· 6 6 import com.jollywhoppers.atproto.server.AtProtoSessionManager 7 7 import com.jollywhoppers.atproto.server.PlayerIdentityStore 8 8 import com.jollywhoppers.atproto.server.PlayerProfileService 9 + import com.jollywhoppers.atproto.server.PlayerSessionSyncService 9 10 import com.jollywhoppers.atproto.server.PlayerStatSyncService 10 11 import com.jollywhoppers.atproto.server.RecordManager 12 + import com.jollywhoppers.atproto.server.ServerStatusSyncService 11 13 import com.jollywhoppers.security.SecurityAuditor 12 14 import net.fabricmc.api.ModInitializer 13 15 import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback 14 16 import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents 15 17 import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents 18 + import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents 16 19 import net.fabricmc.loader.api.FabricLoader 17 20 import org.slf4j.LoggerFactory 18 21 import java.util.concurrent.Executors ··· 42 45 private set 43 46 44 47 lateinit var achievementSyncService: AchievementSyncService 48 + private set 49 + 50 + lateinit var sessionSyncService: PlayerSessionSyncService 51 + private set 52 + 53 + lateinit var serverStatusSyncService: ServerStatusSyncService 45 54 private set 46 55 47 56 lateinit var commands: AtProtoCommands ··· 111 120 AchievementSyncService.INSTANCE = achievementSyncService 112 121 logger.info("Achievement sync service initialized") 113 122 123 + // Initialize session sync service 124 + sessionSyncService = PlayerSessionSyncService( 125 + recordManager = recordManager, 126 + sessionManager = sessionManager, 127 + identityStore = identityStore, 128 + ) 129 + logger.info("Session sync service initialized") 130 + 131 + // Initialize server status sync service 132 + serverStatusSyncService = ServerStatusSyncService( 133 + recordManager = recordManager, 134 + sessionManager = sessionManager, 135 + identityStore = identityStore, 136 + ) 137 + logger.info("Server status sync service initialized") 138 + 114 139 // Initialize command handler (with rate limiting and audit logging) 115 140 commands = AtProtoCommands(atProtoClient, identityStore, sessionManager, profileService) 116 141 ··· 128 153 statSyncService.onServerTick(server) 129 154 } 130 155 logger.info("Minecraft stat sync tick handler registered") 156 + 157 + // Register server status sync (every 5 minutes = 6000 ticks) 158 + var serverStatusTickCounter = 0L 159 + ServerTickEvents.END_SERVER_TICK.register { server -> 160 + serverStatusTickCounter++ 161 + if (serverStatusTickCounter % 6000L == 0L) { 162 + serverStatusSyncService.onSyncTick(server) 163 + } 164 + } 165 + logger.info("Server status sync tick handler registered (5-minute interval)") 166 + 167 + // Register player join/leave events for session tracking 168 + ServerPlayConnectionEvents.JOIN.register { handler, _, server -> 169 + val player = (handler as? net.minecraft.server.network.ServerGamePacketListenerImpl)?.player 170 + if (player != null) { 171 + sessionSyncService.onPlayerJoin(player, server) 172 + } 173 + } 174 + ServerPlayConnectionEvents.DISCONNECT.register { handler, server -> 175 + val player = (handler as? net.minecraft.server.network.ServerGamePacketListenerImpl)?.player 176 + if (player != null) { 177 + sessionSyncService.onPlayerLeave(player.uuid, "disconnected", server) 178 + } 179 + } 180 + logger.info("Session tracking events registered") 131 181 132 182 // Schedule periodic cleanup tasks 133 183 setupCleanupTasks() 134 184 135 185 // Register server lifecycle events 136 - ServerLifecycleEvents.SERVER_STOPPING.register { _ -> 137 - onServerStopping() 186 + ServerLifecycleEvents.SERVER_STOPPING.register { server -> 187 + onServerStopping(server) 138 188 } 139 189 140 190 logger.info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") ··· 146 196 logger.info(" ✓ Security audit logging") 147 197 logger.info(" ✓ Enhanced SSRF protection") 148 198 logger.info(" ✓ Automatic Minecraft stat syncing") 149 - logger.info(" ✓ Privacy controls (stats/sessions visibility)") 199 + logger.info(" ✓ Sync consent controls (stats/sessions)") 150 200 logger.info(" ✓ Player profile record management") 151 201 logger.info(" ✓ Achievement syncing to AT Protocol") 202 + logger.info(" ✓ Play session tracking") 203 + logger.info(" ✓ Server status snapshots") 152 204 logger.info("Players can use /atproto help to see available commands") 153 205 logger.info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") 154 206 } catch (e: Exception) { ··· 177 229 /** 178 230 * Called when the server is stopping. 179 231 */ 180 - private fun onServerStopping() { 232 + private fun onServerStopping(server: net.minecraft.server.MinecraftServer) { 181 233 logger.info("Server stopping, shutting down atproto-connect components") 182 - 234 + 183 235 try { 236 + // Flush open sessions before shutting down 237 + if (::sessionSyncService.isInitialized) { 238 + sessionSyncService.flushAllSessions(server) 239 + sessionSyncService.shutdown() 240 + } 241 + 242 + if (::serverStatusSyncService.isInitialized) { 243 + serverStatusSyncService.shutdown() 244 + } 245 + 184 246 if (::statSyncService.isInitialized) { 185 247 statSyncService.shutdown() 186 248 } ··· 201 263 } catch (e: Exception) { 202 264 logger.error("Error during shutdown", e) 203 265 } 204 - 266 + 205 267 logger.info("atproto-connect mod shut down successfully") 206 268 } 207 269
+217
src/main/kotlin/com/jollywhoppers/atproto/server/PlayerSessionSyncService.kt
··· 1 + package com.jollywhoppers.atproto.server 2 + 3 + import kotlinx.coroutines.CoroutineScope 4 + import kotlinx.coroutines.Dispatchers 5 + import kotlinx.coroutines.SupervisorJob 6 + import kotlinx.coroutines.cancel 7 + import kotlinx.coroutines.launch 8 + import kotlinx.serialization.SerialName 9 + import kotlinx.serialization.Serializable 10 + import net.minecraft.server.MinecraftServer 11 + import net.minecraft.server.level.ServerPlayer 12 + import org.slf4j.LoggerFactory 13 + import java.time.Instant 14 + import java.time.Duration 15 + import java.util.UUID 16 + import java.util.concurrent.ConcurrentHashMap 17 + 18 + /** 19 + * Tracks player play sessions and syncs them to AT Protocol records. 20 + * 21 + * When a player joins the server, we record the join time. 22 + * When they leave, we create a `com.jollywhoppers.minecraft.player.session` record 23 + * with the join time, leave time, duration, and quit reason. 24 + * 25 + * Sync consent: 26 + * - Checks `syncSessions` from PlayerIdentityStore before writing 27 + * - AT Protocol data is always public, so the real privacy control 28 + * is not writing data the user doesn't want published 29 + * 30 + * Edge cases: 31 + * - Server stopping while players are online: flush all open sessions 32 + * with quitReason = "server_stop" 33 + * - Player reconnecting quickly: each join/leave is a separate session 34 + */ 35 + class PlayerSessionSyncService( 36 + private val recordManager: RecordManager, 37 + private val sessionManager: AtProtoSessionManager, 38 + private val identityStore: PlayerIdentityStore, 39 + ) { 40 + private val logger = LoggerFactory.getLogger("atproto-connect:sessions") 41 + private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) 42 + 43 + // Track active sessions: UUID -> join timestamp 44 + private val activeSessions = ConcurrentHashMap<UUID, ActiveSession>() 45 + 46 + companion object { 47 + private const val COLLECTION_ID = "com.jollywhoppers.minecraft.player.session" 48 + } 49 + 50 + data class ActiveSession( 51 + val joinedAt: Instant, 52 + val serverId: String, 53 + val serverName: String, 54 + val serverAddress: String? = null, 55 + ) 56 + 57 + /** 58 + * Called when a player joins the server. 59 + * Records the join time for later session record creation. 60 + */ 61 + fun onPlayerJoin(player: ServerPlayer, server: MinecraftServer) { 62 + val uuid = player.uuid 63 + 64 + // If there's an existing unclosed session (shouldn't happen normally), 65 + // close it first 66 + if (activeSessions.containsKey(uuid)) { 67 + logger.warn("Player ${player.name.string} joined with an existing unclosed session; closing it") 68 + onPlayerLeave(uuid, "reconnected", server) 69 + } 70 + 71 + val serverId = buildServerId(server) 72 + val serverName = server.getMotd().ifBlank { "Minecraft Server" } 73 + val serverAddress = server.getLocalIp().takeIf { it.isNotBlank() }?.let { ip -> 74 + val port = server.getPort() 75 + if (port > 0) "$ip:$port" else ip 76 + } 77 + 78 + activeSessions[uuid] = ActiveSession( 79 + joinedAt = Instant.now(), 80 + serverId = serverId, 81 + serverName = serverName, 82 + serverAddress = serverAddress, 83 + ) 84 + 85 + logger.debug("Tracked session start for ${player.name.string} ($uuid)") 86 + } 87 + 88 + /** 89 + * Called when a player leaves the server. 90 + * Creates a session record if the player is linked and has consented. 91 + */ 92 + fun onPlayerLeave(uuid: UUID, quitReason: String, server: MinecraftServer) { 93 + val session = activeSessions.remove(uuid) ?: run { 94 + logger.debug("No active session for $uuid on leave") 95 + return 96 + } 97 + 98 + // Check if linked and authenticated 99 + if (!identityStore.isLinked(uuid) || !sessionManager.hasSession(uuid)) { 100 + logger.debug("Skipping session record for $uuid: not linked or not authenticated") 101 + return 102 + } 103 + 104 + // Check sync consent 105 + val syncConsent = identityStore.getSyncConsent(uuid) 106 + if (syncConsent != null && !syncConsent.second) { 107 + logger.debug("Skipping session record for $uuid: syncSessions consent is disabled") 108 + return 109 + } 110 + 111 + val leftAt = Instant.now() 112 + val durationMinutes = Duration.between(session.joinedAt, leftAt).toMinutes().toInt().coerceAtLeast(0) 113 + 114 + // Get player name from the identity store (player may have already disconnected) 115 + val identity = identityStore.getIdentity(uuid) 116 + val playerName = identity?.handle ?: uuid.toString().take(8) 117 + 118 + coroutineScope.launch { 119 + try { 120 + val record = MinecraftPlayerSessionRecord( 121 + player = PlayerReference( 122 + uuid = uuid.toString(), 123 + username = playerName, 124 + ), 125 + server = ServerReference( 126 + serverId = session.serverId, 127 + serverName = session.serverName, 128 + serverAddress = session.serverAddress, 129 + ), 130 + joinedAt = session.joinedAt.toString(), 131 + leftAt = leftAt.toString(), 132 + durationMinutes = durationMinutes, 133 + quitReason = normalizeQuitReason(quitReason), 134 + ) 135 + 136 + recordManager.createTypedRecord( 137 + playerUuid = uuid, 138 + collection = COLLECTION_ID, 139 + record = record, 140 + ).getOrThrow() 141 + 142 + logger.info("Synced session record for $playerName ($uuid): ${durationMinutes}min") 143 + } catch (e: Exception) { 144 + logger.error("Failed to sync session record for $uuid", e) 145 + } 146 + } 147 + } 148 + 149 + /** 150 + * Called when the server is stopping. 151 + * Flushes all open sessions with quitReason = "server_stop". 152 + */ 153 + fun flushAllSessions(server: MinecraftServer) { 154 + val openSessions = activeSessions.keys.toList() 155 + if (openSessions.isEmpty()) return 156 + 157 + logger.info("Flushing ${openSessions.size} open sessions on server stop") 158 + openSessions.forEach { uuid -> 159 + onPlayerLeave(uuid, "server_stop", server) 160 + } 161 + } 162 + 163 + /** 164 + * Clears tracking for a player (e.g., on unlink). 165 + */ 166 + fun clearPlayerTracking(uuid: UUID) { 167 + activeSessions.remove(uuid) 168 + } 169 + 170 + private fun normalizeQuitReason(reason: String): String { 171 + return when { 172 + reason.equals("server_stop", ignoreCase = true) -> "server_stop" 173 + reason.equals("reconnected", ignoreCase = true) -> "reconnected" 174 + reason.contains("kicked", ignoreCase = true) -> "kicked" 175 + reason.contains("timeout", ignoreCase = true) -> "timeout" 176 + else -> "disconnected" 177 + }.take(256) // Lexicon maxLength 178 + } 179 + 180 + private fun buildServerId(server: MinecraftServer): String { 181 + val serverPath = server.serverDirectory 182 + .toAbsolutePath() 183 + .normalize() 184 + .toString() 185 + val payload = "socialsync:$serverPath" 186 + val digest = java.security.MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8)) 187 + return digest.joinToString("") { byte -> "%02x".format(byte) } 188 + } 189 + 190 + fun shutdown() { 191 + coroutineScope.cancel() 192 + } 193 + 194 + @Serializable 195 + data class PlayerReference( 196 + val uuid: String, 197 + val username: String, 198 + ) 199 + 200 + @Serializable 201 + data class ServerReference( 202 + val serverId: String, 203 + val serverName: String, 204 + val serverAddress: String? = null, 205 + ) 206 + 207 + @Serializable 208 + data class MinecraftPlayerSessionRecord( 209 + @SerialName("\$type") val type: String = COLLECTION_ID, 210 + val player: PlayerReference, 211 + val server: ServerReference, 212 + val joinedAt: String, 213 + val leftAt: String? = null, 214 + val durationMinutes: Int? = null, 215 + val quitReason: String? = null, 216 + ) 217 + }