From 636d492f711e7c3d7769ef9c91d967a6b212c488 Mon Sep 17 00:00:00 2001 From: Minh Nguyen Date: Wed, 27 Aug 2025 23:11:36 +0700 Subject: [PATCH 1/3] =?UTF-8?q?Th=C3=AAm=20l=E1=BB=87nh=20xo=C3=A1=20d?= =?UTF-8?q?=E1=BB=AF=20li=E1=BB=87u=20top=20n=E1=BA=A1p=20th=E1=BA=BB/mile?= =?UTF-8?q?stone=20c=E1=BB=A7a=20ng=C6=B0=E1=BB=9Di=20ch=C6=A1i?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../net/minevn/dotman/commands/AdminCmd.kt | 45 +++++++++++++++++++ .../minevn/dotman/database/PlayerDataDAO.kt | 9 ++++ .../dotman/database/h2/PlayerDataDAOImpl.kt | 5 +++ .../database/mysql/PlayerDataDAOImpl.kt | 5 +++ 4 files changed, 64 insertions(+) diff --git a/dotman-plugin/src/main/java/net/minevn/dotman/commands/AdminCmd.kt b/dotman-plugin/src/main/java/net/minevn/dotman/commands/AdminCmd.kt index 415a8dc..e05096a 100644 --- a/dotman-plugin/src/main/java/net/minevn/dotman/commands/AdminCmd.kt +++ b/dotman-plugin/src/main/java/net/minevn/dotman/commands/AdminCmd.kt @@ -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 @@ -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") @@ -314,6 +316,49 @@ class AdminCmd { } } + private fun clearMilestoneData() = command { + val usage = "" + 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. * diff --git a/dotman-plugin/src/main/java/net/minevn/dotman/database/PlayerDataDAO.kt b/dotman-plugin/src/main/java/net/minevn/dotman/database/PlayerDataDAO.kt index 6cabfbc..37b2ac0 100644 --- a/dotman-plugin/src/main/java/net/minevn/dotman/database/PlayerDataDAO.kt +++ b/dotman-plugin/src/main/java/net/minevn/dotman/database/PlayerDataDAO.kt @@ -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 { @@ -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() + } + } } diff --git a/dotman-plugin/src/main/java/net/minevn/dotman/database/h2/PlayerDataDAOImpl.kt b/dotman-plugin/src/main/java/net/minevn/dotman/database/h2/PlayerDataDAOImpl.kt index 3493b8b..b2d9d60 100644 --- a/dotman-plugin/src/main/java/net/minevn/dotman/database/h2/PlayerDataDAOImpl.kt +++ b/dotman-plugin/src/main/java/net/minevn/dotman/database/h2/PlayerDataDAOImpl.kt @@ -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() } diff --git a/dotman-plugin/src/main/java/net/minevn/dotman/database/mysql/PlayerDataDAOImpl.kt b/dotman-plugin/src/main/java/net/minevn/dotman/database/mysql/PlayerDataDAOImpl.kt index 8046463..92a0478 100644 --- a/dotman-plugin/src/main/java/net/minevn/dotman/database/mysql/PlayerDataDAOImpl.kt +++ b/dotman-plugin/src/main/java/net/minevn/dotman/database/mysql/PlayerDataDAOImpl.kt @@ -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() } From 6f5303a6bd2c0c4767ac7d1e949387cf1c358f1e Mon Sep 17 00:00:00 2001 From: Minh Nguyen Date: Sun, 12 Oct 2025 15:40:10 +0700 Subject: [PATCH 2/3] =?UTF-8?q?H=E1=BB=97=20tr=E1=BB=A3=20discord=20webhoo?= =?UTF-8?q?k=20embed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/net/minevn/dotman/DotMan.kt | 24 +- .../java/net/minevn/dotman/config/Discord.kt | 216 +++++++++++++++++- dotman-plugin/src/main/resources/discord.yml | 52 ++++- 3 files changed, 265 insertions(+), 27 deletions(-) diff --git a/dotman-plugin/src/main/java/net/minevn/dotman/DotMan.kt b/dotman-plugin/src/main/java/net/minevn/dotman/DotMan.kt index a00125c..ab75d7a 100644 --- a/dotman-plugin/src/main/java/net/minevn/dotman/DotMan.kt +++ b/dotman-plugin/src/main/java/net/minevn/dotman/DotMan.kt @@ -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 @@ -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) } } } diff --git a/dotman-plugin/src/main/java/net/minevn/dotman/config/Discord.kt b/dotman-plugin/src/main/java/net/minevn/dotman/config/Discord.kt index 7d71c69..eaa2f50 100644 --- a/dotman-plugin/src/main/java/net/minevn/dotman/config/Discord.kt +++ b/dotman-plugin/src/main/java/net/minevn/dotman/config/Discord.kt @@ -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? = 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? = 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) { + 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 = (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. */ + 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. + */ + 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 (nối bằng \n). */ + private fun asStringOrJoin(value: Any?): String? = when (value) { + is String -> value + is List<*> -> value.filterIsInstance().joinToString("\n").takeIf { it.isNotEmpty() } + else -> null + } - val list: List = (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 { + 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): 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): 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 = 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 = 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.") diff --git a/dotman-plugin/src/main/resources/discord.yml b/dotman-plugin/src/main/resources/discord.yml index 358497e..ed05abc 100644 --- a/dotman-plugin/src/main/resources/discord.yml +++ b/dotman-plugin/src/main/resources/discord.yml @@ -1,12 +1,48 @@ -# Bạn có thể thêm nhiều discord webhook url cùng lúc. -# Điều chỉnh enabled thành true để bật +# Cấu hình Discord Webhook cho DotMan +# - Chỉ hỗ trợ payload đầy đủ (bao gồm embed). Không còn hỗ trợ cấu hình cũ chỉ text. +# - Trường enabled mặc định là true nếu không khai báo. Đặt false để tạm tắt webhook. +# - Có thể sử dụng placeholder sau trong content/embeds: +# %PLAYER%, %AMOUNT%, %POINT_AMOUNT%, %POINT_UNIT%, %BALANCE%, %METHOD%, %TIME% +# - Màu (color) phải là chuỗi hex dạng "#RRGGBB" (ví dụ: "#58b9ff"). Nếu sai định dạng sẽ tự chuyển về trắng "#FFFFFF". +# Bạn có thể dùng https://discohook.org/ để thiết kế discord-hooks: - enabled: false url: https://discord.com/api/webhooks/123456/webhook-token - content: | - ## Nạp thẻ - **%PLAYER%** vừa nạp **%AMOUNT% VNĐ** - Nhận được: %POINT_AMOUNT% %POINT_UNIT% - Số dư sau khi nạp: %BALANCE% %POINT_UNIT% - Hình thức nạp: %METHOD% + payload: + username: "MineVN" + avatarUrl: "https://i.imgur.com/tdZ2LxY.png" + content: + - "Thông báo nạp tiền thành công!" + - "Thời gian: %TIME%" + + embeds: + - title: "Ting ting 💸" + description: + - "Xin cảm ơn bạn đã ủng hộ server!" + color: "#58b9ff" + fields: + - name: "Người chơi" + value: "%PLAYER%" + inline: true + - name: "Số tiền" + value: "%AMOUNT% VNĐ" + inline: true + - name: "Thực nhận" + value: "%POINT_AMOUNT% %POINT_UNIT% (đang có %BALANCE% %POINT_UNIT%)" + inline: true + - name: "Phương thức" + value: "%METHOD%" + inline: true + + image: + url: "https://i.imgur.com/4g5sEWu.png" + thumbnail: + url: "https://i.imgur.com/tdZ2LxY.png" + author: + name: "MineVN" + url: "https://minevn.studio/" + iconUrl: "https://i.imgur.com/tdZ2LxY.png" + footer: + text: "%TIME%" + iconUrl: "https://i.imgur.com/tdZ2LxY.png" \ No newline at end of file From 57675fc33525ff00d5f5fe5874787d49eff5ced4 Mon Sep 17 00:00:00 2001 From: Minh Nguyen Date: Sun, 12 Oct 2025 15:40:10 +0700 Subject: [PATCH 3/3] =?UTF-8?q?H=E1=BB=97=20tr=E1=BB=A3=20discord=20webhoo?= =?UTF-8?q?k=20embed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/net/minevn/dotman/DotMan.kt | 24 +- .../java/net/minevn/dotman/config/Discord.kt | 216 +++++++++++++++++- dotman-plugin/src/main/resources/discord.yml | 52 ++++- 3 files changed, 265 insertions(+), 27 deletions(-) diff --git a/dotman-plugin/src/main/java/net/minevn/dotman/DotMan.kt b/dotman-plugin/src/main/java/net/minevn/dotman/DotMan.kt index a00125c..ab75d7a 100644 --- a/dotman-plugin/src/main/java/net/minevn/dotman/DotMan.kt +++ b/dotman-plugin/src/main/java/net/minevn/dotman/DotMan.kt @@ -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 @@ -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) } } } diff --git a/dotman-plugin/src/main/java/net/minevn/dotman/config/Discord.kt b/dotman-plugin/src/main/java/net/minevn/dotman/config/Discord.kt index 7d71c69..eaa2f50 100644 --- a/dotman-plugin/src/main/java/net/minevn/dotman/config/Discord.kt +++ b/dotman-plugin/src/main/java/net/minevn/dotman/config/Discord.kt @@ -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? = 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? = 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) { + 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 = (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. */ + 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. + */ + 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 (nối bằng \n). */ + private fun asStringOrJoin(value: Any?): String? = when (value) { + is String -> value + is List<*> -> value.filterIsInstance().joinToString("\n").takeIf { it.isNotEmpty() } + else -> null + } - val list: List = (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 { + 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): 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): 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 = 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 = 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.") diff --git a/dotman-plugin/src/main/resources/discord.yml b/dotman-plugin/src/main/resources/discord.yml index 358497e..ed05abc 100644 --- a/dotman-plugin/src/main/resources/discord.yml +++ b/dotman-plugin/src/main/resources/discord.yml @@ -1,12 +1,48 @@ -# Bạn có thể thêm nhiều discord webhook url cùng lúc. -# Điều chỉnh enabled thành true để bật +# Cấu hình Discord Webhook cho DotMan +# - Chỉ hỗ trợ payload đầy đủ (bao gồm embed). Không còn hỗ trợ cấu hình cũ chỉ text. +# - Trường enabled mặc định là true nếu không khai báo. Đặt false để tạm tắt webhook. +# - Có thể sử dụng placeholder sau trong content/embeds: +# %PLAYER%, %AMOUNT%, %POINT_AMOUNT%, %POINT_UNIT%, %BALANCE%, %METHOD%, %TIME% +# - Màu (color) phải là chuỗi hex dạng "#RRGGBB" (ví dụ: "#58b9ff"). Nếu sai định dạng sẽ tự chuyển về trắng "#FFFFFF". +# Bạn có thể dùng https://discohook.org/ để thiết kế discord-hooks: - enabled: false url: https://discord.com/api/webhooks/123456/webhook-token - content: | - ## Nạp thẻ - **%PLAYER%** vừa nạp **%AMOUNT% VNĐ** - Nhận được: %POINT_AMOUNT% %POINT_UNIT% - Số dư sau khi nạp: %BALANCE% %POINT_UNIT% - Hình thức nạp: %METHOD% + payload: + username: "MineVN" + avatarUrl: "https://i.imgur.com/tdZ2LxY.png" + content: + - "Thông báo nạp tiền thành công!" + - "Thời gian: %TIME%" + + embeds: + - title: "Ting ting 💸" + description: + - "Xin cảm ơn bạn đã ủng hộ server!" + color: "#58b9ff" + fields: + - name: "Người chơi" + value: "%PLAYER%" + inline: true + - name: "Số tiền" + value: "%AMOUNT% VNĐ" + inline: true + - name: "Thực nhận" + value: "%POINT_AMOUNT% %POINT_UNIT% (đang có %BALANCE% %POINT_UNIT%)" + inline: true + - name: "Phương thức" + value: "%METHOD%" + inline: true + + image: + url: "https://i.imgur.com/4g5sEWu.png" + thumbnail: + url: "https://i.imgur.com/tdZ2LxY.png" + author: + name: "MineVN" + url: "https://minevn.studio/" + iconUrl: "https://i.imgur.com/tdZ2LxY.png" + footer: + text: "%TIME%" + iconUrl: "https://i.imgur.com/tdZ2LxY.png" \ No newline at end of file