LTD-ManaagerBot/src/main/kotlin/top/r3944realms/ltdmanager/module/McServerStatusModule.kt
2025-10-31 23:47:15 +08:00

419 lines
17 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package top.r3944realms.ltdmanager.module
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import top.r3944realms.ltdmanager.mcserver.McServerStatus
import top.r3944realms.ltdmanager.module.common.CommandParser
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownManager
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownScope
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownStateProvider
import top.r3944realms.ltdmanager.module.common.filter.TriggerMessageFilter
import top.r3944realms.ltdmanager.module.common.filter.type.CommandFilter
import top.r3944realms.ltdmanager.module.common.filter.type.CooldownFilter
import top.r3944realms.ltdmanager.module.common.filter.type.IgnoreSelfFilter
import top.r3944realms.ltdmanager.module.common.filter.type.NewMessageFilter
import top.r3944realms.ltdmanager.napcat.NapCatClient
import top.r3944realms.ltdmanager.napcat.data.ID
import top.r3944realms.ltdmanager.napcat.data.MessageElement
import top.r3944realms.ltdmanager.napcat.data.MessageType
import top.r3944realms.ltdmanager.napcat.data.msghistory.MsgHistorySpecificMsg
import top.r3944realms.ltdmanager.napcat.request.message.SendForwardMsgRequest
import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.io.File
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
class McServerStatusModule(
moduleName: String,
private val groupMessagePollingModule: GroupMessagePollingModule,
private val selfId: Long,
private val selfNickName: String,
private val cooldownMillis: Long = 60_000L,
private val commands: List<String> = listOf("/mcs", "/s"),
private val presetServer: Map<Set<String>, String> = mapOf(
setOf("hp", "hypixel") to "mc.hypixel.net",
setOf("pm", "mineplex") to "play.mineplex.com"
)
) : BaseModule("McServerStatusModule", moduleName), PersistentState<McServerStatusModule.CooldownState> {
private val stateFile:File = getStateFileInternal("mc_server_status_state.json", name)
private val stateBackupFile:File = getStateFileInternal("mc_server_status_state.json.bak", name)
private val commandParser: CommandParser = CommandParser(commands)
private val cooldownManager by lazy {
CooldownManager(
cooldownMillis = cooldownMillis,
scope = CooldownScope.PerUser,
stateProvider = object : CooldownStateProvider<CooldownState> {
override fun load() = loadState()
override fun save(state: CooldownState) = saveState(state)
},
getLastTrigger = { state, qq ->
val detail = state.map[qq]
(detail?.time ?: -1L) to (detail?.lastCooldownRealId ?: -1L)
},
updateTrigger = { state, qq, realId, time ->
val id = requireNotNull(qq) { "userId required for per-user cooldown" }
state.updateLastTrigger(id, realId, time) }
,
updateCooldownRealId = { state, qq, realId ->
val id = requireNotNull(qq) { "userId required for per-user cooldown" }
state.updateLastCooldownRealId(id, realId)
},
groupId = groupMessagePollingModule.targetGroupId
)
}
private val triggerFilter = TriggerMessageFilter(
listOf(
IgnoreSelfFilter(selfId),
NewMessageFilter { qq ->
cooldownState.getLastTriggerTime(qq) to cooldownState.getLastTriggerRealId(qq)
},
CommandFilter(commandParser),
CooldownFilter(
cooldownManager = cooldownManager,
sendCooldown = { msg, remaining ->
sendCooldownMessage(napCatClient, msg.realId, "⏳ 查询过于频繁, $remaining 秒后执行查询,切勿重复发送")
}
)
)
)
private val presetServerByAlias: Map<String, String> by lazy {
presetServer.flatMap { (aliases, ip) ->
aliases.map { it.lowercase() to ip }
}.toMap()
}
fun getServerIp(alias: String): String? = presetServerByAlias[alias.lowercase()]
private var scope: CoroutineScope? = null
private val fileLock = ReentrantLock()
private var cooldownState = loadState()
override fun getStateFileInternal(): File = stateFile
override fun getState(): CooldownState = cooldownState
override fun onLoad() {
LoggerUtil.logger.info("[$name] 模块已装载,目标群组: ${groupMessagePollingModule.targetGroupId}")
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope!!.launch {
LoggerUtil.logger.info("[$name] 轮询协程启动")
groupMessagePollingModule.messagesFlow.collect { messages ->
if (loaded) handleMessages(messages)
}
}
}
override suspend fun onUnload() {
saveState(cooldownState)
scope?.cancel()
LoggerUtil.logger.info("[$name] 模块已卸载完成")
}
private suspend fun handleMessages(messages: List<MsgHistorySpecificMsg>) {
if (messages.isEmpty()) return
val triggerMsgs = filterTriggerMessages(messages)
if (triggerMsgs.isEmpty()) return
try {
triggerMsgs.forEach {
processCommand(it)
}
} catch (e: Exception) {
sendFailedMessage(napCatClient, text = "系统错误,请联系管理员: $e")
} finally {
saveState(cooldownState)
}
}
private suspend fun filterTriggerMessages(
messages: List<MsgHistorySpecificMsg>
): List<MsgHistorySpecificMsg> = triggerFilter.filter(messages)
private suspend fun sendFailedMessage(
client: NapCatClient,
qq: Long? = null,
realId: Long? = null,
time: Long? = null,
text: String = "失败消息"
) {
LoggerUtil.logger.info("[$name] 发送失败消息: realId=$realId, text=$text")
if (realId != null && qq != null && time != null) {
val request = SendGroupMsgRequest(
MessageElement.reply(ID.long(realId), text),
ID.long(groupMessagePollingModule.targetGroupId)
)
client.sendUnit(request)
LoggerUtil.logger.info("[$name] 已发送 失败消息")
// 更新触发的最大 realId
cooldownState = cooldownState.updateLastTrigger(qq, realId, time)
} else {
val request = SendGroupMsgRequest(
listOf(MessageElement.text(text)),
ID.long(groupMessagePollingModule.targetGroupId)
)
client.sendUnit(request)
LoggerUtil.logger.info("[$name] 已发送 失败消息[无指定对象]")
}
}
private suspend fun sendCooldownMessage(client: NapCatClient, realId: Long, text: String) {
val request = SendGroupMsgRequest(
MessageElement.reply(ID.long(realId), text),
ID.long(groupMessagePollingModule.targetGroupId)
)
client.sendUnit(request)
}
private suspend fun processCommand(msg: MsgHistorySpecificMsg) {
// 找出文本内容
val text = msg.message
.firstOrNull { it.type == MessageType.Text }
?.data?.text
?.trim()
?: return
// 使用命令解析器解析命令
val parsedCommand = commandParser.parseCommand(text) ?: return
val (_, address) = parsedCommand
// 使用预设别名替换
val finalAddress = if (address.isNotEmpty()) {
presetServerByAlias[address.lowercase()] ?: address
} else {
""
}
if (finalAddress.isEmpty()) {
sendFailedMessage(
napCatClient,
msg.userId,
msg.realId,
msg.time,
"❌ 请输入服务器地址,例如 /mcs n2.akiracloud.net:10599"
)
return
}
try {
val status = mcSrvStatusClient.getServerStatus(finalAddress)
if (!status.online) {
sendFailedMessage(
napCatClient, msg.userId, msg.realId, msg.time,
"❌ 查询失败,请检查服务器地址或服务器是否在线"
)
return
}
sendStatusForwardMessage(napCatClient, msg, finalAddress, status, msg.realId, msg.time)
} catch (e: Exception) {
LoggerUtil.logger.error("查询服务器状态失败: $address", e)
sendFailedMessage(
napCatClient,
msg.userId,
msg.realId,
msg.time,
"❌ 查询失败,请检查服务器地址或服务器是否在线"
)
}
}
// ---------------- 转发消息封装 ----------------
private suspend fun sendStatusForwardMessage(
client: NapCatClient,
msg: MsgHistorySpecificMsg,
address: String,
status: McServerStatus,
realId: Long,
time: Long
) {
LoggerUtil.logger.info("[$name] 发送服务器状态转发消息: realId=$realId, address=$address, online=${status.online}")
val messages = mutableListOf<SendForwardMsgRequest.Message>()
// ① 服务器基本信息 + MOTD
val motdText = status.motd?.clean?.joinToString("\n") ?: "无 MOTD"
val basicInfo = buildString {
appendLine("🌐 服务器: $address")
appendLine("".repeat(25))
appendLine("MOTD:\n$motdText")
}
messages.add(SendForwardMsgRequest.Message(SendForwardMsgRequest.PurpleData(basicInfo), MessageType.Text))
// ② 玩家列表
val playerList = status.players?.list?.joinToString("\n") { it.name } ?: ""
val playersInfo = buildString {
appendLine("📊 在线: ${status.players?.online ?: 0}/${status.players?.max ?: 0}")
appendLine("👥 玩家:\n$playerList")
}
messages.add(SendForwardMsgRequest.Message(SendForwardMsgRequest.PurpleData(playersInfo), MessageType.Text))
// ③ 版本 + 状态
val versionStatus = buildString {
appendLine("🎮 版本: ${status.version ?: "未知"}")
appendLine("✅ 状态: ${if (status.online) "在线" else "离线"}")
status.software?.let { appendLine("💻 软件: $it") }
}
messages.add(SendForwardMsgRequest.Message(SendForwardMsgRequest.PurpleData(versionStatus), MessageType.Text))
// ④ 摘要信息
val summaryText = buildString {
appendLine("📌 查询摘要")
appendLine("".repeat(20))
appendLine("服务器: $address")
appendLine("在线玩家: ${status.players?.online ?: 0}/${status.players?.max ?: 0}")
appendLine("状态: ${if (status.online) "在线" else "离线"}")
appendLine("🕐 ${getCurrentTime()}")
appendLine("🤖 由 $selfNickName 提供")
}
messages.add(SendForwardMsgRequest.Message(SendForwardMsgRequest.PurpleData(summaryText), MessageType.Text))
// 封装 Forward 消息
val topMessage = SendForwardMsgRequest.TopForwardMsg(
data = SendForwardMsgRequest.MessageData(
content = messages,
nickname = selfNickName,
userId = ID.long(selfId)
),
type = MessageType.Node
)
val request = SendForwardMsgRequest(
groupId = ID.long(groupMessagePollingModule.targetGroupId),
messages = listOf(topMessage),
news = listOf(
SendForwardMsgRequest.ForwardModelNews("点击查看服务器状态与玩家列表"),
SendForwardMsgRequest.ForwardModelNews("在线 ${status.players?.online ?: 0} / ${status.players?.max ?: 0}"),
SendForwardMsgRequest.ForwardModelNews("更新时间: ${getCurrentTime()}")
),
prompt = "服务器状态查询结果",
source = "🎮 服务器状态",
summary = "在线 ${status.players?.online ?: 0} / ${status.players?.max ?: 0}"
)
client.sendUnit(request)
LoggerUtil.logger.info("[$name] 已发送服务器状态转发消息")
// 更新冷却状态
cooldownState = cooldownState.updateLastTrigger(msg.userId, realId, time)
}
// 时间格式化
private fun getCurrentTime(): String {
return java.time.LocalDateTime.now()
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
}
// ---------------- 状态持久化 ----------------
@Serializable
data class CooldownState(
val map: Map<Long, TriggerDetail> = emptyMap()
) {
// 获取上次处理时间
fun getLastTriggerTime(qq: Long): Long = map[qq]?.time ?: -1
// 获取上次处理消息ID
fun getLastTriggerRealId(qq: Long): Long = map[qq]?.realId ?: -1
// 获取上次冷却消息ID
fun getLastCooldownRealId(qq: Long): Long = map[qq]?.lastCooldownRealId ?: -1
// 冷却结束,允许处理消息 → 更新 time 和 realId
fun updateLastTrigger(qq: Long, realId: Long, time: Long): CooldownState {
val old = map[qq]
val newMap = map.toMutableMap().apply {
put(qq, TriggerDetail(
realId = realId, // 当前允许处理消息ID
time = time, // 当前允许处理消息时间
lastCooldownRealId = old?.lastCooldownRealId ?: -1 // 保留冷却中记录的消息ID
))
}
return copy(map = newMap)
}
// 冷却中消息 → 只更新 lastCooldownRealId保留 time 和 realId
fun updateLastCooldownRealId(qq: Long, realId: Long): CooldownState {
val old = map[qq]
val newMap = map.toMutableMap().apply {
put(qq, TriggerDetail(
realId = old?.realId ?: -1, // 保持上次允许处理的消息ID
time = old?.time ?: -1, // 保持上次允许处理的时间
lastCooldownRealId = realId // 更新当前冷却拒绝的消息ID
))
}
return copy(map = newMap)
}
}
@Serializable
data class TriggerDetail(
val realId: Long, // 上次允许处理消息ID
val time: Long, // 上次允许处理消息时间(秒)
val lastCooldownRealId: Long = -1 // 上次被冷却拒绝的消息ID
)
override fun loadState(): CooldownState {
return try {
val fileToRead = when {
stateFile.exists() -> stateFile
stateBackupFile.exists() -> stateBackupFile
else -> null
}
if (fileToRead == null) return CooldownState()
val content = fileToRead.readText()
Json.decodeFromString(CooldownState.serializer(), content)
} catch (e: Exception) {
LoggerUtil.logger.warn("[$name] 状态恢复失败,使用默认值", e)
CooldownState()
}
}
override fun saveState(state: CooldownState) {
fileLock.withLock {
try {
val json = Json.encodeToString(CooldownState.serializer(), state)
if (stateFile.exists()) stateFile.copyTo(stateBackupFile, overwrite = true)
stateFile.writeText(json)
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 保存状态失败", e)
}
}
}
override fun info(): String {
return buildString {
appendLine("模块名称: $name")
appendLine("模块类型: McServerStatusModule")
appendLine("目标群组: ${groupMessagePollingModule.targetGroupId}")
appendLine("机器人昵称: $selfNickName (ID: $selfId)")
appendLine("冷却时间: ${cooldownMillis / 1000}")
appendLine("支持命令: ${commands.joinToString(", ")}")
appendLine("预设服务器别名:")
presetServer.forEach { (aliases, ip) ->
appendLine(" ${aliases.joinToString("/")} -> $ip")
}
appendLine("状态文件路径: ${stateFile.absolutePath}")
appendLine("状态备份文件路径: ${stateBackupFile.absolutePath}")
}
}
// 返回模块使用帮助
override fun help(): String = buildString {
appendLine("使用帮助 - McServerStatusModule")
appendLine("指令格式: /mcs <服务器别名或IP> 或 /s <服务器别名或IP>")
appendLine("示例:")
presetServerByAlias.forEach { (alias, ip) ->
appendLine(" /mcs $alias -> 查询服务器 $ip 状态")
}
appendLine("注意事项:")
appendLine(" - 查询冷却时间为 ${cooldownMillis / 1000}")
appendLine(" - 输入服务器 IP 或别名均可")
appendLine(" - 查询结果会以转发消息形式发送到群组")
}
}