diff --git a/build.gradle.kts b/build.gradle.kts index 8922403..1ecf27f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,7 @@ plugins { kotlin("jvm") version "2.1.20-Beta2" 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" @@ -23,10 +23,16 @@ repositories { dependencies { compileOnly("org.spigotmc:spigot-api:1.21.4-R0.1-SNAPSHOT") 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") - implementation("com.squareup.okhttp3:okhttp:4.12.0") + + // These will be packaged into the shadow JAR 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 @@ -34,15 +40,26 @@ kotlin { jvmToolchain(targetJavaVersion) } -tasks.build { - dependsOn("shadowJar") -} +// Configure the shadowJar task +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 { - val props = mapOf("version" to version) - inputs.properties(props) - filteringCharset = "UTF-8" - filesMatching("plugin.yml") { - expand(props) + // Make “build” produce the shadow jar + build { + dependsOn(shadowJar) + } + + processResources { + val props = mapOf("version" to version) + inputs.properties(props) + filteringCharset = "UTF-8" + filesMatching("plugin.yml") { + expand(props) + } } } diff --git a/src/main/kotlin/dev/solsynth/snConnect/SolarNetworkConnect.kt b/src/main/kotlin/dev/solsynth/snConnect/SolarNetworkConnect.kt index 2ca7c73..f08f37b 100644 --- a/src/main/kotlin/dev/solsynth/snConnect/SolarNetworkConnect.kt +++ b/src/main/kotlin/dev/solsynth/snConnect/SolarNetworkConnect.kt @@ -2,15 +2,71 @@ package dev.solsynth.snConnect import dev.solsynth.snConnect.commands.SnCommand import dev.solsynth.snConnect.commands.SnCommandCompleter +import dev.solsynth.snConnect.models.* 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 org.bukkit.Bukkit +import org.bukkit.Bukkit.getOnlinePlayers +import org.bukkit.Color import org.bukkit.plugin.java.JavaPlugin class SolarNetworkConnect : JavaPlugin() { private var economy: Economy? = null private var sn: SnService? = null + private var syncChatRooms: List = 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() { 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 clientId = config.getString("sn.client_id") ?: 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; } diff --git a/src/main/kotlin/dev/solsynth/snConnect/WebSocketTester.kt b/src/main/kotlin/dev/solsynth/snConnect/WebSocketTester.kt new file mode 100644 index 0000000..4340da0 --- /dev/null +++ b/src/main/kotlin/dev/solsynth/snConnect/WebSocketTester.kt @@ -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) { + if (args.size < 4) { + println("Usage: WebSocketTester ") + 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") + } + } + } + } +} diff --git a/src/main/kotlin/dev/solsynth/snConnect/models/SnAccount.kt b/src/main/kotlin/dev/solsynth/snConnect/models/SnAccount.kt new file mode 100644 index 0000000..84ef26f --- /dev/null +++ b/src/main/kotlin/dev/solsynth/snConnect/models/SnAccount.kt @@ -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 = 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(jsonElement) + } + } +} diff --git a/src/main/kotlin/dev/solsynth/snConnect/models/SnAccountProfile.kt b/src/main/kotlin/dev/solsynth/snConnect/models/SnAccountProfile.kt new file mode 100644 index 0000000..13e6a56 --- /dev/null +++ b/src/main/kotlin/dev/solsynth/snConnect/models/SnAccountProfile.kt @@ -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(jsonElement) + } + } +} diff --git a/src/main/kotlin/dev/solsynth/snConnect/models/SnAccountStatus.kt b/src/main/kotlin/dev/solsynth/snConnect/models/SnAccountStatus.kt new file mode 100644 index 0000000..7a92918 --- /dev/null +++ b/src/main/kotlin/dev/solsynth/snConnect/models/SnAccountStatus.kt @@ -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(jsonElement) + } + } +} diff --git a/src/main/kotlin/dev/solsynth/snConnect/models/SnChatMember.kt b/src/main/kotlin/dev/solsynth/snConnect/models/SnChatMember.kt new file mode 100644 index 0000000..368481a --- /dev/null +++ b/src/main/kotlin/dev/solsynth/snConnect/models/SnChatMember.kt @@ -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?, +) diff --git a/src/main/kotlin/dev/solsynth/snConnect/models/SnChatMessage.kt b/src/main/kotlin/dev/solsynth/snConnect/models/SnChatMessage.kt new file mode 100644 index 0000000..3ccc465 --- /dev/null +++ b/src/main/kotlin/dev/solsynth/snConnect/models/SnChatMessage.kt @@ -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? = emptyList(), + @SerialName("edited_at") val editedAt: String?, + val attachments: List = emptyList(), + // val reactions: List = 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(jsonElement) + } + } +} diff --git a/src/main/kotlin/dev/solsynth/snConnect/models/SnChatReaction.kt b/src/main/kotlin/dev/solsynth/snConnect/models/SnChatReaction.kt new file mode 100644 index 0000000..10f8eb0 --- /dev/null +++ b/src/main/kotlin/dev/solsynth/snConnect/models/SnChatReaction.kt @@ -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?, +) diff --git a/src/main/kotlin/dev/solsynth/snConnect/models/SnChatRoom.kt b/src/main/kotlin/dev/solsynth/snConnect/models/SnChatRoom.kt new file mode 100644 index 0000000..b41ba37 --- /dev/null +++ b/src/main/kotlin/dev/solsynth/snConnect/models/SnChatRoom.kt @@ -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?, // Placeholder +) diff --git a/src/main/kotlin/dev/solsynth/snConnect/models/SnCloudFile.kt b/src/main/kotlin/dev/solsynth/snConnect/models/SnCloudFile.kt new file mode 100644 index 0000000..7f8a29d --- /dev/null +++ b/src/main/kotlin/dev/solsynth/snConnect/models/SnCloudFile.kt @@ -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(jsonElement) + } + } +} diff --git a/src/main/kotlin/dev/solsynth/snConnect/models/SnContactMethod.kt b/src/main/kotlin/dev/solsynth/snConnect/models/SnContactMethod.kt new file mode 100644 index 0000000..67574b8 --- /dev/null +++ b/src/main/kotlin/dev/solsynth/snConnect/models/SnContactMethod.kt @@ -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(jsonElement) + } + } +} diff --git a/src/main/kotlin/dev/solsynth/snConnect/models/SnWalletSubscriptionRef.kt b/src/main/kotlin/dev/solsynth/snConnect/models/SnWalletSubscriptionRef.kt new file mode 100644 index 0000000..07a97cb --- /dev/null +++ b/src/main/kotlin/dev/solsynth/snConnect/models/SnWalletSubscriptionRef.kt @@ -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(jsonElement) + } + } +} diff --git a/src/main/kotlin/dev/solsynth/snConnect/models/WebSocketPacket.kt b/src/main/kotlin/dev/solsynth/snConnect/models/WebSocketPacket.kt new file mode 100644 index 0000000..f92bb9b --- /dev/null +++ b/src/main/kotlin/dev/solsynth/snConnect/models/WebSocketPacket.kt @@ -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 + } + } + } +} diff --git a/src/main/kotlin/dev/solsynth/snConnect/services/SnService.kt b/src/main/kotlin/dev/solsynth/snConnect/services/SnService.kt index 1d934a8..f537f46 100644 --- a/src/main/kotlin/dev/solsynth/snConnect/services/SnService.kt +++ b/src/main/kotlin/dev/solsynth/snConnect/services/SnService.kt @@ -1,11 +1,73 @@ 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.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(); + private val logger = Logger.getLogger(SnService::class.java.name) fun getUrl(service: String, segment: String): String { - return "$baseUrl/cgi/$service$segment" + return "$baseUrl/$service$segment" } -} \ No newline at end of file + + 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 = 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") + } + } +} diff --git a/src/test/kotlin/dev/solsynth/snConnect/WebSocketTest.kt b/src/test/kotlin/dev/solsynth/snConnect/WebSocketTest.kt new file mode 100644 index 0000000..ec8a5a4 --- /dev/null +++ b/src/test/kotlin/dev/solsynth/snConnect/WebSocketTest.kt @@ -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() + 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 + } +}