Compare commits

..

17 Commits

Author SHA1 Message Date
80d58ea4e2 🐛 Fix join first 2025-10-06 12:06:57 +08:00
770a58fb1d 🐛 Fix bugs 2025-10-06 03:22:38 +08:00
1862ce4f65 🔇 Toggable websocket packet 2025-10-06 01:00:53 +08:00
1c304a5108 🐛 Bug fixes 2025-10-06 00:49:15 +08:00
a902ecef8d Balance exchange source points, link account with Solarpass with drasl 2025-10-05 23:46:06 +08:00
3803e27a06 Bedrock remover 2025-10-05 19:06:56 +08:00
339944d13a First time join 2025-10-05 18:26:39 +08:00
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
29 changed files with 1668 additions and 54 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,17 @@ 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("com.github.Zrips:CMI-API:9.7.14.3")
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.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,15 +41,26 @@ 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"
filesMatching("plugin.yml") { filesMatching("plugin.yml") {
expand(props) expand(props)
} }
}
} }

View File

@@ -1,22 +1,104 @@
package dev.solsynth.snConnect package dev.solsynth.snConnect
import dev.solsynth.snConnect.commands.BedrockRemoverCommand
import dev.solsynth.snConnect.commands.BedrockRemoverCompleter
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.listeners.SolarpassCheckListener
import dev.solsynth.snConnect.services.AuthService
import dev.solsynth.snConnect.services.AuthUserService
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.configuration.file.YamlConfiguration
import org.bukkit.plugin.java.JavaPlugin import org.bukkit.plugin.java.JavaPlugin
import java.io.InputStreamReader
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 authService: AuthService? = null
private var authUserService: AuthUserService? = 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 var playerSolarpassMap: MutableMap<String, String?> = mutableMapOf()
private fun handleWebSocketPacket(packet: WebSocketPacket) {
if (config.getBoolean("debug"))
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()
val locale = config.getString("locale") ?: "en"
val messageFile = if (locale == "en") "messages.yml" else "messages_$locale.yml"
val messagesStream = getResource(messageFile) ?: throw RuntimeException("$messageFile not found in JAR")
val messagesConfig = YamlConfiguration.loadConfiguration(InputStreamReader(messagesStream, "UTF-8"))
messages = messagesConfig.getValues(false)
.mapValues { it.value as String } + (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,26 +108,78 @@ class SolarNetworkConnect : JavaPlugin() {
) )
} }
Bukkit.getPluginCommand("solar")!!.setExecutor(SnCommand(this.sn!!)) if (messageService != null && destinationChatId != null) {
server.pluginManager.registerEvents(SnChatListener(messageService!!, destinationChatId!!, messages), this)
}
if (authUserService != null) {
server.pluginManager.registerEvents(
SolarpassCheckListener(authUserService!!, messages, playerSolarpassMap),
this
)
}
Bukkit.getPluginCommand("solar")!!.setExecutor(SnCommand(this.sn!!, this.economy, messages, playerSolarpassMap))
Bukkit.getPluginCommand("solar")!!.tabCompleter = SnCommandCompleter() Bukkit.getPluginCommand("solar")!!.tabCompleter = SnCommandCompleter()
Bukkit.getPluginCommand("eatrock")!!.setExecutor(BedrockRemoverCommand(this.economy))
Bukkit.getPluginCommand("eatrock")!!.tabCompleter = BedrockRemoverCompleter()
logger.info( logger.info(
String.format( String.format(
"Successfully loaded Solar Network Connect connected with %s", "Successfully loaded Solar Network Connect connected with %s",
config.getString("sn.endpoint") config.getString("sn.endpoint")
) )
); );
// Send server start message
try {
destinationChatId?.let { chatId ->
messageService?.sendMessage(chatId, messages["server_start"] ?: "🚀 Server started successfully")
}
} catch (e: Exception) {
logger.warning("Failed to send server start message: ${e.message}")
}
} }
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
try {
destinationChatId?.let { chatId ->
messageService?.sendMessage(chatId, messages["server_stop"] ?: "⏹️ Server stopped")
}
} catch (e: Exception) {
logger.warning("Failed to send server stop message: ${e.message}")
}
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!!)
val authUrl = config.getString("auth.endpoint") ?: return false;
val siteSecret = config.getString("auth.site_secret")
authService = AuthService(authUrl, siteSecret);
authUserService = AuthUserService(authService!!)
webSocketJob = GlobalScope.launch {
sn!!.connectWebSocketAsFlow().collect { packet ->
handleWebSocketPacket(packet)
}
}
return true; return true;
} }

View File

@@ -0,0 +1,220 @@
package dev.solsynth.snConnect.commands
import net.milkbowl.vault.economy.Economy
import net.milkbowl.vault.economy.EconomyResponse
import org.bukkit.ChatColor
import org.bukkit.Material
import org.bukkit.Sound
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
import org.bukkit.command.TabCompleter
import org.bukkit.entity.Player
import org.bukkit.inventory.ItemStack
class BedrockRemoverCommand(private val economy: Economy?) : CommandExecutor {
private val paymentMethods = listOf("money", "materials")
private val requiredMaterials = listOf(
Material.PISTON,
Material.TNT,
Material.LEVER,
Material.OAK_TRAPDOOR // Representative for trapdoor group
)
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>): Boolean {
if (sender !is Player) {
sender.sendMessage(ChatColor.RED.toString() + "This command can only be used by players.")
return true
}
if (args.isEmpty() || args.size > 1) {
sender.sendMessage(ChatColor.RED.toString() + "Usage: /bedrockremove <money/materials>")
return true
}
val paymentMethod = args[0].lowercase()
if (paymentMethod !in paymentMethods) {
sender.sendMessage(ChatColor.RED.toString() + "Invalid payment method. Use: money or materials.")
return true
}
// Get the block the player is looking at
val targetBlock = sender.getTargetBlock(null, 10)
if (targetBlock == null || targetBlock.type != Material.BEDROCK) {
sender.sendMessage(ChatColor.RED.toString() + "You must be looking at a bedrock block within 10 blocks.")
return true
}
// Attempt payment
val success = when (paymentMethod) {
"money" -> payWithMoney(sender, 100.0)
"materials" -> payWithAllMaterials(sender)
else -> return false
}
if (!success) {
// Error messages are handled in the pay methods
return true
}
// Remove the bedrock
targetBlock.type = Material.AIR
sender.sendMessage(ChatColor.GREEN.toString() + "Bedrock removed successfully!")
sender.playSound(sender.location, Sound.BLOCK_STONE_BREAK, 1.0f, 1.0f)
return true
}
private fun payWithMoney(player: Player, amount: Double): Boolean {
if (economy == null) {
player.sendMessage(ChatColor.RED.toString() + "Economy not available.")
return false
}
val response = economy.withdrawPlayer(player, "Bedrock removal", amount)
if (response.type != EconomyResponse.ResponseType.SUCCESS) {
player.sendMessage(ChatColor.RED.toString() + "Insufficient funds. You need ${amount}.")
return false
}
player.sendMessage(ChatColor.GREEN.toString() + "Withdrew $amount from your account.")
return true
}
private fun payWithMaterial(player: Player, material: Material): Boolean {
val inventory = player.inventory
// Special handling for materials with multiple variants
val hasItem = when (material) {
Material.OAK_TRAPDOOR -> {
// Check for any trapdoor type
inventory.contents.any { item -> item != null && item.type.name.endsWith("_TRAPDOOR") }
}
Material.PISTON -> {
// Check for any piston type
inventory.contents.any { item -> item != null && (item.type == Material.PISTON || item.type == Material.STICKY_PISTON) }
}
else -> inventory.contains(material, 1)
}
if (!hasItem) {
player.sendMessage(ChatColor.RED.toString() + "You don't have a ${material.name.lowercase()} in your inventory.")
return false
}
// Remove the item
when (material) {
Material.OAK_TRAPDOOR -> {
// Find and remove any trapdoor
for (item in inventory.contents) {
if (item != null && item.type.name.endsWith("_TRAPDOOR")) {
val amount = item.amount
if (amount > 1) {
item.amount = amount - 1
} else {
inventory.remove(item)
}
break
}
}
}
Material.PISTON -> {
// Find and remove any piston
for (item in inventory.contents) {
if (item != null && (item.type == Material.PISTON || item.type == Material.STICKY_PISTON)) {
val amount = item.amount
if (amount > 1) {
item.amount = amount - 1
} else {
inventory.remove(item)
}
break
}
}
}
else -> {
inventory.removeItem(ItemStack(material, 1))
}
}
player.sendMessage(ChatColor.GREEN.toString() + "Consumed 1 ${material.name.lowercase()}.")
return true
}
private fun payWithAllMaterials(player: Player): Boolean {
val inventory = player.inventory
// Check if player has all required materials
for (material in requiredMaterials) {
val hasItem = when (material) {
Material.OAK_TRAPDOOR -> {
// Check for any trapdoor type
inventory.contents.any { item -> item != null && item.type.name.endsWith("_TRAPDOOR") }
}
Material.PISTON -> {
// Check for any piston type
inventory.contents.any { item -> item != null && (item.type == Material.PISTON || item.type == Material.STICKY_PISTON) }
}
else -> inventory.contains(material, 1)
}
if (!hasItem) {
player.sendMessage(ChatColor.RED.toString() + "You don't have all required materials: piston, tnt, lever, and trapdoor.")
return false
}
}
// Remove one of each material
for (material in requiredMaterials) {
when (material) {
Material.OAK_TRAPDOOR -> {
// Find and remove any trapdoor
for (item in inventory.contents) {
if (item != null && item.type.name.endsWith("_TRAPDOOR")) {
val amount = item.amount
if (amount > 1) {
item.amount = amount - 1
} else {
inventory.remove(item)
}
break
}
}
}
Material.PISTON -> {
// Find and remove any piston
for (item in inventory.contents) {
if (item != null && (item.type == Material.PISTON || item.type == Material.STICKY_PISTON)) {
val amount = item.amount
if (amount > 1) {
item.amount = amount - 1
} else {
inventory.remove(item)
}
break
}
}
}
else -> {
inventory.removeItem(ItemStack(material, 1))
}
}
}
player.sendMessage(ChatColor.GREEN.toString() + "Consumed materials: piston, tnt, lever, trapdoor.")
return true
}
}
class BedrockRemoverCompleter : TabCompleter {
private val paymentMethods = listOf("money", "materials")
override fun onTabComplete(
sender: CommandSender,
command: Command,
label: String,
args: Array<out String>
): MutableList<String> {
return when (args.size) {
1 -> paymentMethods.filter { it.startsWith(args[0], ignoreCase = true) }.toMutableList()
else -> mutableListOf()
}
}
}

View File

@@ -1,17 +1,26 @@
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.Bukkit.getLogger
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?,
private val messages: Map<String, String>,
private val playerSolarpassMap: MutableMap<String, String?>
) :
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;
@@ -22,26 +31,185 @@ class SnCommand(private val sn: SnService) : CommandExecutor {
when (p3[0].lowercase()) { when (p3[0].lowercase()) {
"deposit" -> { "deposit" -> {
if (p3.size < 2) { if (p3.size < 2) {
p0.sendMessage(ChatColor.RED.toString() + "You need to specific an amount to deposit.") p0.sendMessage(
ChatColor.RED.toString() + (messages["command_deposit_no_amount"]
?: "You need to specify an amount to deposit.")
)
return true;
}
val orderSrv = SnOrderService(sn);
if (p3[1].equals("confirm", ignoreCase = true) && p3.size >= 3) {
// Confirming order
val orderNumber = p3[2];
p0.sendMessage(
ChatColor.GRAY.toString() + (messages["command_deposit_confirming"]
?: "Confirming payment, please stand by...")
);
val order: SnOrder
try {
order = orderSrv.getOrder(orderNumber);
// The update validated the order is created by us
orderSrv.updateOrderStatus(orderNumber, false);
} catch (_: Exception) {
p0.sendMessage(
ChatColor.RED.toString() + (messages["command_deposit_pull_error"]
?: "An error occurred while pulling transaction. Make sure the order exists then try again later.")
)
return true;
}
if (order.status != 1L) {
p0.sendMessage(
ChatColor.RED.toString() + (messages["command_deposit_order_not_paid"]
?: "Order was not paid yet.")
)
return true;
}
val bal = order.amount;
eco?.depositPlayer(p0.player, bal)
val doneComponent =
TextComponent(ChatColor.GREEN.toString() + (messages["command_deposit_done"] ?: "Done!"))
val moneyComponent = TextComponent(ChatColor.GOLD.toString() + ChatColor.BOLD + " $bal$")
val suffixComponent =
TextComponent(
ChatColor.GREEN.toString() + (messages["command_deposit_added_balance"]
?: " 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();
if (amount == null) {
p0.sendMessage(
ChatColor.RED.toString() + (messages["command_deposit_invalid_amount"]
?: "You need to specify an amount as a number to deposit.")
)
return true;
}
p0.sendMessage(
ChatColor.GRAY.toString() + (messages["command_deposit_creating"]
?: "Creating order, please stand by...")
);
val order: SnOrder
try {
order = orderSrv.createOrder("Deposit to GoatCraft", amount);
} catch (e: Exception) {
p0.sendMessage(
ChatColor.RED.toString() + (messages["command_deposit_create_error"]
?: "An error occurred while creating order. Try again later.")
)
e.printStackTrace();
return true;
}
val linkComponent =
TextComponent(
ChatColor.GOLD.toString() + ChatColor.UNDERLINE.toString() + ChatColor.BOLD.toString() + (messages["command_deposit_click_here"]
?: "click here")
)
linkComponent.clickEvent =
ClickEvent(ClickEvent.Action.OPEN_URL, "https://solian.app/orders/${order.id}");
val orderHintComponent =
TextComponent(
ChatColor.GRAY.toString() + (messages["command_deposit_order_created"]
?: "Order created, number ") + ChatColor.WHITE + ChatColor.BOLD + "#${order.id.take(6)}" + " "
)
val followingComponent = TextComponent(
ChatColor.GRAY.toString() + (messages["command_deposit_to_payment_page"] ?: " to the payment page.")
)
p0.spigot()
.sendMessage(orderHintComponent, linkComponent, followingComponent);
val afterPaidComponent =
TextComponent(
ChatColor.UNDERLINE.toString() + ChatColor.YELLOW + (messages["command_deposit_after_paid"]
?: "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() + (messages["command_deposit_no_amount"]
?: "You need to specify an amount to deposit.")
)
return true; return true;
} }
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() + (messages["command_deposit_invalid_amount"]
?: "You need to specify an amount as a number to deposit.")
)
return true; return true;
} }
val orderSrv = SnOrderService(sn); val playerUuid = p0.uniqueId.toString()
val order = orderSrv.createOrder("Deposit to Highland MC", amount / 100); val accountId = playerSolarpassMap[playerUuid]
if (accountId == null) {
val linkComponent = TextComponent(ChatColor.GOLD.toString() + "Click here to payment page") p0.sendMessage(
linkComponent.clickEvent = ChatColor.RED.toString() + "Bind your Solarpass first!"
ClickEvent(ClickEvent.Action.OPEN_URL, "https://solsynth.dev/orders/${order.id}"); )
p0.spigot().sendMessage(linkComponent); return true;
} }
else -> return false; p0.sendMessage(
ChatColor.GRAY.toString() + (messages["command_withdraw_making"]
?: "Making transaction, please stand by...")
);
// Takes extra 20% as fee
val fee = amount * 0.2;
val resp = eco?.withdrawPlayer(p0.player, amount + fee);
if (resp?.type != EconomyResponse.ResponseType.SUCCESS) {
p0.sendMessage(
ChatColor.RED.toString() + (messages["command_withdraw_no_money"]
?: "Your in-game account does not have enough money for that.")
)
return true;
}
try {
val transactionSrv = SnBalanceService(sn);
transactionSrv.addBalance(amount, "Withdraw from GoatCraft", accountId);
val transactionHintComponent =
TextComponent(
ChatColor.GREEN.toString() + (messages["command_withdraw_done"]
?: "Done! ")
)
p0.playSound(p0.player!!, Sound.BLOCK_ANVIL_PLACE, 1.0F, 1.0F)
p0.spigot().sendMessage(transactionHintComponent)
} catch (e: Exception) {
eco.depositPlayer(p0.player, amount + fee)
p0.sendMessage(
ChatColor.RED.toString() + (messages["command_withdraw_error"]
?: "An error occurred while making transaction. Make sure your wallet exists then try again later.")
)
e.printStackTrace()
return true
}
}
else -> {
return false
}
} }
return true; return true;

View File

@@ -0,0 +1,51 @@
package dev.solsynth.snConnect.listeners
import com.Zrips.CMI.CMI
import dev.solsynth.snConnect.services.SnMessageService
import org.bukkit.Bukkit
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)
}
@Suppress("SENSELESS_COMPARISON")
@EventHandler()
fun onPlayerJoin(event: PlayerJoinEvent) {
val firstTime = Bukkit.getOfflinePlayer(event.player.uniqueId) == null;
val templateKey = if (!firstTime) "join" else "join_first";
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()
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()
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 @@
package dev.solsynth.snConnect.listeners
import dev.solsynth.snConnect.services.AuthUserService
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.bukkit.ChatColor
import org.bukkit.event.EventHandler
import org.bukkit.event.Listener
import org.bukkit.event.player.PlayerJoinEvent
class SolarpassCheckListener(
private val authUserService: AuthUserService,
private val messages: Map<String, String>,
private val playerSolarpassMap: MutableMap<String, String?>
) : Listener {
@EventHandler()
fun onPlayerJoin(event: PlayerJoinEvent) {
GlobalScope.launch {
try {
val playerUuid = event.player.uniqueId.toString()
var solarpassId = playerSolarpassMap[playerUuid]
if (solarpassId == null) {
solarpassId = authUserService.getSnByPlayer(playerUuid)
playerSolarpassMap[playerUuid] = solarpassId
}
if (solarpassId == null) {
// Send suggestion message
val message = messages["solarpass_bind_suggestion"] ?: "${ChatColor.YELLOW}To get more features, please bind your Solarpass account!"
event.player.sendMessage(message)
}
} catch (e: Exception) {
// Optionally log the error or handle it
}
}
}
}

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,13 @@
package dev.solsynth.snConnect.services
import okhttp3.OkHttpClient
import java.util.logging.Logger
class AuthService(private val baseUrl: String, val siteSecret: String?) {
val client = OkHttpClient.Builder().build();
private val logger = Logger.getLogger(AuthService::class.java.name)
fun getUrl(segment: String): String {
return "$baseUrl$segment"
}
}

View File

@@ -0,0 +1,132 @@
package dev.solsynth.snConnect.services
import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Request
import java.io.IOException
@Serializable
data class DraslPlayer(
@SerialName("userUuid")
val userUuid: String,
val name: String,
val uuid: String,
@SerialName("offlineUuid")
val offlineUuid: String,
@SerialName("fallbackPlayer")
val fallbackPlayer: String,
@SerialName("skinModel")
val skinModel: String,
@SerialName("skinUrl")
val skinUrl: String?,
@SerialName("capeUrl")
val capeUrl: String?,
@SerialName("createdAt")
val createdAt: String, // ISO string
@SerialName("nameLastChangedAt")
val nameLastChangedAt: String
)
@Serializable
data class DraslOIDCIdentity(
@SerialName("userUuid")
val userUuid: String,
@SerialName("oidcProviderName")
val oidcProviderName: String,
val issuer: String,
val subject: String
)
@Serializable
data class DraslUser(
@SerialName("isAdmin")
val isAdmin: Boolean,
@SerialName("isLocked")
val isLocked: Boolean,
val uuid: String,
val username: String,
@SerialName("preferredLanguage")
val preferredLanguage: String,
@SerialName("maxPlayerCount")
val maxPlayerCount: Int,
val players: List<DraslPlayer>,
@SerialName("oidcIdentities")
val oidcIdentities: List<DraslOIDCIdentity>
)
@Serializable
data class APILoginRequest(
val username: String,
val password: String
)
@Serializable
data class APILoginResponse(
val user: DraslUser,
val token: String,
@SerialName("expires_at")
val expiresAt: Instant
)
@Serializable
data class APIOIDCTokenRequest(
@SerialName("provider")
val provider: String,
val code: String,
val state: String
)
@Serializable
data class APIOIDCTokenResponse(
val user: DraslUser,
val token: String,
@SerialName("expires_at")
val expiresAt: Instant
)
class AuthUserService(private val sn: AuthService) {
private val json = Json {
ignoreUnknownKeys = true
}
private fun addAuthHeader(builder: Request.Builder): Request.Builder {
sn.siteSecret?.let { builder.header("Authorization", "Bearer $it") }
return builder
}
fun getUser(id: String): DraslUser {
val requestBuilder = Request.Builder()
.url(sn.getUrl("/drasl/api/v2/users/$id"))
.get()
sn.client.newCall(addAuthHeader(requestBuilder).build()).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
val responseBody = response.body!!.string()
return json.decodeFromString<DraslUser>(responseBody)
}
}
fun getPlayerByUuid(uuid: String): DraslPlayer {
val requestBuilder = Request.Builder()
.url(sn.getUrl("/drasl/api/v2/players/$uuid"))
.get()
sn.client.newCall(addAuthHeader(requestBuilder).build()).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
val responseBody = response.body!!.string()
return json.decodeFromString<DraslPlayer>(responseBody)
}
}
fun getSnByPlayer(playerUuid: String): String? {
return try {
val player = getPlayerByUuid(playerUuid)
val user = getUser(player.userUuid)
user.oidcIdentities.find { it.oidcProviderName == "Solarpass" }?.subject
} catch (e: IOException) {
null
}
}
}

View File

@@ -0,0 +1,70 @@
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
import java.math.BigDecimal
@Serializable
data class SnTransaction(
val id: String,
@SerialName("created_at")
val createdAt: Instant,
@SerialName("updated_at")
val updatedAt: Instant,
@SerialName("deleted_at")
val deletedAt: Instant? = null,
@SerialName("currency")
val currency: String,
@SerialName("amount")
val amount: Double,
@SerialName("remarks")
val remarks: String?,
@SerialName("type")
val type: String,
@SerialName("payer_wallet_id")
val payerWalletId: String?,
@SerialName("payee_wallet_id")
val payeeWalletId: String?
)
@Serializable
data class SnTransactionRequest(
@SerialName("account_id")
val accountID: String,
val remark: String,
val amount: Double,
val currency: String
)
class SnBalanceService(private val sn: SnService) {
private val json = Json {
ignoreUnknownKeys = true
}
fun addBalance(amount: Double, remark: String, accountID: String) {
val body = SnTransactionRequest(
amount = amount,
remark = remark,
accountID = accountID,
currency = "points"
);
// Notice, the balance modification is admin API, the bot need have the admin privilege to do so.
val request = Request.Builder()
.url(sn.getUrl("id", "/wallets/balance"))
.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) throw IOException("Unexpected code $response")
}
}
}

View File

@@ -0,0 +1,39 @@
package dev.solsynth.snConnect.services
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.*
@Serializable
data class SnMessageRequest(
val content: String,
val nonce: String
)
class SnMessageService(private val sn: SnService) {
private val json: 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

@@ -4,24 +4,45 @@ import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException import java.io.IOException
import java.math.BigDecimal
@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")
val clientSecret: String, val clientSecret: String,
val remark: String, @SerialName("currency")
val amount: Double val currency: String,
@SerialName("amount")
val amount: Double,
@SerialName("remarks")
val remarks: String? = null,
@SerialName("product_identifier")
val productIdentifier: String? = null,
@SerialName("meta")
val meta: JsonElement? = null,
@SerialName("duration_hours")
val durationHours: Int = 24
)
@Serializable
data class SnOrderUpdateStatus(
@SerialName("client_id")
val clientId: String,
@SerialName("client_secret")
val clientSecret: String,
val status: Int
) )
@Serializable @Serializable
data class SnOrder( data class SnOrder(
val id: Long, val id: String,
@SerialName("created_at") @SerialName("created_at")
val createdAt: Instant, val createdAt: Instant,
@SerialName("updated_at") @SerialName("updated_at")
@@ -29,16 +50,14 @@ data class SnOrder(
@SerialName("deleted_at") @SerialName("deleted_at")
val deletedAt: Instant? = null, val deletedAt: Instant? = null,
val status: Long, val status: Long,
val remark: String, val remarks: String,
val amount: String, val amount: Double,
@SerialName("payer_id") @SerialName("payer_id")
val payerID: Int? = null, val payerID: String? = null,
@SerialName("payee_id") @SerialName("payee_id")
val payeeID: Int? = null, val payeeID: String? = null,
@SerialName("transaction_id") @SerialName("transaction_id")
val transactionID: Int? = null, val transactionID: String? = null,
@SerialName("client_id")
val clientID: Long
) )
class SnOrderService(private val sn: SnService) { class SnOrderService(private val sn: SnService) {
@@ -48,13 +67,14 @@ class SnOrderService(private val sn: SnService) {
fun createOrder(remark: String, amount: Double): SnOrder { fun createOrder(remark: String, amount: Double): SnOrder {
val body = SnOrderRequest( val body = SnOrderRequest(
sn.clientId, clientId = sn.clientId,
sn.clientSecret, clientSecret = sn.clientSecret,
remark, currency = "points",
amount, amount = amount,
remarks = remark
); );
val request = Request.Builder() val request = Request.Builder()
.url(sn.getUrl("wa", "/orders")) .url(sn.getUrl("id", "/orders"))
.post(Json.encodeToString(body).toRequestBody("application/json".toMediaTypeOrNull())) .post(Json.encodeToString(body).toRequestBody("application/json".toMediaTypeOrNull()))
.build() .build()
@@ -66,4 +86,40 @@ class SnOrderService(private val sn: SnService) {
return out return out
} }
} }
fun getOrder(id: String): SnOrder {
val request = Request.Builder()
.url(sn.getUrl("id", "/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
}
}
// If the isCancelled is true, set the order status to be canceled, otherwise set it to finished
fun updateOrderStatus(id: String, isCancelled: Boolean = false): SnOrder {
val status = if (isCancelled) 2 else 3;
val body = SnOrderUpdateStatus(
sn.clientId,
sn.clientSecret,
status
);
val request = Request.Builder()
.url(sn.getUrl("id", "/orders/$id/status"))
.patch(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

@@ -0,0 +1,140 @@
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 APIPlayer(
val id: String,
@SerialName("user_id")
val userId: String,
@SerialName("display_name")
val displayName: String?,
@SerialName("uuid")
val uuid: String?, // Minecraft UUID
@SerialName("created_at")
val createdAt: Instant,
@SerialName("updated_at")
val updatedAt: Instant,
@SerialName("last_login")
val lastLogin: Instant?
)
@Serializable
data class APIPlayerCreate(
@SerialName("user_id")
val userId: String,
@SerialName("display_name")
val displayName: String?,
@SerialName("uuid")
val uuid: String?
)
@Serializable
data class APIPlayerUpdate(
@SerialName("display_name")
val displayName: String?,
@SerialName("uuid")
val uuid: String?
)
class SnPlayerService(private val sn: AuthService) {
private val json = Json {
ignoreUnknownKeys = true
}
private fun addAuthHeader(builder: Request.Builder): Request.Builder {
sn.siteSecret?.let { builder.header("Authorization", "Bearer $it") }
return builder
}
fun getPlayers(): List<APIPlayer> {
val requestBuilder = Request.Builder()
.url(sn.getUrl("/drasl/api/v2/players"))
.get()
sn.client.newCall(addAuthHeader(requestBuilder).build()).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
val responseBody = response.body!!.string()
return json.decodeFromString(responseBody)
}
}
fun getPlayer(id: String): APIPlayer {
val requestBuilder = Request.Builder()
.url(sn.getUrl("/drasl/api/v2/players/$id"))
.get()
sn.client.newCall(addAuthHeader(requestBuilder).build()).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
val responseBody = response.body!!.string()
return json.decodeFromString(responseBody)
}
}
fun getPlayerByUuid(uuid: String): APIPlayer {
val requestBuilder = Request.Builder()
.url(sn.getUrl("/drasl/api/v2/players/uuid/$uuid"))
.get()
sn.client.newCall(addAuthHeader(requestBuilder).build()).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
val responseBody = response.body!!.string()
return json.decodeFromString(responseBody)
}
}
fun createPlayer(request: APIPlayerCreate): APIPlayer {
val body = Json.encodeToString(request).toRequestBody("application/json".toMediaTypeOrNull())
val requestBuilder = Request.Builder()
.url(sn.getUrl("/drasl/api/v2/players"))
.post(body)
sn.client.newCall(addAuthHeader(requestBuilder).build()).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
val responseBody = response.body!!.string()
return json.decodeFromString(responseBody)
}
}
fun updatePlayer(id: String, request: APIPlayerUpdate): APIPlayer {
val body = Json.encodeToString(request).toRequestBody("application/json".toMediaTypeOrNull())
val requestBuilder = Request.Builder()
.url(sn.getUrl("/drasl/api/v2/players/$id"))
.patch(body)
sn.client.newCall(addAuthHeader(requestBuilder).build()).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
val responseBody = response.body!!.string()
return json.decodeFromString(responseBody)
}
}
fun deletePlayer(id: String) {
val requestBuilder = Request.Builder()
.url(sn.getUrl("/drasl/api/v2/players/$id"))
.delete()
sn.client.newCall(addAuthHeader(requestBuilder).build()).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
}
}
fun getPlayerByUser(userId: String): APIPlayer {
val requestBuilder = Request.Builder()
.url(sn.getUrl("/drasl/api/v2/players/user/$userId"))
.get()
sn.client.newCall(addAuthHeader(requestBuilder).build()).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
val responseBody = response.body!!.string()
return json.decodeFromString(responseBody)
}
}
}

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

@@ -1,4 +1,12 @@
locale: en
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
auth:
endpoint: https://authmc.solsynth.dev
site_secret: hereissecret
chat:
sync_rooms: []
outgoing_room: 00000000-0000-0000-0000-00000000008b

View File

@@ -0,0 +1,29 @@
join: "➡️ {player} joined the game."
join_first: "➡️ {player} first time joined the game."
quit: "⬅️ {player} left the game."
death: "💀 {player} {message}"
advancement: "🎉 {player} unlocked advancement: {advancement}"
server_start: "🚀 Server started successfully"
server_stop: "⏹️ Server stopped"
# Command messages
command_deposit_no_amount: "You need to specify an amount to deposit."
command_deposit_invalid_order: "Invalid order number, it must be a uuid."
command_deposit_confirming: "Confirming payment, please stand by..."
command_deposit_pull_error: "An error occurred while pulling transaction. Make sure the order exists then try again later."
command_deposit_order_not_paid: "Order was not paid yet."
command_deposit_done: "Done!"
command_deposit_added_balance: " has been added to your balance."
command_deposit_invalid_amount: "You need to specify an amount as a number to deposit."
command_deposit_creating: "Creating order, please stand by..."
command_deposit_create_error: "An error occurred while creating order. Try again later."
command_deposit_click_here: "click here"
command_deposit_order_created: "Order created, number "
command_deposit_to_payment_page: " to the payment page."
command_deposit_after_paid: "After you paid, click here to confirm payment."
command_withdraw_no_wallet: "You need to specify a wallet account to withdraw to."
command_withdraw_making: "Making transaction, please stand by..."
command_withdraw_no_money: "Your in-game account does not have enough money for that."
command_withdraw_done: "Done! transaction number "
command_withdraw_error: "An error occurred while making transaction. Make sure your wallet exists then try again later."
command_withdraw_refund_reason: "Withdraw to Source Point Failed - Refund"

View File

@@ -0,0 +1,29 @@
join: "➡️ {player} 加入了游戏。"
join_first: "➡️ {player} 首次加入了游戏。"
quit: "⬅️ {player} 离开了游戏。"
death: "💀 {player} {message}"
advancement: "🎉 {player} 解锁成就:{advancement}"
server_start: "🚀 服务器成功启动"
server_stop: "⏹️ 服务器已停止"
# Command messages
command_deposit_no_amount: "您需要指定存款金额。"
command_deposit_invalid_order: "无效的订单号,它必须是一个 UUID。"
command_deposit_confirming: "正在确认付款,请稍等..."
command_deposit_pull_error: "拉取交易时发生错误。请确保订单存在,然后稍后再试。"
command_deposit_order_not_paid: "订单尚未付款。"
command_deposit_done: "完成!"
command_deposit_added_balance: " 已添加到您的余额。"
command_deposit_invalid_amount: "您需要将金额指定为数字进行存款。"
command_deposit_creating: "正在创建订单,请稍等..."
command_deposit_create_error: "创建订单时发生错误。请稍后再试。"
command_deposit_click_here: "点击这里"
command_deposit_order_created: "订单已创建,编号 "
command_deposit_to_payment_page: "到付款页面。"
command_deposit_after_paid: "付款后,点击这里确认付款。"
command_withdraw_no_wallet: "您需要指定一个提取钱包账户。"
command_withdraw_making: "正在进行交易,请稍等..."
command_withdraw_no_money: "您的游戏内账户没有足够的钱。"
command_withdraw_done: "完成!交易编号 "
command_withdraw_error: "进行交易时发生错误。请确保您的钱包存在,然后稍后再试。"
command_withdraw_refund_reason: "提取到 Source Point 失败 - 退款"

View File

@@ -4,7 +4,7 @@ main: dev.solsynth.snConnect.SolarNetworkConnect
api-version: '1.21' api-version: '1.21'
authors: [ LittleSheepOvO ] authors: [ LittleSheepOvO ]
description: Solar Network Connect provide features connect with Solar Network description: Solar Network Connect provide features connect with Solar Network
loadbefore: softdepend:
- CMI - CMI
- CMIEInjector - CMIEInjector
- CMILib - CMILib
@@ -17,7 +17,16 @@ commands:
aliases: ['sn'] aliases: ['sn']
permission: solar-network.command.sn permission: solar-network.command.sn
permission-message: §cYou don't have the permission -> §6[solar-network.command.sn] permission-message: §cYou don't have the permission -> §6[solar-network.command.sn]
eatrock:
description: Remove bedrock with payment
usage: §e/eatrock §r<money/materials>
aliases: ['eatrock']
permission: solar-network.command.eatrock
permission-message: §cYou don't have the permission -> §6[solar-network.command.eatrock]
permissions: permissions:
solar-network.command.sn: solar-network.command.sn:
description: Permission of uses Solar Network Command description: Permission of uses Solar Network Command
default: true default: true
solar-network.command.eatrock:
description: Permission to remove bedrock with payment
default: op

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
}