summaryrefslogtreecommitdiff
path: root/platform-velocity
diff options
context:
space:
mode:
authorSho Sakuma <me@m1sk9.dev>2026-06-17 16:15:40 +0900
committerSho Sakuma <me@m1sk9.dev>2026-06-17 16:15:40 +0900
commitc678bd15324dad0519cb1d9a4c58202a981ee8e2 (patch)
tree12e31ff3ba8b34fc95b1fe5b3279e02079b8c99b /platform-velocity
parent7e06f2d77e121827e7eabdcb508deb04417b0860 (diff)
downloadLunaticChat-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')
-rw-r--r--platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/LunaticChat.kt23
-rw-r--r--platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt88
-rw-r--r--platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandler.kt9
-rw-r--r--platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/presence/PresenceTracker.kt98
-rw-r--r--platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelayTest.kt116
-rw-r--r--platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandlerTest.kt54
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()