diff options
| author | Sho Sakuma <me@m1sk9.dev> | 2026-06-17 16:15:40 +0900 |
|---|---|---|
| committer | Sho Sakuma <me@m1sk9.dev> | 2026-06-17 16:15:40 +0900 |
| commit | c678bd15324dad0519cb1d9a4c58202a981ee8e2 (patch) | |
| tree | 12e31ff3ba8b34fc95b1fe5b3279e02079b8c99b /platform-velocity | |
| parent | 7e06f2d77e121827e7eabdcb508deb04417b0860 (diff) | |
| download | LunaticChat-c678bd15324dad0519cb1d9a4c58202a981ee8e2.tar.gz LunaticChat-c678bd15324dad0519cb1d9a4c58202a981ee8e2.tar.bz2 LunaticChat-c678bd15324dad0519cb1d9a4c58202a981ee8e2.zip | |
feat: add cross-server direct messaging via Velocity
Allow /tell and /reply to reach players on other Paper servers behind a
Velocity proxy using the "<player>@<server>" target syntax.
Engine (protocol bumped 1.0.0 -> 1.0.1, optional sub-channels):
- Add DirectMessageRelay, DirectMessageError, PresenceSnapshot/PresenceEntry
and PresenceRequest messages plus codec branches.
Velocity:
- CrossServerDirectMessageRelay routes a DM to the target server (or returns
a delivery error to the source).
- PresenceTracker broadcasts proxy-wide presence snapshots on join/quit/switch
and on request.
Paper:
- RemotePlayerRegistry caches proxy presence for completion and remote target
resolution.
- CrossServerDirectMessageManager handles send/receive/error and dedup.
- DirectMessageHandler reply state generalized to ReplyTarget (Local/Remote)
so /reply works across servers.
- TellCommand parses "name@server", completes local names and remote
name@server targets, and uses exact local name matching.
- New crossServerDirectMessage config flag and i18n keys (en/ja).
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'platform-velocity')
6 files changed, 385 insertions, 3 deletions
diff --git a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/LunaticChat.kt b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/LunaticChat.kt index be5a4cd..df62b2f 100644 --- a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/LunaticChat.kt +++ b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/LunaticChat.kt @@ -8,7 +8,9 @@ import com.velocitypowered.api.plugin.Plugin import com.velocitypowered.api.plugin.PluginContainer import com.velocitypowered.api.proxy.ProxyServer import dev.m1sk9.lunaticChat.velocity.messaging.CrossServerChatRelay +import dev.m1sk9.lunaticChat.velocity.messaging.CrossServerDirectMessageRelay import dev.m1sk9.lunaticChat.velocity.messaging.PluginMessageHandler +import dev.m1sk9.lunaticChat.velocity.presence.PresenceTracker import org.slf4j.Logger /** @@ -36,6 +38,8 @@ class LunaticChat ) { private var messageHandler: PluginMessageHandler? = null private var crossServerChatRelay: CrossServerChatRelay? = null + private var crossServerDirectMessageRelay: CrossServerDirectMessageRelay? = null + private var presenceTracker: PresenceTracker? = null @Subscribe fun onProxyInitialization(event: ProxyInitializeEvent) { @@ -54,6 +58,22 @@ class LunaticChat logger = logger, ) + // Initialize cross-server direct message relay + crossServerDirectMessageRelay = + CrossServerDirectMessageRelay( + server = server, + logger = logger, + ) + + // Initialize presence tracker + presenceTracker = + PresenceTracker( + plugin = this@LunaticChat, + server = server, + logger = logger, + ) + presenceTracker?.initialize() + // Initialize plugin message handler messageHandler = PluginMessageHandler( @@ -62,6 +82,8 @@ class LunaticChat logger = logger, pluginVersion = pluginVersion, crossServerChatRelay = crossServerChatRelay!!, + crossServerDirectMessageRelay = crossServerDirectMessageRelay!!, + presenceTracker = presenceTracker!!, ) messageHandler?.initialize() @@ -72,5 +94,6 @@ class LunaticChat fun onProxyShutdown(event: ProxyShutdownEvent) { logger.info("Shutting down LunaticChat Velocity plugin") messageHandler?.shutdown() + presenceTracker?.shutdown() } } diff --git a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt new file mode 100644 index 0000000..89cc15f --- /dev/null +++ b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt @@ -0,0 +1,88 @@ +package dev.m1sk9.lunaticChat.velocity.messaging + +import com.velocitypowered.api.proxy.ProxyServer +import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier +import com.velocitypowered.api.proxy.server.RegisteredServer +import dev.m1sk9.lunaticChat.engine.protocol.PluginMessage +import dev.m1sk9.lunaticChat.engine.protocol.PluginMessageCodec +import org.slf4j.Logger + +/** + * Routes cross-server direct messages to a single target server. + * + * Unlike [CrossServerChatRelay] which broadcasts, this resolves the requested + * target server and player, then forwards the message only to that server. + * On failure it sends a [PluginMessage.DirectMessageError] back to the source. + */ +class CrossServerDirectMessageRelay( + private val server: ProxyServer, + private val logger: Logger, +) { + companion object { + private val CHANNEL = MinecraftChannelIdentifier.create("lunaticchat", "main") + } + + /** + * Relays a direct message to the target server, or returns an error to the source. + * + * @param message Direct message relay to route + * @param sourceServer The server that sent the message + */ + fun relay( + message: PluginMessage.DirectMessageRelay, + sourceServer: RegisteredServer, + ) { + try { + val targetServer = + server.allServers.firstOrNull { it.serverInfo.name == message.targetServerName } + if (targetServer == null) { + logger.info( + "Direct message target server not found: ${message.targetServerName} " + + "(messageId=${message.messageId})", + ) + sendError(sourceServer, message, PluginMessage.DirectMessageError.Reason.SERVER_NOT_FOUND) + return + } + + val targetPlayer = server.getPlayer(message.targetName).orElse(null) + val onTargetServer = + targetPlayer + ?.currentServer + ?.orElse(null) + ?.serverInfo + ?.name == message.targetServerName + if (targetPlayer == null || !onTargetServer) { + logger.info( + "Direct message target offline or on different server: ${message.targetName}@${message.targetServerName} " + + "(messageId=${message.messageId})", + ) + sendError(sourceServer, message, PluginMessage.DirectMessageError.Reason.TARGET_OFFLINE) + return + } + + targetServer.sendPluginMessage(CHANNEL, PluginMessageCodec.encode(message)) + logger.info( + "Relayed direct message from ${message.senderName}@${message.sourceServerName} " + + "to ${message.targetName}@${message.targetServerName} (messageId=${message.messageId})", + ) + } catch (e: Exception) { + logger.error("Failed to relay direct message: ${e.message}", e) + } + } + + private fun sendError( + sourceServer: RegisteredServer, + message: PluginMessage.DirectMessageRelay, + reason: String, + ) { + val error = + PluginMessage.DirectMessageError( + messageId = message.messageId, + senderId = message.senderId, + targetName = message.targetName, + targetServerName = message.targetServerName, + reason = reason, + ) + sourceServer.sendPluginMessage(CHANNEL, PluginMessageCodec.encode(error)) + } +} diff --git a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandler.kt b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandler.kt index 382eba4..4472d1c 100644 --- a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandler.kt +++ b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandler.kt @@ -8,6 +8,7 @@ import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier import dev.m1sk9.lunaticChat.engine.protocol.PluginMessage import dev.m1sk9.lunaticChat.engine.protocol.PluginMessageCodec import dev.m1sk9.lunaticChat.engine.protocol.ProtocolVersion +import dev.m1sk9.lunaticChat.velocity.presence.PresenceTracker import org.slf4j.Logger /** @@ -25,6 +26,8 @@ class PluginMessageHandler( private val logger: Logger, private val pluginVersion: String, private val crossServerChatRelay: CrossServerChatRelay, + private val crossServerDirectMessageRelay: CrossServerDirectMessageRelay, + private val presenceTracker: PresenceTracker, ) { companion object { private val CHANNEL = MinecraftChannelIdentifier.create("lunaticchat", "main") @@ -63,6 +66,12 @@ class PluginMessageHandler( is PluginMessage.GlobalChatMessage -> { handleGlobalChatMessage(source, message) } + is PluginMessage.DirectMessageRelay -> { + crossServerDirectMessageRelay.relay(message, source.server) + } + is PluginMessage.PresenceRequest -> { + presenceTracker.sendSnapshotTo(source.server) + } else -> { logger.warn("Unexpected message type from Paper: ${message::class.simpleName}") } diff --git a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/presence/PresenceTracker.kt b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/presence/PresenceTracker.kt new file mode 100644 index 0000000..f9442ee --- /dev/null +++ b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/presence/PresenceTracker.kt @@ -0,0 +1,98 @@ +package dev.m1sk9.lunaticChat.velocity.presence + +import com.velocitypowered.api.event.Subscribe +import com.velocitypowered.api.event.connection.DisconnectEvent +import com.velocitypowered.api.event.player.ServerPostConnectEvent +import com.velocitypowered.api.proxy.Player +import com.velocitypowered.api.proxy.ProxyServer +import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier +import com.velocitypowered.api.proxy.server.RegisteredServer +import dev.m1sk9.lunaticChat.engine.protocol.PluginMessage +import dev.m1sk9.lunaticChat.engine.protocol.PluginMessageCodec +import dev.m1sk9.lunaticChat.engine.protocol.PresenceEntry +import org.slf4j.Logger + +/** + * Tracks proxy-wide player presence and pushes snapshots to Paper servers. + * + * Velocity is the source of truth for presence. Paper servers keep a cache that + * is replaced wholesale on each [PluginMessage.PresenceSnapshot]. + */ +class PresenceTracker( + /** + * Plugin instance for event registration. Type is [Any] because Velocity's + * EventManager.register() accepts Object (mirrors [PluginMessageHandler]). + */ + private val plugin: Any, + private val server: ProxyServer, + private val logger: Logger, +) { + companion object { + private val CHANNEL = MinecraftChannelIdentifier.create("lunaticchat", "main") + } + + /** + * Registers presence event listeners. + */ + fun initialize() { + server.eventManager.register(plugin, this) + logger.info("Presence tracker registered") + } + + /** + * Broadcasts the current snapshot when a player joins or switches servers. + */ + @Subscribe + fun onServerPostConnect(event: ServerPostConnectEvent) { + broadcastSnapshot() + } + + /** + * Broadcasts the current snapshot when a player disconnects, excluding them. + */ + @Subscribe + fun onDisconnect(event: DisconnectEvent) { + broadcastSnapshot(exclude = event.player) + } + + /** + * Unregisters presence event listeners. + */ + fun shutdown() { + server.eventManager.unregisterListener(plugin, this) + logger.info("Presence tracker unregistered") + } + + /** + * Sends the current snapshot to a single server (initial sync on request). + */ + fun sendSnapshotTo(target: RegisteredServer) { + val data = PluginMessageCodec.encode(buildSnapshot()) + target.sendPluginMessage(CHANNEL, data) + } + + private fun broadcastSnapshot(exclude: Player? = null) { + try { + val data = PluginMessageCodec.encode(buildSnapshot(exclude)) + server.allServers.forEach { it.sendPluginMessage(CHANNEL, data) } + } catch (e: Exception) { + logger.error("Failed to broadcast presence snapshot: ${e.message}", e) + } + } + + private fun buildSnapshot(exclude: Player? = null): PluginMessage.PresenceSnapshot { + val entries = + server.allPlayers + .asSequence() + .filter { exclude == null || it.uniqueId != exclude.uniqueId } + .mapNotNull { player -> + val serverName = + player.currentServer + .orElse(null) + ?.serverInfo + ?.name ?: return@mapNotNull null + PresenceEntry(playerName = player.username, serverName = serverName) + }.toList() + return PluginMessage.PresenceSnapshot(players = entries) + } +} diff --git a/platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelayTest.kt b/platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelayTest.kt new file mode 100644 index 0000000..afbf6ca --- /dev/null +++ b/platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelayTest.kt @@ -0,0 +1,116 @@ +package dev.m1sk9.lunaticChat.velocity.messaging + +import com.velocitypowered.api.proxy.Player +import com.velocitypowered.api.proxy.ProxyServer +import com.velocitypowered.api.proxy.ServerConnection +import com.velocitypowered.api.proxy.messages.ChannelIdentifier +import com.velocitypowered.api.proxy.server.RegisteredServer +import com.velocitypowered.api.proxy.server.ServerInfo +import dev.m1sk9.lunaticChat.engine.protocol.PluginMessage +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.slf4j.Logger +import java.util.Optional +import kotlin.test.Test + +class CrossServerDirectMessageRelayTest { + private fun createRelay(): Pair<CrossServerDirectMessageRelay, ProxyServer> { + val server = mockk<ProxyServer>(relaxed = true) + val logger = mockk<Logger>(relaxed = true) + val relay = CrossServerDirectMessageRelay(server, logger) + return relay to server + } + + private fun createRegisteredServer(name: String): RegisteredServer { + val server = mockk<RegisteredServer>(relaxed = true) + val serverInfo = mockk<ServerInfo>(relaxed = true) + every { serverInfo.name } returns name + every { server.serverInfo } returns serverInfo + return server + } + + private fun createPlayer(currentServerName: String?): Player { + val player = mockk<Player>(relaxed = true) + if (currentServerName == null) { + every { player.currentServer } returns Optional.empty() + } else { + val connection = mockk<ServerConnection>(relaxed = true) + val info = mockk<ServerInfo>(relaxed = true) + every { info.name } returns currentServerName + every { connection.serverInfo } returns info + every { player.currentServer } returns Optional.of(connection) + } + return player + } + + private fun createMessage( + targetName: String = "Recipient", + targetServerName: String = "survival", + ): PluginMessage.DirectMessageRelay = + PluginMessage.DirectMessageRelay( + messageId = "dm-1", + sourceServerName = "lobby", + senderId = "00000001-0000-0000-0000-000000000000", + senderName = "Sender", + targetServerName = targetServerName, + targetName = targetName, + message = "Hello!", + timestamp = 1000L, + ) + + @Test + fun `relay should forward to target server only`() { + val (relay, proxyServer) = createRelay() + val sourceServer = createRegisteredServer("lobby") + val targetServer = createRegisteredServer("survival") + every { proxyServer.allServers } returns listOf(sourceServer, targetServer) + every { proxyServer.getPlayer("Recipient") } returns Optional.of(createPlayer("survival")) + + relay.relay(createMessage(), sourceServer) + + verify(exactly = 1) { targetServer.sendPluginMessage(any<ChannelIdentifier>(), any<ByteArray>()) } + verify(exactly = 0) { sourceServer.sendPluginMessage(any<ChannelIdentifier>(), any<ByteArray>()) } + } + + @Test + fun `relay should return SERVER_NOT_FOUND error when target server missing`() { + val (relay, proxyServer) = createRelay() + val sourceServer = createRegisteredServer("lobby") + every { proxyServer.allServers } returns listOf(sourceServer) + + relay.relay(createMessage(targetServerName = "ghost"), sourceServer) + + // Error returned to source; nothing relayed to a target + verify(exactly = 1) { sourceServer.sendPluginMessage(any<ChannelIdentifier>(), any<ByteArray>()) } + } + + @Test + fun `relay should return TARGET_OFFLINE error when player not online`() { + val (relay, proxyServer) = createRelay() + val sourceServer = createRegisteredServer("lobby") + val targetServer = createRegisteredServer("survival") + every { proxyServer.allServers } returns listOf(sourceServer, targetServer) + every { proxyServer.getPlayer("Recipient") } returns Optional.empty() + + relay.relay(createMessage(), sourceServer) + + verify(exactly = 1) { sourceServer.sendPluginMessage(any<ChannelIdentifier>(), any<ByteArray>()) } + verify(exactly = 0) { targetServer.sendPluginMessage(any<ChannelIdentifier>(), any<ByteArray>()) } + } + + @Test + fun `relay should return TARGET_OFFLINE error when player on a different server`() { + val (relay, proxyServer) = createRelay() + val sourceServer = createRegisteredServer("lobby") + val targetServer = createRegisteredServer("survival") + every { proxyServer.allServers } returns listOf(sourceServer, targetServer) + // Player exists but is connected to "creative", not the requested "survival" + every { proxyServer.getPlayer("Recipient") } returns Optional.of(createPlayer("creative")) + + relay.relay(createMessage(), sourceServer) + + verify(exactly = 1) { sourceServer.sendPluginMessage(any<ChannelIdentifier>(), any<ByteArray>()) } + verify(exactly = 0) { targetServer.sendPluginMessage(any<ChannelIdentifier>(), any<ByteArray>()) } + } +} diff --git a/platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandlerTest.kt b/platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandlerTest.kt index 3342331..ed564eb 100644 --- a/platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandlerTest.kt +++ b/platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandlerTest.kt @@ -12,6 +12,7 @@ import com.velocitypowered.api.proxy.server.ServerInfo import dev.m1sk9.lunaticChat.engine.protocol.PluginMessage import dev.m1sk9.lunaticChat.engine.protocol.PluginMessageCodec import dev.m1sk9.lunaticChat.engine.protocol.ProtocolVersion +import dev.m1sk9.lunaticChat.velocity.presence.PresenceTracker import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -21,14 +22,24 @@ import kotlin.test.Test class PluginMessageHandlerTest { private val channel = MinecraftChannelIdentifier.create("lunaticchat", "main") - private fun createHandler(pluginVersion: String = "0.10.0"): Triple<PluginMessageHandler, ProxyServer, CrossServerChatRelay> { + private data class Handlers( + val handler: PluginMessageHandler, + val server: ProxyServer, + val relay: CrossServerChatRelay, + val dmRelay: CrossServerDirectMessageRelay, + val presenceTracker: PresenceTracker, + ) + + private fun createHandler(pluginVersion: String = "0.10.0"): Handlers { val plugin = Any() val server = mockk<ProxyServer>(relaxed = true) val logger = mockk<Logger>(relaxed = true) val relay = mockk<CrossServerChatRelay>(relaxed = true) + val dmRelay = mockk<CrossServerDirectMessageRelay>(relaxed = true) + val presenceTracker = mockk<PresenceTracker>(relaxed = true) - val handler = PluginMessageHandler(plugin, server, logger, pluginVersion, relay) - return Triple(handler, server, relay) + val handler = PluginMessageHandler(plugin, server, logger, pluginVersion, relay, dmRelay, presenceTracker) + return Handlers(handler, server, relay, dmRelay, presenceTracker) } private fun createServerConnection(serverName: String = "lobby"): ServerConnection { @@ -145,6 +156,43 @@ class PluginMessageHandlerTest { } @Test + fun `onPluginMessage should relay direct message`() { + val handlers = createHandler() + val connection = createServerConnection() + + val dm = + PluginMessage.DirectMessageRelay( + messageId = "dm-1", + sourceServerName = "lobby", + senderId = "00000001-0000-0000-0000-000000000000", + senderName = "Sender", + targetServerName = "survival", + targetName = "Recipient", + message = "Hi!", + timestamp = 1000L, + ) + val data = PluginMessageCodec.encode(dm) + val event = createPluginMessageEvent(connection, mockk(relaxed = true), channel, data) + + handlers.handler.onPluginMessage(event) + + verify { handlers.dmRelay.relay(any<PluginMessage.DirectMessageRelay>(), any<RegisteredServer>()) } + } + + @Test + fun `onPluginMessage should send presence snapshot on request`() { + val handlers = createHandler() + val connection = createServerConnection() + + val data = PluginMessageCodec.encode(PluginMessage.PresenceRequest) + val event = createPluginMessageEvent(connection, mockk(relaxed = true), channel, data) + + handlers.handler.onPluginMessage(event) + + verify { handlers.presenceTracker.sendSnapshotTo(any<RegisteredServer>()) } + } + + @Test fun `onPluginMessage should ignore messages from wrong channel`() { val (handler, _, relay) = createHandler() val connection = createServerConnection() |
