Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions dotman-plugin/src/main/java/net/minevn/dotman/DotMan.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import net.minevn.libs.db.Transaction
import org.black_ixx.playerpoints.PlayerPoints
import org.bstats.bukkit.Metrics
import org.bukkit.Bukkit
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.*
import java.util.logging.Level

Expand Down Expand Up @@ -143,15 +145,19 @@ class DotMan : MineVNPlugin() {
runNotSync {
val balance = playerPoints.api.look(uuid)
val playerName = PlayerInfoDAO.getInstance().getName(uuidStr) ?: return@runNotSync
discord.list.forEach {
val content = it.contentTmpl
.replace("%PLAYER%", playerName)
.replace("%AMOUNT%", amount.format())
.replace("%POINT_AMOUNT%", pointAmount.toString())
.replace("%POINT_UNIT%", config.pointUnit)
.replace("%BALANCE%", balance.toString())
.replace("%METHOD%", type.typeName)
it.send(content)
val timeStr = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("HH:mm:ss dd/MM/yyyy"))
val replacements = mapOf(
"%PLAYER%" to playerName,
"%AMOUNT%" to amount.format(),
"%POINT_AMOUNT%" to pointAmount.toString(),
"%POINT_UNIT%" to config.pointUnit,
"%BALANCE%" to balance.toString(),
"%METHOD%" to type.typeName,
"%TIME%" to timeStr
)
discord.list.forEach { hook ->
hook.send(replacements)
}
}
}
Expand Down
45 changes: 45 additions & 0 deletions dotman-plugin/src/main/java/net/minevn/dotman/commands/AdminCmd.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import net.minevn.dotman.DotMan.Companion.transactional
import net.minevn.dotman.TopupType
import net.minevn.dotman.database.ConfigDAO
import net.minevn.dotman.database.LogDAO
import net.minevn.dotman.database.PlayerDataDAO
import net.minevn.dotman.database.PlayerInfoDAO
import net.minevn.dotman.utils.Utils.Companion.format
import net.minevn.dotman.utils.Utils.Companion.makePagination
Expand All @@ -28,6 +29,7 @@ class AdminCmd {
addSubCommand(history(), "lichsu", "history")
addSubCommand(napThuCong(), "napthucong", "manual")
addSubCommand(traCuuGiaoDich(), "tracuugd", "magiaodich")
addSubCommand(clearMilestoneData(), "cleardata")

action {
sender.sendMessage("§b§lCác lệnh của plugin DotMan")
Expand Down Expand Up @@ -314,6 +316,49 @@ class AdminCmd {
}
}

private fun clearMilestoneData() = command {
val usage = "<tên người chơi>"
description("Xoá dữ liệu top nạp thẻ/milestone của người chơi")

tabComplete {
when(args.size) {
0 -> emptyList()
1 -> Bukkit.getOnlinePlayers().map { it.name }
.filter { it.lowercase().startsWith(args.last().lowercase()) }
else -> emptyList()
}
}

action {
val infoDAO = PlayerInfoDAO.getInstance()
val dataDAO = PlayerDataDAO.getInstance()

if (args.isEmpty()) {
sender.send("§cCách dùng: /$commandTree $usage")
return@action
}
val playerName = args.first()

runNotSync { transactional {
try {
val uuid = infoDAO.getUUID(playerName)
?: run {
sender.send("§cKhông tìm thấy người chơi $playerName, có thể họ chưa vào server bao giờ?")
return@transactional
}

val donateKeyOrPattern = "DONATE_TOTAL_ALL%"
dataDAO.deleteDataByKeyLike(uuid, donateKeyOrPattern)

sender.send("§aĐã xoá dữ liệu top nạp thẻ/milestone của người chơi §b$playerName")
} catch (e: Exception) {
sender.send("§cCó lỗi xảy ra: ${e.message} (chi tiết hãy xem Console và báo lỗi cho MineVN Studio)")
throw e
}
}}
}
}

/**
* Removes and returns the first argument from the list if it exists and is not an option flag.
*
Expand Down
216 changes: 206 additions & 10 deletions dotman-plugin/src/main/java/net/minevn/dotman/config/Discord.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,217 @@ import net.minevn.libs.post
import net.minevn.libs.toJson

class Discord : FileConfig("discord") {
class WebHook(val url: String, val contentTmpl: String) {
fun send(content: String) {
val dto = DTO(content).toJson()
runCatching { post(url, "application/json", dto) }

/**
* Payload gửi lên Discord webhook.
*/
data class Payload(
val content: String? = null,
val username: String? = null,
val avatarUrl: String? = null,
val embeds: List<Embed>? = null
)

/**
* Định nghĩa một Embed chuẩn của Discord.
*/
data class Embed(
val title: String? = null,
val description: String? = null,
val timestamp: String? = null,
val color: Int? = null,
val footer: Footer? = null,
val image: Image? = null,
val thumbnail: Thumbnail? = null,
val author: Author? = null,
val fields: List<Field>? = null
)

/** Author của embed */
data class Author(
val name: String? = null,
val url: String? = null,
val iconUrl: String? = null
)

/** Footer của embed */
data class Footer(
val text: String? = null,
val iconUrl: String? = null
)

/** Ảnh cỡ lớn */
data class Image(val url: String? = null)
/** Ảnh thumbnail nhỏ */
data class Thumbnail(val url: String? = null)
/** Field của embed */
data class Field(val name: String? = null, val value: String? = null, val inline: Boolean? = null)

/**
* Đại diện một webhook hoàn chỉnh để gửi lên discord.
*/
inner class WebHook(private val url: String, private val payloadTmpl: Payload) {
fun send(replacements: Map<String, String>) {
val applied = this@Discord.run { payloadTmpl.applyReplacements(replacements) }
val json = applied.toDiscordMap().toJson()
runCatching { post(url, "application/json", json) }
.onFailure { it.warning("Discord webhook failed") }
}
}

data class DTO(val content: String)
/** Danh sách webhook đã bật */
val list: List<WebHook> = (config.getList("discord-hooks") ?: emptyList()).mapNotNull { raw ->
val map = raw as? Map<*, *> ?: return@mapNotNull null
val enabled = map["enabled"] as? Boolean ?: true
if (!enabled) return@mapNotNull null
val url = map["url"] as? String ?: return@mapNotNull null
val payloadMap = map["payload"] as? Map<*, *> ?: return@mapNotNull null
WebHook(url, mapToPayload(payloadMap))
}

// region mapping helpers

/** Map YAML sang Payload. Chấp nhận content dạng String hoặc List<String>. */
private fun mapToPayload(map: Map<*, *>): Payload = Payload(
content = asStringOrJoin(map["content"]),
username = map["username"] as? String,
avatarUrl = map["avatarUrl"] as? String,
embeds = (map["embeds"] as? List<*>)?.mapNotNull { e -> (e as? Map<*, *>)?.let { mapToEmbed(it) } }
)

/**
* Map YAML -> Embed. Hỗ trợ description là String hoặc List<String>.
*/
private fun mapToEmbed(map: Map<*, *>): Embed = Embed(
title = map["title"] as? String,
description = asStringOrJoin(map["description"]),
timestamp = map["timestamp"] as? String,
color = parseColor(map["color"]),
footer = (map["footer"] as? Map<*, *>)?.let {
Footer(text = it["text"] as? String, iconUrl = it["iconUrl"] as? String)
},
image = (map["image"] as? Map<*, *>)?.let { Image(url = it["url"] as? String) },
thumbnail = (map["thumbnail"] as? Map<*, *>)?.let { Thumbnail(url = it["url"] as? String) },
author = (map["author"] as? Map<*, *>)?.let {
Author(name = it["name"] as? String, url = it["url"] as? String, iconUrl = it["iconUrl"] as? String)
},
fields = (map["fields"] as? List<*>)?.mapNotNull { f ->
(f as? Map<*, *>)?.let {
Field(
name = it["name"] as? String,
value = it["value"] as? String,
inline = when (val b = it["inline"]) {
is Boolean -> b
is String -> b.equals("true", true)
else -> null
}
)
}
}
)

/** Trả về chuỗi từ String hoặc List<String> (nối bằng \n). */
private fun asStringOrJoin(value: Any?): String? = when (value) {
is String -> value
is List<*> -> value.filterIsInstance<String>().joinToString("\n").takeIf { it.isNotEmpty() }
else -> null
}

val list: List<WebHook> = (config.getList("discord-hooks") ?: emptyList()).map {
it as Map<*, *>
if (it["enabled"] != true) return@map null
WebHook(it["url"] as String, it["content"] as String)
}.filterNotNull()
/** Replace placeholder */
private fun String.applyReplacements(repl: Map<String, String>): String {
var s = this
for ((k, v) in repl) s = s.replace(k, v)
return s
}

/** Replace placeholder cho payload và các embed con */
private fun Payload.applyReplacements(repl: Map<String, String>): Payload = this.copy(
content = content?.applyReplacements(repl),
username = username?.applyReplacements(repl),
avatarUrl = avatarUrl?.applyReplacements(repl),
embeds = embeds?.map { it.applyReplacements(repl) }
)

/** Replace placeholder cho embed và các field */
private fun Embed.applyReplacements(repl: Map<String, String>): Embed = this.copy(
title = title?.applyReplacements(repl),
description = description?.applyReplacements(repl),
timestamp = timestamp?.applyReplacements(repl),
footer = footer?.let { Footer(it.text?.applyReplacements(repl),
it.iconUrl?.applyReplacements(repl)) },
image = image?.let { Image(it.url?.applyReplacements(repl)) },
thumbnail = thumbnail?.let { Thumbnail(it.url?.applyReplacements(repl)) },
author = author?.let { Author(it.name?.applyReplacements(repl),
it.url?.applyReplacements(repl),
it.iconUrl?.applyReplacements(repl)) },
fields = fields?.map { Field(it.name?.applyReplacements(repl),
it.value?.applyReplacements(repl),
it.inline) }
)

/**
* Convert màu từ mã hex trong config:
* - Chấp nhận dạng "#RRGGBB" hoặc "RRGGBB" (không phân biệt hoa thường)
* - Nếu sai định dạng -> trả về 0xFFFFFF (trắng)
* - Nếu không có trường color -> trả về null (không set màu)
*/
private fun parseColor(value: Any?): Int? {
if (value == null) return null
val raw = (value as? String)?.trim() ?: run {
warning("discord.yml: 'color' phải là chuỗi hex dạng #RRGGBB, plugin sẽ dùng mặc định #FFFFFF")
return 0xFFFFFF
}
val match = Regex("^#?([0-9a-fA-F]{6})$").matchEntire(raw)
val hex = match?.groupValues?.get(1)
return if (hex != null) {
try {
hex.toInt(16)
} catch (_: Exception) {
warning("discord.yml: 'color' không hợp lệ, plugin sẽ dùng mặc định #FFFFFF")
0xFFFFFF
}
} else {
warning("discord.yml: 'color' phải là chuỗi hex dạng #RRGGBB, plugin sẽ dùng mặc định #FFFFFF")
0xFFFFFF
}
}

/**
* Chuyển Payload sang Map có key theo chuẩn Discord (snake_case).
*/
private fun Payload.toDiscordMap(): Map<String, Any?> = buildMap {
put("content", content)
put("username", username)
put("avatar_url", avatarUrl)
put("embeds", embeds?.map { it.toDiscordMap() })
}

/** Chuyển Embed sang Map theo chuẩn Discord (snake_case). */
private fun Embed.toDiscordMap(): Map<String, Any?> = buildMap {
put("title", title)
put("description", description)
put("timestamp", timestamp)
put("color", color)
footer?.let { put("footer", buildMap {
put("text", it.text)
put("icon_url", it.iconUrl)
}) }
image?.let { put("image", buildMap { put("url", it.url) }) }
thumbnail?.let { put("thumbnail", buildMap { put("url", it.url) }) }
author?.let { put("author", buildMap {
put("name", it.name)
put("url", it.url)
put("icon_url", it.iconUrl)
}) }
fields?.let { list ->
put("fields", list.map { f -> buildMap {
put("name", f.name)
put("value", f.value)
if (f.inline != null) put("inline", f.inline)
} })
}
}
// endregion

init {
info("Đã nạp ${list.size} webhook discord.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ abstract class PlayerDataDAO : DataAccess() {
abstract fun getDataScript(): String
abstract fun getSumDataScript(): String
abstract fun getAllDataScript(): String
abstract fun deleteDataByKeyLikeScript(): String

fun insertData(uuid: String, key: String, value: Int) {
insertDataScript().statement {
Expand Down Expand Up @@ -68,4 +69,12 @@ abstract class PlayerDataDAO : DataAccess() {
}.toMap()
}
}

fun deleteDataByKeyLike(uuid: String, likePattern: String): Int {
return deleteDataByKeyLikeScript().statement {
setString(1, uuid)
setString(2, likePattern)
update()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,9 @@ class PlayerDataDAOImpl : PlayerDataDAO() {
FROM "dotman_player_data"
WHERE "uuid" = ?;
""".trimIndent()

override fun deleteDataByKeyLikeScript() = """
DELETE FROM "dotman_player_data"
WHERE "uuid" = ? AND "key" LIKE ?;
""".trimIndent()
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,9 @@ class PlayerDataDAOImpl : PlayerDataDAO() {
FROM `dotman_player_data`
WHERE `uuid` = ?;
""".trimIndent()

override fun deleteDataByKeyLikeScript() = """
DELETE FROM `dotman_player_data`
WHERE `uuid` = ? AND `key` LIKE ?;
""".trimIndent()
}
Loading