Sync chat from SN

This commit is contained in:
2025-10-05 02:42:21 +08:00
parent affe9a40f5
commit e77841fc09
16 changed files with 580 additions and 17 deletions

View File

@@ -1,7 +1,7 @@
plugins { plugins {
kotlin("jvm") version "2.1.20-Beta2" kotlin("jvm") version "2.1.20-Beta2"
kotlin("plugin.serialization") version "2.1.10" kotlin("plugin.serialization") version "2.1.10"
id("com.github.johnrengelman.shadow") version "8.1.1" id("com.github.johnrengelman.shadow") version "8.1.1" // add shadow plugin
} }
group = "dev.solsynth" group = "dev.solsynth"
@@ -23,10 +23,16 @@ repositories {
dependencies { dependencies {
compileOnly("org.spigotmc:spigot-api:1.21.4-R0.1-SNAPSHOT") compileOnly("org.spigotmc:spigot-api:1.21.4-R0.1-SNAPSHOT")
compileOnly("com.github.MilkBowl:VaultAPI:1.7") compileOnly("com.github.MilkBowl:VaultAPI:1.7")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") // These will be packaged into the shadow JAR
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// Test dependencies
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
} }
val targetJavaVersion = 21 val targetJavaVersion = 21
@@ -34,11 +40,21 @@ kotlin {
jvmToolchain(targetJavaVersion) jvmToolchain(targetJavaVersion)
} }
tasks.build { // Configure the shadowJar task
dependsOn("shadowJar") tasks {
shadowJar {
archiveClassifier.set("") // so that the shadow JAR replaces the “normal” JAR
mergeServiceFiles()
// Optionally relocate packages to avoid conflicts with other plugins
// e.g. relocate("kotlin", "dev.solsynth.shadow.kotlin")
} }
tasks.processResources { // Make “build” produce the shadow jar
build {
dependsOn(shadowJar)
}
processResources {
val props = mapOf("version" to version) val props = mapOf("version" to version)
inputs.properties(props) inputs.properties(props)
filteringCharset = "UTF-8" filteringCharset = "UTF-8"
@@ -46,3 +62,4 @@ tasks.processResources {
expand(props) expand(props)
} }
} }
}

View File

@@ -2,15 +2,71 @@ package dev.solsynth.snConnect
import dev.solsynth.snConnect.commands.SnCommand import dev.solsynth.snConnect.commands.SnCommand
import dev.solsynth.snConnect.commands.SnCommandCompleter import dev.solsynth.snConnect.commands.SnCommandCompleter
import dev.solsynth.snConnect.models.*
import dev.solsynth.snConnect.services.SnService import dev.solsynth.snConnect.services.SnService
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import net.md_5.bungee.api.ChatColor
import net.md_5.bungee.api.chat.BaseComponent
import net.md_5.bungee.api.chat.ClickEvent
import net.md_5.bungee.api.chat.ComponentBuilder
import net.md_5.bungee.api.chat.HoverEvent
import net.md_5.bungee.api.chat.TextComponent
import net.milkbowl.vault.economy.Economy import net.milkbowl.vault.economy.Economy
import org.bukkit.Bukkit import org.bukkit.Bukkit
import org.bukkit.Bukkit.getOnlinePlayers
import org.bukkit.Color
import org.bukkit.plugin.java.JavaPlugin import org.bukkit.plugin.java.JavaPlugin
class SolarNetworkConnect : JavaPlugin() { class SolarNetworkConnect : JavaPlugin() {
private var economy: Economy? = null private var economy: Economy? = null
private var sn: SnService? = null private var sn: SnService? = null
private var syncChatRooms: List<String> = emptyList()
private fun handleWebSocketPacket(packet: WebSocketPacket) {
logger.info("Received WebSocket packet: type=${packet.type}")
if (packet.type.startsWith("messages") && packet.data != null) {
try {
when (packet.type) {
"messages.new" -> {
val message = SnChatMessage.fromJson(packet.data)
if (syncChatRooms.isEmpty() || syncChatRooms.contains(message.chatRoomId)) {
val roomName = message.chatRoom.name ?: "DM"
val senderName = message.sender.account.nick
val profileUrl = "https://solian.app/@${message.sender.account.name}"
val componentBuilder = ComponentBuilder()
.append("").color(ChatColor.YELLOW)
.event(HoverEvent(HoverEvent.Action.SHOW_TEXT, arrayOf(TextComponent("Solar Network"))))
.append(" ")
.append(roomName).color(ChatColor.GOLD)
.append(" ").color(ChatColor.YELLOW)
.append(senderName).color(ChatColor.YELLOW)
.event(ClickEvent(ClickEvent.Action.OPEN_URL, profileUrl))
.append(" » ").color(ChatColor.GRAY)
if (message.content != null && message.content.isNotBlank()) {
componentBuilder.append(message.content).color(ChatColor.WHITE)
}
if (message.attachments.isNotEmpty()) {
for (attachment in message.attachments) {
val fileName = attachment.name.takeIf { it.isNotBlank() } ?: "attachment"
val url = "https://solian.app/files/${attachment.id}"
componentBuilder.append(" <$fileName>").color(ChatColor.BLUE)
.event(ClickEvent(ClickEvent.Action.OPEN_URL, url))
}
}
val component = componentBuilder.create()
for (player in getOnlinePlayers()) {
player.spigot().sendMessage(*component)
}
}
}
}
} catch (e: Exception) {
logger.warning("Failed to parse chat message: ${e.message}")
}
}
}
override fun onEnable() { override fun onEnable() {
logger.info(String.format("Enabling Version %s", description.version)); logger.info(String.format("Enabling Version %s", description.version));
@@ -45,7 +101,15 @@ class SolarNetworkConnect : JavaPlugin() {
val baseUrl = config.getString("sn.endpoint") ?: return false; val baseUrl = config.getString("sn.endpoint") ?: return false;
val clientId = config.getString("sn.client_id") ?: return false; val clientId = config.getString("sn.client_id") ?: return false;
val clientSecret = config.getString("sn.client_secret") ?: return false; val clientSecret = config.getString("sn.client_secret") ?: return false;
sn = SnService(baseUrl, clientId, clientSecret); val botApiKey = config.getString("sn.bot_secret");
val syncRooms = config.getStringList("chat.sync_chat_rooms")
syncChatRooms = syncRooms
sn = SnService(baseUrl, clientId, clientSecret, botApiKey);
GlobalScope.launch {
sn!!.connectWebSocketAsFlow().collect { packet ->
handleWebSocketPacket(packet)
}
}
return true; return true;
} }

View File

@@ -0,0 +1,39 @@
package dev.solsynth.snConnect
import dev.solsynth.snConnect.services.SnService
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
class WebSocketTester {
companion object {
@JvmStatic
fun main(args: Array<String>) {
if (args.size < 4) {
println("Usage: WebSocketTester <baseUrl> <clientId> <clientSecret> <botApiKey>")
return
}
val baseUrl = args[0]
val clientId = args[1]
val clientSecret = args[2]
val botApiKey = if (args[3] == "null") null else args[3]
runBlocking {
val service = SnService(baseUrl, clientId, clientSecret, botApiKey)
println("Starting WebSocket test for $baseUrl")
val result = withTimeoutOrNull(30000L) { // 30 seconds timeout
service.connectWebSocketAsFlow().collect { packet ->
println("Received packet: type=${packet.type}, endpoint=${packet.endpoint}, data=${packet.data}")
}
}
if (result == null) {
println("WebSocket test timed out")
} else {
println("WebSocket test completed")
}
}
}
}
}

View File

@@ -0,0 +1,37 @@
import dev.solsynth.snConnect.models.SnAccountProfile
import dev.solsynth.snConnect.models.SnContactMethod
import dev.solsynth.snConnect.models.SnWalletSubscriptionRef
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
import kotlinx.serialization.json.decodeFromJsonElement
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
@Serializable
data class SnAccount(
val id: String,
val name: String,
val nick: String,
val language: String,
val region: String = "",
@SerialName("is_superuser") val isSuperuser: Boolean,
@SerialName("automated_id") val automatedId: String?,
val profile: SnAccountProfile,
@SerialName("perk_subscription") val perkSubscription: SnWalletSubscriptionRef?,
val contacts: List<SnContactMethod> = emptyList(),
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String,
@SerialName("deleted_at") val deletedAt: String?,
) {
companion object {
private val json = Json { ignoreUnknownKeys = true }
fun fromJson(jsonElement: JsonElement): SnAccount {
return json.decodeFromJsonElement<SnAccount>(jsonElement)
}
}
}

View File

@@ -0,0 +1,44 @@
package dev.solsynth.snConnect.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
import kotlinx.serialization.json.decodeFromJsonElement
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
@Serializable
data class SnAccountProfile(
val id: String,
@SerialName("first_name") val firstName: String = "",
@SerialName("middle_name") val middleName: String = "",
@SerialName("last_name") val lastName: String = "",
val bio: String = "",
val gender: String = "",
val pronouns: String = "",
val location: String = "",
@SerialName("time_zone") val timeZone: String = "",
val birthday: String?,
@SerialName("last_seen_at") val lastSeenAt: String?,
val experience: Int,
val level: Int,
@SerialName("social_credits") val socialCredits: Double = 100.0,
@SerialName("social_credits_level") val socialCreditsLevel: Int = 0,
@SerialName("leveling_progress") val levelingProgress: Double,
val picture: SnCloudFile?,
val background: SnCloudFile?,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String,
@SerialName("deleted_at") val deletedAt: String?,
) {
companion object {
private val json = Json { ignoreUnknownKeys = true }
fun fromJson(jsonElement: JsonElement): SnAccountProfile {
return json.decodeFromJsonElement<SnAccountProfile>(jsonElement)
}
}
}

View File

@@ -0,0 +1,36 @@
package dev.solsynth.snConnect.models
import kotlinx.serialization.Contextual
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
import kotlinx.serialization.json.decodeFromJsonElement
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
@Serializable
data class SnAccountStatus(
val id: String,
val attitude: Int,
@SerialName("is_online") val isOnline: Boolean,
@SerialName("is_invisible") val isInvisible: Boolean,
@SerialName("is_not_disturb") val isNotDisturb: Boolean,
@SerialName("is_customized") val isCustomized: Boolean,
val label: String = "",
@SerialName("cleared_at") val clearedAt: String?,
@SerialName("account_id") val accountId: String,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String,
@SerialName("deleted_at") val deletedAt: String?,
) {
companion object {
private val json = Json { ignoreUnknownKeys = true }
fun fromJson(jsonElement: JsonElement): SnAccountStatus {
return json.decodeFromJsonElement<SnAccountStatus>(jsonElement)
}
}
}

View File

@@ -0,0 +1,29 @@
package dev.solsynth.snConnect.models
import SnAccount
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
@Serializable
data class SnChatMember(
val id: String,
@SerialName("chat_room_id") val chatRoomId: String,
// val chatRoom: SnChatRoom?, // Placeholder
@SerialName("account_id") val accountId: String,
val account: SnAccount, // Placeholder
val nick: String?,
val role: Int,
val notify: Int,
@SerialName("joined_at") val joinedAt: String?,
@SerialName("break_until") val breakUntil: String? = null,
@SerialName("timeout_until") val timeoutUntil: String? = null,
@SerialName("is_bot") val isBot: Boolean,
// val status: SnAccountStatus?, // Placeholder
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String,
@SerialName("deleted_at") val deletedAt: String?,
)

View File

@@ -0,0 +1,40 @@
package dev.solsynth.snConnect.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
import kotlinx.serialization.json.decodeFromJsonElement
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
@Serializable
data class SnChatMessage(
val id: String,
val type: String = "text",
val content: String?,
val nonce: String?,
@SerialName("members_mentioned") val membersMentioned: List<String>? = emptyList(),
@SerialName("edited_at") val editedAt: String?,
val attachments: List<SnCloudFile> = emptyList(),
// val reactions: List<SnChatReaction> = emptyList(), // Placeholder
@SerialName("replied_message_id") val repliedMessageId: String?,
@SerialName("forwarded_message_id") val forwardedMessageId: String?,
@SerialName("sender_id") val senderId: String,
val sender: SnChatMember,
@SerialName("chat_room") val chatRoom: SnChatRoom,
@SerialName("chat_room_id") val chatRoomId: String,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String,
@SerialName("deleted_at") val deletedAt: String?,
) {
companion object {
private val json = Json { ignoreUnknownKeys = true }
fun fromJson(jsonElement: JsonElement): SnChatMessage {
return json.decodeFromJsonElement<SnChatMessage>(jsonElement)
}
}
}

View File

@@ -0,0 +1,21 @@
package dev.solsynth.snConnect.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
@Serializable
data class SnChatReaction(
val id: String,
@SerialName("message_id") val messageId: String,
@SerialName("sender_id") val senderId: String,
// val sender: SnChatMember, // Placeholder
val symbol: String,
val attitude: Int,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String,
@SerialName("deleted_at") val deletedAt: String?,
)

View File

@@ -0,0 +1,26 @@
package dev.solsynth.snConnect.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
@Serializable
data class SnChatRoom(
val id: String,
val name: String?,
val description: String?,
val type: Int,
@SerialName("is_public") val isPublic: Boolean = false,
@SerialName("is_community") val isCommunity: Boolean = false,
// val picture: SnCloudFile?, // Placeholder
// val background: SnCloudFile?, // Placeholder
val realmId: String? = null,
// val realm: SnRealm?, // Placeholder
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String,
@SerialName("deleted_at") val deletedAt: String?,
// val members: List<SnChatMember>?, // Placeholder
)

View File

@@ -0,0 +1,22 @@
package dev.solsynth.snConnect.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
@Serializable
data class SnCloudFile(
val id: String,
val name: String,
val size: Long,
) {
companion object {
private val json = Json { ignoreUnknownKeys = true }
fun fromJson(jsonElement: JsonElement): SnCloudFile {
return json.decodeFromJsonElement<SnCloudFile>(jsonElement)
}
}
}

View File

@@ -0,0 +1,33 @@
package dev.solsynth.snConnect.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
import kotlinx.serialization.json.decodeFromJsonElement
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
@Serializable
data class SnContactMethod(
val id: String,
val type: Int,
@SerialName("verified_at") val verifiedAt: String?,
@SerialName("is_primary") val isPrimary: Boolean,
@SerialName("is_public") val isPublic: Boolean,
val content: String,
@SerialName("account_id") val accountId: String,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String,
@SerialName("deleted_at") val deletedAt: String?,
) {
companion object {
private val json = Json { ignoreUnknownKeys = true }
fun fromJson(jsonElement: JsonElement): SnContactMethod {
return json.decodeFromJsonElement<SnContactMethod>(jsonElement)
}
}
}

View File

@@ -0,0 +1,26 @@
package dev.solsynth.snConnect.models
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
@Serializable
data class SnWalletSubscriptionRef(
val id: String,
val identifier: String,
@SerialName("account_id") val accountId: String,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String,
@SerialName("deleted_at") val deletedAt: String?,
) {
companion object {
private val json = Json { ignoreUnknownKeys = true }
fun fromJson(jsonElement: JsonElement): SnWalletSubscriptionRef {
return json.decodeFromJsonElement<SnWalletSubscriptionRef>(jsonElement)
}
}
}

View File

@@ -0,0 +1,30 @@
package dev.solsynth.snConnect.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
@Serializable
data class WebSocketPacket(
val type: String,
@SerialName("data") val data: JsonElement?,
val endpoint: String?,
@SerialName("error_message") val errorMessage: String?
) {
companion object {
private val json = Json { ignoreUnknownKeys = true }
fun fromJson(jsonString: String): WebSocketPacket? {
return try {
json.decodeFromString(jsonString)
} catch (e: Exception) {
null
}
}
}
}

View File

@@ -1,11 +1,73 @@
package dev.solsynth.snConnect.services package dev.solsynth.snConnect.services
import dev.solsynth.snConnect.models.WebSocketPacket
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.Response
import okio.ByteString
import java.util.logging.Logger
class SnService(private val baseUrl: String, val clientId: String, val clientSecret: String) { class SnService(private val baseUrl: String, val clientId: String, val clientSecret: String, private val botApiKey: String?) {
val client = OkHttpClient.Builder().build(); val client = OkHttpClient.Builder().build();
private val logger = Logger.getLogger(SnService::class.java.name)
fun getUrl(service: String, segment: String): String { fun getUrl(service: String, segment: String): String {
return "$baseUrl/cgi/$service$segment" return "$baseUrl/$service$segment"
}
fun getWsBaseUrl(): String {
return baseUrl.replaceFirst("http", "ws")
}
fun connectWebSocket(listener: WebSocketListener): WebSocket {
val request = Request.Builder()
.url("${getWsBaseUrl()}/ws")
.apply {
botApiKey?.let { header("Authorization", "Bearer $it") }
}
.build()
return client.newWebSocket(request, listener)
}
fun connectWebSocketAsFlow(): Flow<WebSocketPacket> = callbackFlow {
val url = "${getWsBaseUrl()}/ws";
val request = Request.Builder()
.url(url)
.apply {
botApiKey?.let { header("Authorization", "Bearer $it") }
}
.build()
val websocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
logger.info("WebSocket connection opened to $url")
}
override fun onMessage(webSocket: WebSocket, text: String) {
WebSocketPacket.fromJson(text)?.let { trySend(it) }
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
val text = bytes.string(Charsets.UTF_8)
WebSocketPacket.fromJson(text)?.let { trySend(it) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
logger.severe("WebSocket connection failed: ${t.message}, response: ${response?.code}")
close(t)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
logger.info("WebSocket connection closed: code=$code, reason=$reason")
close()
}
})
awaitClose {
websocket.close(1000, "Flow closed")
}
} }
} }

View File

@@ -0,0 +1,37 @@
package dev.solsynth.snConnect
import dev.solsynth.snConnect.services.SnService
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.jupiter.api.Test
import java.util.logging.Logger
class WebSocketTest {
private val logger = Logger.getLogger(WebSocketTest::class.java.name)
@Test
fun testWebSocketConnection() = runBlocking {
val service = SnService(
baseUrl = "http://localhost:8080", // Replace with actual test server URL
clientId = "testClientId",
clientSecret = "testClientSecret",
botApiKey = "testBotApiKey"
)
// Collect from the flow for a limited time to avoid hanging on failure
val packets = withTimeoutOrNull(10000L) { // 10 seconds timeout
val collectedPackets = mutableListOf<String>()
service.connectWebSocketAsFlow().collect { packet ->
collectedPackets.add("Received packet: type=${packet.type}, endpoint=${packet.endpoint}")
logger.info(collectedPackets.last())
// In a real test, you might check packet contents or behavior
}
collectedPackets
}
// For this test, we're mainly checking that the flow can be created and the setup doesn't crash
// In real scenarios, you'd need a WebSocket server running at the specified URL
logger.info("WebSocket test finished. Packets received: ${packets?.size ?: 0}")
assert(packets != null) // Flow started without immediate error
}
}