Compare commits

..

10 Commits

Author SHA1 Message Date
c2e7f613c7 Server start message, and the stop one 2025-10-05 12:13:00 +08:00
bc23269778 🚀 Launch 1.0 2025-10-05 03:27:33 +08:00
8ef05de8ad 🚀 Finishing up the Chat Sync 2025-10-05 03:14:20 +08:00
6303d44ab4 Sync message to SN 2025-10-05 03:06:23 +08:00
e77841fc09 Sync chat from SN 2025-10-05 02:42:21 +08:00
affe9a40f5 🐛 Fix can repeat using a same order 2025-02-03 13:24:55 +08:00
100d8bc747 🚀 Launch 1.0 2025-02-03 12:59:31 +08:00
5bdf6e07a7 Error handing 2025-02-03 12:54:45 +08:00
582c0c45b2 Withdraw money 2025-02-03 12:46:37 +08:00
d89cd6f9ce Deposit money 2025-02-03 12:06:14 +08:00
21 changed files with 946 additions and 31 deletions

View File

@@ -1,11 +1,11 @@
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"
version = "1.0-SNAPSHOT" version = "1.0"
repositories { repositories {
mavenCentral() mavenCentral()
@@ -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,21 +2,94 @@ 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.listeners.SnChatListener
import dev.solsynth.snConnect.models.SnChatMessage
import dev.solsynth.snConnect.models.WebSocketPacket
import dev.solsynth.snConnect.services.SnMessageService
import dev.solsynth.snConnect.services.SnService import dev.solsynth.snConnect.services.SnService
import kotlinx.coroutines.*
import net.md_5.bungee.api.ChatColor
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.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 messageService: SnMessageService? = null
private var syncChatRooms: List<String> = emptyList()
private var destinationChatId: String? = null
private var webSocketJob: Job? = null
private var messages: Map<String, String> = mapOf()
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)
// Ignore automated accounts
if (message.sender.account.automatedId.isNullOrBlank().not()) return;
// Only some rooms got synced
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));
this.saveDefaultConfig() this.saveDefaultConfig()
messages = mapOf(
"join" to "➡️ {player} joined the game.",
"joinFirst" to "➡️ {player} first time joined the game.",
"quit" to "⬅️ {player} left the game.",
"death" to "💀 {player} {message}",
"advancement" to "🎉 {player} unlocked advancement: {advancement}",
"serverStart" to "🚀 Server started successfully",
"serverStop" to "⏹️ Server stopped"
) + (config.getConfigurationSection("messages")?.getValues(false) as? Map<String, String> ?: emptyMap())
if (!setupNetwork()) { if (!setupNetwork()) {
logger.severe("Failed to setup Solar Network Network, check your configuration.") logger.severe("Failed to setup Solar Network Network, check your configuration.")
} }
@@ -26,7 +99,11 @@ class SolarNetworkConnect : JavaPlugin() {
) )
} }
Bukkit.getPluginCommand("solar")!!.setExecutor(SnCommand(this.sn!!)) if (messageService != null && destinationChatId != null) {
server.pluginManager.registerEvents(SnChatListener(messageService!!, destinationChatId!!, messages), this)
}
Bukkit.getPluginCommand("solar")!!.setExecutor(SnCommand(this.sn!!, this.economy))
Bukkit.getPluginCommand("solar")!!.tabCompleter = SnCommandCompleter() Bukkit.getPluginCommand("solar")!!.tabCompleter = SnCommandCompleter()
logger.info( logger.info(
@@ -35,17 +112,41 @@ class SolarNetworkConnect : JavaPlugin() {
config.getString("sn.endpoint") config.getString("sn.endpoint")
) )
); );
// Send server start message
destinationChatId?.let { chatId ->
messageService?.sendMessage(chatId, messages["serverStart"] ?: "🚀 Server started successfully")
}
} }
override fun onDisable() { override fun onDisable() {
logger.info(String.format("Disabled Version %s", description.version)); logger.info(String.format("Disabled Version %s", description.version));
// Send server stop message
destinationChatId?.let { chatId ->
messageService?.sendMessage(chatId, messages["serverStop"] ?: "⏹️ Server stopped")
}
sn?.disconnect()
webSocketJob?.cancel()
} }
private fun setupNetwork(): Boolean { private fun setupNetwork(): Boolean {
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 destination = config.getString("chat.outgoing_room") ?: return false;
val syncRooms = config.getStringList("chat.sync_rooms")
syncChatRooms = syncRooms
destinationChatId = destination
sn = SnService(baseUrl, clientId, clientSecret, botApiKey);
messageService = SnMessageService(sn!!)
webSocketJob = GlobalScope.launch {
sn!!.connectWebSocketAsFlow().collect { packet ->
handleWebSocketPacket(packet)
}
}
return true; return true;
} }

View File

@@ -1,17 +1,19 @@
package dev.solsynth.snConnect.commands package dev.solsynth.snConnect.commands
import dev.solsynth.snConnect.services.SnOrderService import dev.solsynth.snConnect.services.*
import dev.solsynth.snConnect.services.SnService
import net.md_5.bungee.api.chat.ClickEvent import net.md_5.bungee.api.chat.ClickEvent
import net.md_5.bungee.api.chat.TextComponent import net.md_5.bungee.api.chat.TextComponent
import net.milkbowl.vault.economy.Economy
import net.milkbowl.vault.economy.EconomyResponse
import org.bukkit.ChatColor import org.bukkit.ChatColor
import org.bukkit.Sound
import org.bukkit.command.Command import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender import org.bukkit.command.CommandSender
import org.bukkit.command.TabCompleter import org.bukkit.command.TabCompleter
import org.bukkit.entity.Player import org.bukkit.entity.Player
class SnCommand(private val sn: SnService) : CommandExecutor { class SnCommand(private val sn: SnService, private val eco: Economy?) : CommandExecutor {
override fun onCommand(p0: CommandSender, p1: Command, p2: String, p3: Array<out String>): Boolean { override fun onCommand(p0: CommandSender, p1: Command, p2: String, p3: Array<out String>): Boolean {
if (p0 !is Player) { if (p0 !is Player) {
return false; return false;
@@ -26,22 +28,132 @@ class SnCommand(private val sn: SnService) : CommandExecutor {
return true; return true;
} }
val orderSrv = SnOrderService(sn);
if (p3[1].equals("confirm", ignoreCase = true) && p3.size >= 3) {
// Confirming order
val orderNumber = p3[2].toLongOrNull();
if (orderNumber == null) {
p0.sendMessage(ChatColor.RED.toString() + "Invalid order number, it must be a number.");
return true;
}
p0.sendMessage(ChatColor.GRAY.toString() + "Confirming payment, please stand by...");
val order: SnOrder
try {
order = orderSrv.getOrder(orderNumber);
orderSrv.cancelOrder(orderNumber);
} catch (_: Exception) {
p0.sendMessage(ChatColor.RED.toString() + "An error occurred while pulling transaction. Make sure the order is exists then try again later.")
return true;
}
if (order.status != 1L) {
p0.sendMessage(ChatColor.RED.toString() + "Order was not paid yet.");
return true;
}
val bal = order.amount.toDouble() * 100;
eco?.depositPlayer(p0.player, bal)
val doneComponent = TextComponent(ChatColor.GREEN.toString() + "Done!")
val moneyComponent = TextComponent(ChatColor.GOLD.toString() + ChatColor.BOLD + " $bal$")
val suffixComponent =
TextComponent(ChatColor.GREEN.toString() + " has been added to your balance.")
p0.playSound(p0.player!!, Sound.BLOCK_ANVIL_PLACE, 1.0F, 1.0F)
p0.spigot().sendMessage(doneComponent, moneyComponent, suffixComponent)
return true;
}
// Creating new order
val amount = p3[1].toDoubleOrNull(); val amount = p3[1].toDoubleOrNull();
if (amount == null) { if (amount == null) {
p0.sendMessage(ChatColor.RED.toString() + "You need to specific an amount of number to deposit.") p0.sendMessage(ChatColor.RED.toString() + "You need to specific an amount of number to deposit.")
return true; return true;
} }
val orderSrv = SnOrderService(sn); p0.sendMessage(ChatColor.GRAY.toString() + "Creating order, please stand by...");
val order = orderSrv.createOrder("Deposit to Highland MC", amount / 100);
val linkComponent = TextComponent(ChatColor.GOLD.toString() + "Click here to payment page") val order: SnOrder
linkComponent.clickEvent = try {
ClickEvent(ClickEvent.Action.OPEN_URL, "https://solsynth.dev/orders/${order.id}"); order = orderSrv.createOrder("Deposit to Highland MC", amount / 100);
p0.spigot().sendMessage(linkComponent); } catch (_: Exception) {
p0.sendMessage(ChatColor.RED.toString() + "An error occurred while creating order. Try again later.")
return true;
} }
else -> return false; val linkComponent =
TextComponent(ChatColor.GOLD.toString() + ChatColor.UNDERLINE.toString() + ChatColor.BOLD.toString() + "click here")
linkComponent.clickEvent =
ClickEvent(ClickEvent.Action.OPEN_URL, "https://solsynth.dev/orders/${order.id}");
val orderHintComponent =
TextComponent(ChatColor.GRAY.toString() + "Order created, number " + ChatColor.WHITE + ChatColor.BOLD + "#${order.id}")
val followingComponent = TextComponent(ChatColor.GRAY.toString() + " to the payment page.")
p0.spigot()
.sendMessage(orderHintComponent, linkComponent, followingComponent);
val afterPaidComponent =
TextComponent(ChatColor.UNDERLINE.toString() + ChatColor.YELLOW + "After you paid, click here to confirm payment.")
afterPaidComponent.clickEvent =
ClickEvent(ClickEvent.Action.RUN_COMMAND, "/sn deposit confirm ${order.id}")
p0.spigot().sendMessage(afterPaidComponent);
}
"withdraw" -> {
if (p3.size < 2) {
p0.sendMessage(ChatColor.RED.toString() + "You need to specific an amount to deposit.")
return true;
}
val amount = p3[1].toDoubleOrNull();
if (amount == null) {
p0.sendMessage(ChatColor.RED.toString() + "You need to specific an amount of number to deposit.")
return true;
}
if (p3.size < 3) {
p0.sendMessage(ChatColor.RED.toString() + "You need to specific a wallet account to deposit.")
return true;
}
val walletId = p3[2].toLongOrNull();
if (walletId == null) {
p0.sendMessage(ChatColor.RED.toString() + "You need to specific a wallet account to deposit.")
return true;
}
p0.sendMessage(ChatColor.GRAY.toString() + "Making transaction, please stand by...");
val bal = amount / 100;
val resp = eco?.withdrawPlayer(p0.player, "Withdraw to Source Point - $bal SRC", amount);
if (resp?.type != EconomyResponse.ResponseType.SUCCESS) {
p0.sendMessage(ChatColor.RED.toString() + "Your in game account has no enough money for that.")
return true;
}
try {
val transactionSrv = SnTransactionService(sn);
val transaction = transactionSrv.makeTransaction(bal, "Withdraw from Highland MC", walletId);
val transactionHintComponent =
TextComponent(ChatColor.GREEN.toString() + "Done! transaction number " + ChatColor.WHITE + ChatColor.BOLD + "#${transaction.id}")
p0.playSound(p0.player!!, Sound.BLOCK_ANVIL_PLACE, 1.0F, 1.0F)
p0.spigot().sendMessage(transactionHintComponent)
} catch (_: Exception) {
eco?.depositPlayer(p0.player, "Withdraw to Source Point Failed - Refund", amount)
p0.sendMessage(ChatColor.RED.toString() + "An error occurred while making transaction. Make sure your wallet is exists then try again later.")
return true
}
}
else -> {
return false
}
} }
return true; return true;

View File

@@ -0,0 +1,49 @@
package dev.solsynth.snConnect.listeners
import dev.solsynth.snConnect.services.SnMessageService
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.EventPriority
import org.bukkit.event.Listener
import org.bukkit.event.entity.PlayerDeathEvent
import org.bukkit.event.player.AsyncPlayerChatEvent
import org.bukkit.event.player.PlayerAdvancementDoneEvent
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerQuitEvent
class SnChatListener(
private val messageService: SnMessageService,
private val destinationChatId: String,
private val messages: Map<String, String>
) : Listener {
@EventHandler(priority = EventPriority.MONITOR)
fun onPlayerChat(event: AsyncPlayerChatEvent) {
val message = "${event.player.name}: ${event.message}"
messageService.sendMessage(destinationChatId, message)
}
@EventHandler(priority = EventPriority.MONITOR)
fun onPlayerJoin(event: PlayerJoinEvent) {
val firstTime = event.player.hasPlayedBefore();
val templateKey = if (!firstTime) "join" else "joinFirst";
val template = messages[templateKey]
?: if (!firstTime) "➡️ {player} joined the game." else "➡️ {player} first time joined the game."
val message = template.replace("{player}", event.player.name)
messageService.sendMessage(destinationChatId, message)
}
@EventHandler(priority = EventPriority.MONITOR)
fun onPlayerQuit(event: PlayerQuitEvent) {
val template = messages["quit"] ?: "⬅️ {player} left the game."
val message = template.replace("{player}", event.player.name)
messageService.sendMessage(destinationChatId, message)
}
@EventHandler(priority = EventPriority.MONITOR)
fun onPlayerDeath(event: PlayerDeathEvent) {
val template = messages["death"] ?: "💀 {player} {message}"
val message = template.replace("{player}", event.entity.name).replace("{message}", event.deathMessage ?: "")
messageService.sendMessage(destinationChatId, message)
}
}

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

@@ -0,0 +1,40 @@
package dev.solsynth.snConnect.services
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.UUID
@Serializable
data class SnMessageRequest(
val content: String,
val nonce: String
)
class SnMessageService(private val sn: SnService) {
private val json = Json {
ignoreUnknownKeys = true
}
fun sendMessage(chatRoomId: String, content: String) {
val body = SnMessageRequest(content, UUID.randomUUID().toString())
val request = Request.Builder()
.url(sn.getUrl("sphere", "/chat/$chatRoomId/messages"))
.post(json.encodeToString(body).toRequestBody("application/json".toMediaTypeOrNull()))
.apply {
sn.botApiKey?.let { header("Authorization", "Bearer $it") }
}
.build()
sn.client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
val responseBody = response.body?.string() ?: "No body"
throw IOException("Unexpected code $response: $responseBody")
}
}
}
}

View File

@@ -10,7 +10,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException import java.io.IOException
@Serializable @Serializable
class SnOrderRequest( data class SnOrderRequest(
@SerialName("client_id") @SerialName("client_id")
val clientId: String, val clientId: String,
@SerialName("client_secret") @SerialName("client_secret")
@@ -19,6 +19,14 @@ class SnOrderRequest(
val amount: Double val amount: Double
) )
@Serializable
data class SnOrderCancelRequest(
@SerialName("client_id")
val clientId: String,
@SerialName("client_secret")
val clientSecret: String,
)
@Serializable @Serializable
data class SnOrder( data class SnOrder(
val id: Long, val id: Long,
@@ -66,4 +74,37 @@ class SnOrderService(private val sn: SnService) {
return out return out
} }
} }
fun getOrder(id: Long): SnOrder {
val request = Request.Builder()
.url(sn.getUrl("wa", "/orders/$id"))
.get()
.build()
sn.client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
val responseBody = response.body!!.string()
val out = json.decodeFromString<SnOrder>(responseBody)
return out
}
}
fun cancelOrder(id: Long): SnOrder {
val body = SnOrderCancelRequest(
sn.clientId,
sn.clientSecret,
);
val request = Request.Builder()
.url(sn.getUrl("wa", "/orders/$id/cancel"))
.post(Json.encodeToString(body).toRequestBody("application/json".toMediaTypeOrNull()))
.build()
sn.client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
val responseBody = response.body!!.string()
val out = json.decodeFromString<SnOrder>(responseBody)
return out
}
}
} }

View File

@@ -1,11 +1,98 @@
package dev.solsynth.snConnect.services package dev.solsynth.snConnect.services
import dev.solsynth.snConnect.models.WebSocketPacket
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.launch
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, val botApiKey: String?) {
val client = OkHttpClient.Builder().build(); val client = OkHttpClient.Builder().build();
private val logger = Logger.getLogger(SnService::class.java.name)
private var websocket: WebSocket? = null
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> = channelFlow {
val url = "${getWsBaseUrl()}/ws"
val request = Request.Builder()
.url(url)
.apply {
botApiKey?.let { header("Authorization", "Bearer $it") }
}
.build()
fun connect() {
logger.info("Attempting WebSocket connection to $url")
websocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
logger.info("WebSocket connection opened successfully 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}")
websocket = null
GlobalScope.launch {
delay(1000)
connect()
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
logger.info("WebSocket connection closed: code=$code, reason=$reason")
websocket = null
GlobalScope.launch {
delay(1000)
connect()
}
}
})
}
connect()
awaitClose {
websocket?.close(1000, "Shutting down")
websocket = null
}
}
fun disconnect() {
websocket?.close(1000, "Disconnecting")
websocket = null
} }
} }

View File

@@ -0,0 +1,83 @@
package dev.solsynth.snConnect.services
import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
@Serializable
data class SnTransaction (
val id: Long,
@SerialName("created_at")
val createdAt: Instant,
@SerialName("updated_at")
val updatedAt: Instant,
@SerialName("deleted_at")
val deletedAt: Instant? = null,
val remark: String,
val amount: String,
@SerialName("payer_id")
val payerID: Long? = null,
@SerialName("payee_id")
val payeeID: Long? = null
)
@Serializable
data class SnTransactionRequest(
@SerialName("client_id")
val clientId: String,
@SerialName("client_secret")
val clientSecret: String,
@SerialName("payee_id")
val payeeID: Long? = null,
@SerialName("payer_id")
val payerID: Long? = null,
val remark: String,
val amount: Double
)
class SnTransactionService(private val sn: SnService) {
private val json = Json {
ignoreUnknownKeys = true
}
fun makeTransaction(amount: Double, remark: String, payeeID: Long): SnTransaction {
val body = SnTransactionRequest(
sn.clientId,
sn.clientSecret,
amount = amount,
remark = remark,
payeeID = payeeID,
);
val request = Request.Builder()
.url(sn.getUrl("wa", "/transactions"))
.post(Json.encodeToString(body).toRequestBody("application/json".toMediaTypeOrNull()))
.build()
sn.client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
val responseBody = response.body!!.string()
val out = json.decodeFromString<SnTransaction>(responseBody)
return out
}
}
fun getTransaction(id: Long): SnTransaction {
val request = Request.Builder()
.url(sn.getUrl("wa", "/transactions/$id"))
.get()
.build()
sn.client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
val responseBody = response.body!!.string()
val out = json.decodeFromString<SnTransaction>(responseBody)
return out
}
}
}

View File

@@ -1,4 +1,15 @@
sn: sn:
endpoint: https://api.sn.solsynth.dev endpoint: https://api.solian.app
client_id: highland-mc client_id: goatcraft
client_secret: 12345678 client_secret: 12345678
bot_secret: 114.514.19198
chat:
sync_rooms: []
outgoing_room: 00000000-0000-0000-0000-00000000008b
messages:
join: "➡️ {player} joined the game."
joinFirst: "➡️ {player} first time joined the game."
quit: "⬅️ {player} left the game."
death: "💀 {player} {message}"
serverStart: "🚀 Server started successfully"
serverStop: "⏹️ Server stopped"

View File

@@ -0,0 +1,30 @@
package dev.solsynth.snConnect
import dev.solsynth.snConnect.services.SnService
import kotlinx.coroutines.withTimeoutOrNull
suspend fun main() {
val service = SnService(
baseUrl = "https://api.solian.app", // Replace with actual test server URL
clientId = "goatcraft",
clientSecret = "testClientSecret",
botApiKey = "02-l7ARXnEimQaBY0Dj3SQ.g4UTdShR9GiNWBDjC7qm9Xu83t0Vq8mQ2WPaTO8S-_j6EuKcWd0Kqb_hEkFlahmhfAd5lcH7j_-N8knSIXjo3X7OSFck0E_ogwluZpGSzqbYOrlBAQc9Rk1VhHVNu_W4fi9NR6NnUwpAgyTIh2RuRHk98oVa2I4Ie-NXPybb26N3i9wXv3-wXlkml0IOrhs3FRKMbcJNmKIzEYP0KQNqs3w9TAx0R2fe9DAAQ8WvPW5iPGSbyxr_fF4Qm7tQ0rQvg89x0cUGmKQHtCTeKa2WZi7UBTbw_b4SiHMqpLMxIDQBEZQGqkJ5r37_YCyCNqe5Huha86GG7bT__m6z5emeow"
)
print("server started")
// 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}")
print(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
print("WebSocket test finished. Packets received: ${packets?.size ?: 0}")
assert(packets != null) // Flow started without immediate error
}