From 9f83026e5624fec84a4b21f6f2524a80c6b6fbdb Mon Sep 17 00:00:00 2001 From: 3944Realms Date: Sat, 13 Sep 2025 03:26:07 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3BanModule=20=E4=B8=AD?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E9=A1=BA=E5=BA=8F=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=9A=84NPE=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 、 --- gradle.properties | 2 +- .../kotlin/top/r3944realms/ltdmanager/main.kt | 41 ++- .../ltdmanager/module/BanModule.kt | 42 ++- .../ltdmanager/module/BaseModule.kt | 16 +- .../module/GroupMessagePollingModule.kt | 7 +- .../module/GroupRequestHandlerModule.kt | 8 +- .../ltdmanager/module/HelpModule.kt | 245 +++++++++++++++++- .../module/InvitationCodesModule.kt | 150 +++++++---- .../ltdmanager/module/MailModule.kt | 37 ++- .../ltdmanager/module/McServerStatusModule.kt | 197 ++++++++------ .../module/ModGroupHandlerModule.kt | 207 ++++++++++++++- .../ltdmanager/module/ModuleManager.kt | 10 + .../ltdmanager/module/PersistentState.kt | 7 +- .../ltdmanager/module/RconPlayerListModule.kt | 214 ++++++++------- .../ltdmanager/module/common/CommandParser.kt | 7 +- .../module/common/cooldown/CooldownManager.kt | 66 ++++- .../module/common/cooldown/CooldownResult.kt | 13 +- .../module/common/cooldown/CooldownScope.kt | 6 +- .../common/cooldown/CooldownStateProvider.kt | 2 +- .../module/common/filter/MessageFilter.kt | 3 + .../common/filter/TriggerMessageFilter.kt | 55 +--- .../common/filter/type/CommandFilter.kt | 13 +- .../common/filter/type/CooldownFilter.kt | 17 +- .../common/filter/type/IgnoreSelfFilter.kt | 9 +- .../common/filter/type/KeywordFilter.kt | 12 +- .../common/filter/type/NewMessageFilter.kt | 16 +- .../ltdmanager/utils/FileNameFilter.kt | 116 +++++++++ .../ltdmanager/utils/SystemType.kt | 4 + .../ltdmanagertest/mail/MailTest.kt | 3 +- .../ltdmanagertest/misc/StringTest.kt | 10 + .../top/r394realms/ltdmanagertest/test.kt | 1 + 31 files changed, 1200 insertions(+), 336 deletions(-) diff --git a/gradle.properties b/gradle.properties index 3d986a5..60a346b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,4 +3,4 @@ org.gradle.downloadSources=false org.gradle.parallel=true org.gradle.degree_of_parallelism=16 project_group=top.r3944realms.ltdmanager -project_version=1.3-SNAPSHOT +project_version=1.6-SNAPSHOT diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/main.kt b/src/main/kotlin/top/r3944realms/ltdmanager/main.kt index 5431180..fa20ac2 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/main.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/main.kt @@ -11,16 +11,25 @@ fun main() = GlobalManager.runBlockingMain { val selfNickName = "闲趣老土豆" // 创建模块实例 val groupModule = GroupRequestHandlerModule( + moduleName = "WhiteListGroup", client = GlobalManager.napCatClient, targetGroupId = groupId ) val groupMsgPollingModule = GroupMessagePollingModule( + moduleName = "WhiteListGroup", targetGroupId = groupId, pollIntervalMillis = 5_000L, msgHistoryCheck = 15 ) + val helpModule = HelpModule( + moduleName = "WhiteListGroup", + groupMessagePollingModule = groupMsgPollingModule, + selfId = selfQQId, + selfNickName = selfNickName, + ) val toolConfig = YamlConfigLoader.loadToolConfig() val rconModule = RconPlayerListModule( + moduleName = "WhiteListGroup", groupMessagePollingModule = groupMsgPollingModule, rconTimeOut = 2_000L, cooldownMillis = 10_000L, @@ -38,13 +47,15 @@ fun main() = GlobalManager.runBlockingMain { ) val mailConfig = YamlConfigLoader.loadMailConfig() val mailModule = MailModule( - host = mailConfig.host.toString(), - authToken = mailConfig.decryptedPassword.toString(), - port = mailConfig.port!!, - senderEmailAddress = mailConfig.mailAddress!!, + moduleName = "WhiteListGroup", + host = mailConfig.host.toString(), + authToken = mailConfig.decryptedPassword.toString(), + port = mailConfig.port!!, + senderEmailAddress = mailConfig.mailAddress!!, ) val blessingSkinConfig = YamlConfigLoader.loadBlessingSkinServerConfig() val invitationCodesModule = InvitationCodesModule( + moduleName = "WhiteListGroup", groupMessagePollingModule = groupMsgPollingModule, mailModule = mailModule, apiToken = blessingSkinConfig.invitationApi?.decryptedToken!!, @@ -57,9 +68,10 @@ fun main() = GlobalManager.runBlockingMain { ) ) val mcServerStatusModule = McServerStatusModule( + moduleName = "WhiteListGroup", groupMessagePollingModule = groupMsgPollingModule, selfId = selfQQId, - cooldownSeconds = 20, + cooldownMillis = 20_000L, selfNickName = selfNickName, commands = listOf("/m", "/mcs", "seek", "s"), presetServer = mapOf( @@ -67,6 +79,19 @@ fun main() = GlobalManager.runBlockingMain { setOf("土豆", "老土豆", "七周目", "7" ,"ZZ", "zz", "Zz", "seven") to "main.mmccdd.top:11106", ) ) + val banModule = BanModule( + moduleName = "WhiteListGroup", + groupMessagePollingModule = groupMsgPollingModule, + selfId = selfQQId, + commandPrefixList = listOf("口球", "mute", "杂鱼三九"), + minBanMinutes = 1, + maxBanMinutes = 15, + ) + val modGroupHandlerModule = ModGroupHandlerModule( + moduleName = "ModGrouup", + targetGroupId = 339340846, + answers = listOf("戏鸢", "一只戏鸢", "折戏鸢", "LostInLinearPast", "lostinlinearpast"), + ) // 注册模块到全局模块管理器 GlobalManager.moduleManager.registerModule(groupModule) @@ -75,6 +100,9 @@ fun main() = GlobalManager.runBlockingMain { GlobalManager.moduleManager.registerModule(rconModule) GlobalManager.moduleManager.registerModule(mailModule) GlobalManager.moduleManager.registerModule(invitationCodesModule) + GlobalManager.moduleManager.registerModule(helpModule) + GlobalManager.moduleManager.registerModule(banModule) + GlobalManager.moduleManager.registerModule(modGroupHandlerModule) // 加载模块 GlobalManager.moduleManager.loadModule(groupModule.name) @@ -83,4 +111,7 @@ fun main() = GlobalManager.runBlockingMain { GlobalManager.moduleManager.loadModule(rconModule.name) GlobalManager.moduleManager.loadModule(mailModule.name) GlobalManager.moduleManager.loadModule(invitationCodesModule.name) + GlobalManager.moduleManager.loadModule(helpModule.name) + GlobalManager.moduleManager.loadModule(banModule.name) + GlobalManager.moduleManager.loadModule(modGroupHandlerModule.name) } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/BanModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/BanModule.kt index 3199dfd..3aac270 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/BanModule.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/BanModule.kt @@ -21,33 +21,36 @@ import kotlin.random.Random /** * 指令触发禁言模块 */ -class CommandBanModule( +class BanModule( moduleName: String, private val groupMessagePollingModule : GroupMessagePollingModule, private val selfId: Long, commandPrefixList: List = listOf("/mute"), // 默认命令前缀 private val minBanMinutes: Int = 1, private val maxBanMinutes: Int = 15 -) : BaseModule("CommandBanModule", moduleName), PersistentState { +) : BaseModule("BanModule", moduleName), PersistentState { private val commandParser = CommandParser(commandPrefixList) private val commandFilter = CommandFilter(commandParser) - private val banState = loadState() + private val stateFile: File = getStateFileInternal("command_ban_state.json", name) + private val stateBackupFile: File = getStateFileInternal("command_ban_state.json.bak", name) + private var banState = loadState() override fun getState(): BanState = banState private val triggerFilter by lazy { TriggerMessageFilter( listOf( IgnoreSelfFilter(selfId), - NewMessageFilter { _ -> banState.lastTriggerTime to banState.lastTriggerRealId }, + NewMessageFilter { userId -> + banState.getLastTriggerTime(userId) to banState.getLastTriggerRealId(userId) + }, commandFilter ) ) } private var scope: CoroutineScope? = null - private val stateFile: File = getStateFileInternal("command_ban_state.json", name) - private val stateBackupFile: File = getStateFileInternal("command_ban_state.json.bak", name) + override fun getStateFileInternal(): File = stateFile @@ -105,8 +108,8 @@ class CommandBanModule( sendGroupMessage("✅ 你已被禁言 $durationMinutes 分钟", msg.realId) // 更新状态(保证状态保存正确) - banState.lastTriggerRealId = msg.realId - banState.lastTriggerTime = msg.time + // 禁言成功后更新状态 + banState = banState.updateLastTrigger(targetUserId, msg.realId, msg.time) saveState(banState) } catch (e: Exception) { @@ -152,11 +155,28 @@ class CommandBanModule( // ---------------- 持久化 ---------------- @Serializable - data class BanState( - var lastTriggerRealId: Long = -1, - var lastTriggerTime: Long = 0 + data class UserBanDetail( + val realId: Long, + val time: Long, ) + @Serializable + data class BanState( + val map: Map = emptyMap() + ) { + fun getLastTriggerTime(userId: Long): Long = map[userId]?.time ?: -1 + fun getLastTriggerRealId(userId: Long): Long = map[userId]?.realId ?: -1 + + fun updateLastTrigger(userId: Long, realId: Long, time: Long = -1): BanState { + val old = map[userId] + val newTime = if (time != -1L) time else old?.time ?: -1 + val newMap = map.toMutableMap().apply { + put(userId, UserBanDetail(realId, newTime)) + } + return copy(map = newMap) + } + } + override fun saveState(state: BanState) { try { if (stateFile.exists()) stateFile.copyTo(stateBackupFile, overwrite = true) diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/BaseModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/BaseModule.kt index 5bba8ce..bba80a6 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/BaseModule.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/BaseModule.kt @@ -9,12 +9,12 @@ import kotlin.coroutines.cancellation.CancellationException * 模块抽象基类 * 所有功能模块都继承该类 */ -abstract class BaseModule { +abstract class BaseModule(baseName : String = "BaseModule", idName : String = "") { /** * 模块名称 */ - abstract val name: String + val name: String = "$baseName-#$idName"; /** * 停止信号 @@ -73,6 +73,13 @@ abstract class BaseModule { } catch (_: CancellationException) {} LoggerUtil.syncInfo("[$name] 模块已安全停止") } + /** + * 模块说明 / 帮助信息 + * 默认返回空字符串,子类可重写提供具体帮助文本 + */ + open fun help(): String = "" + /** 模块基础信息,用于 HelpModule 显示 */ + open fun info(): String = "模块 $name 未提供详细信息" /** * 提供访问全局 NapCatClient 的快捷方式 */ @@ -85,9 +92,14 @@ abstract class BaseModule { * 提供访问全局 mcSrvStatusClient 的快捷方式 */ protected val mcSrvStatusClient get() = GlobalManager.mcSrvStatusClient + /** + * 提供访问全局 加载模块 的快捷方式 + */ + protected val moduleMap get() = GlobalManager.moduleManager.getModules() /** * 获取数据库连接 * 使用 try-with-resources 时会自动关闭 */ protected fun getConnection() = GlobalManager.getConnection() + } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/GroupMessagePollingModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/GroupMessagePollingModule.kt index 43eb130..a987e99 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/GroupMessagePollingModule.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/GroupMessagePollingModule.kt @@ -11,12 +11,11 @@ import top.r3944realms.ltdmanager.napcat.request.message.GetGroupMsgHistoryReque import top.r3944realms.ltdmanager.utils.LoggerUtil class GroupMessagePollingModule( + moduleName: String, val targetGroupId: Long, private val pollIntervalMillis: Long = 5_000L, - private val msgHistoryCheck: Int = 15 -) : BaseModule() { - - override val name: String = "MessagePollingModule" + private val msgHistoryCheck: Int = 15, +) : BaseModule("MessagePollingModule", moduleName) { private var scope: CoroutineScope? = null // 用 Flow 存消息,其他模块可以订阅 diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/GroupRequestHandlerModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/GroupRequestHandlerModule.kt index 4ae8c4a..4facc32 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/GroupRequestHandlerModule.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/GroupRequestHandlerModule.kt @@ -11,12 +11,11 @@ import top.r3944realms.ltdmanager.napcat.request.group.SetGroupAddRequestRequest import top.r3944realms.ltdmanager.utils.LoggerUtil class GroupRequestHandlerModule( + moduleName: String, private val client: NapCatClient, private val targetGroupId: Long, private val pollIntervalMillis: Long = 30_000L, -) : BaseModule() { - - override val name: String = "GroupRequestHandlerModule" +) : BaseModule("GroupRequestHandlerModule", moduleName) { private var scope: CoroutineScope? = null @@ -176,4 +175,7 @@ class GroupRequestHandlerModule( return data.invitedRequest + data.joinRequests } } + override fun info(): String = "模块: $name\n功能: 自动处理群组加群请求\n版本: 1.0" + + override fun help(): String = "本模块会轮询群组加群请求并根据数据库白名单自动同意或拒绝" } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/HelpModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/HelpModule.kt index acd6a67..cb77df1 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/HelpModule.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/HelpModule.kt @@ -1,4 +1,245 @@ package top.r3944realms.ltdmanager.module -class HelpModule { -} \ No newline at end of file +import kotlinx.coroutines.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +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.CooldownFilter +import top.r3944realms.ltdmanager.module.common.filter.type.IgnoreSelfFilter +import top.r3944realms.ltdmanager.module.common.filter.type.KeywordFilter +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.event.message.GetFriendMsgHistoryEvent +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 + +/** + * HelpModule 提供全局模块帮助信息 + */ +class HelpModule( + moduleName: String, + private val groupMessagePollingModule: GroupMessagePollingModule, + private val selfId: Long, + private val selfNickName: String, + private val keywords: List = listOf("help", "帮助"), + private val cooldownMillis: Long = 30_000L +) : BaseModule("HelpModule", moduleName), PersistentState { + + // 命令解析器 + private val commandParser = CommandParser(keywords) + private val GetFriendMsgHistoryEvent.SpecificMsg.textContent: String + get() = message.joinToString("") { it.data.text ?: "" } + + // 持久化文件 + private val stateFile: File = getStateFileInternal("help_module_state.json", name) + private val stateBackupFile: File = getStateFileInternal("help_module_state.json.bak", name) + + @Serializable + data class HelpState(var lastTriggeredRealId: Long = -1L, var lastTriggerTime: Long = 0L) + + private var lastTriggerState: HelpState = loadState() + + // 冷却管理器 + private val cooldownManager by lazy { + CooldownManager( + cooldownMillis = cooldownMillis, + scope = CooldownScope.Global, + stateProvider = object : CooldownStateProvider { + override fun load() = loadState() + override fun save(state: HelpState) = saveState(state) + }, + getLastTrigger = { state, _ -> state.lastTriggerTime to state.lastTriggeredRealId }, + updateTrigger = { state, _, realId, time -> state.copy(lastTriggeredRealId = realId, lastTriggerTime = time) }, + updateCooldownRealId = { state, _, realId -> state.copy(lastTriggeredRealId = realId) }, + groupId = groupMessagePollingModule.targetGroupId + ) + } + + // 触发过滤器 + private val triggerFilter by lazy { + TriggerMessageFilter( + listOf( + IgnoreSelfFilter(selfId), + NewMessageFilter { _ -> lastTriggerState.lastTriggerTime to lastTriggerState.lastTriggeredRealId }, + KeywordFilter(keywords.toSet()), + CooldownFilter(cooldownManager) { msg, remain -> sendCooldownMessage(napCatClient, msg.realId, remain) } + ) + ) + } + + private var scope: CoroutineScope? = null + + override fun getStateFileInternal(): File = stateFile + + override fun getState(): HelpState = lastTriggerState + + override fun onLoad() { + LoggerUtil.logger.info("[$name] 模块已加载,监听 help 指令") + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + scope!!.launch { + groupMessagePollingModule.messagesFlow.collect { messages -> + if (loaded) handleMessages(messages) + } + } + } + + override suspend fun onUnload() { + LoggerUtil.logger.info("[$name] 模块卸载,取消协程...") + scope?.cancel() + saveState(lastTriggerState) + LoggerUtil.logger.info("[$name] 模块已卸载完成") + } + + private suspend fun handleMessages(messages: List) { + val filtered = triggerFilter.filter(messages) + val triggerMsg = filtered.maxByOrNull { it.time } ?: return + + val cmdPair = commandParser.parseCommand(triggerMsg.textContent) + if (cmdPair != null) { + val (_, arg) = cmdPair + if (arg.isNotEmpty()) { + val module = moduleMap[arg] + if (module != null) sendModuleHelp(triggerMsg, arg, module) + else sendText(triggerMsg, "未找到模块: $arg") + } else { + sendAllModulesHelp(triggerMsg) + } + } + } + + private suspend fun sendAllModulesHelp(msg: GetFriendMsgHistoryEvent.SpecificMsg) { + val messages = moduleMap.map { (name, module) -> + val textBuilder = StringBuilder() + textBuilder.appendLine("===== $name =====") + textBuilder.appendLine(module.info()) + val helpText = module.help() + if (helpText.isNotEmpty()) textBuilder.appendLine(helpText) + textBuilder.appendLine().appendLine() + SendForwardMsgRequest.Message( + data = SendForwardMsgRequest.PurpleData(textBuilder.toString()), + type = MessageType.Text + ) + } + + 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("点击查看所有模块信息")), + prompt = "全局模块信息", + source = "📚 HelpModule", + summary = "信息,共 ${messages.size} 个模块" + ) + + napCatClient.sendUnit(request) + updateTriggerState(msg) + } + + private suspend fun sendModuleHelp(msg: GetFriendMsgHistoryEvent.SpecificMsg, moduleName: String, module: BaseModule) { + val textBuilder = StringBuilder() + textBuilder.appendLine("===== $moduleName =====") + textBuilder.appendLine(module.info()) + val helpText = module.help() + if (helpText.isNotEmpty()) textBuilder.appendLine(helpText) + + val message = SendForwardMsgRequest.Message( + data = SendForwardMsgRequest.PurpleData(textBuilder.toString()), + type = MessageType.Text + ) + + val topMessage = SendForwardMsgRequest.TopForwardMsg( + data = SendForwardMsgRequest.MessageData( + content = listOf(message), + nickname = selfNickName, + userId = ID.long(selfId) + ), + type = MessageType.Node + ) + + val request = SendForwardMsgRequest( + groupId = ID.long(groupMessagePollingModule.targetGroupId), + messages = listOf(topMessage), + news = listOf(SendForwardMsgRequest.ForwardModelNews("点击查看模块 $moduleName 帮助")), + prompt = "模块 $moduleName 帮助", + source = "📚 HelpModule", + summary = "模块 $moduleName 帮助信息" + ) + + napCatClient.sendUnit(request) + updateTriggerState(msg) + } + + private suspend fun sendText(msg: GetFriendMsgHistoryEvent.SpecificMsg, text: String) { + val request = SendGroupMsgRequest( + MessageElement.reply(ID.long(msg.realId), text), + ID.long(groupMessagePollingModule.targetGroupId) + ) + napCatClient.sendUnit(request) + updateTriggerState(msg) + } + + private fun updateTriggerState(msg: GetFriendMsgHistoryEvent.SpecificMsg) { + lastTriggerState.lastTriggeredRealId = msg.realId + lastTriggerState.lastTriggerTime = msg.time + saveState(lastTriggerState) + } + + private suspend fun sendCooldownMessage(client: NapCatClient, realId: Long, remaining: Long) { + val msg = "⏳ Help 查询过于频繁,请稍后再试(剩余 $remaining 秒)" + LoggerUtil.logger.info("[$name] 发送冷却提示: $msg") + client.sendUnit( + SendGroupMsgRequest( + MessageElement.reply(ID.long(realId), msg), + ID.long(groupMessagePollingModule.targetGroupId) + ) + ) + } + + // ---------------- 持久化 ---------------- + override fun saveState(state: HelpState) { + try { + if (stateFile.exists()) stateFile.copyTo(stateBackupFile, overwrite = true) + stateFile.writeText(Json.encodeToString(state)) + LoggerUtil.logger.info("[$name] 已保存状态: lastTriggeredRealId=${state.lastTriggeredRealId}, lastTriggerTime=${state.lastTriggerTime}") + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 保存状态失败", e) + } + } + + override fun loadState(): HelpState { + return try { + val fileToRead = when { + stateFile.exists() -> stateFile + stateBackupFile.exists() -> stateBackupFile + else -> null + } + if (fileToRead == null) return HelpState() + Json.decodeFromString(fileToRead.readText()) + } catch (e: Exception) { + LoggerUtil.logger.warn("[$name] 读取状态失败,使用默认值", e) + HelpState() + } + } + + override fun help(): String = "发送 'help' 获取所有模块帮助信息" + + override fun info(): String = "模块: $name\n功能: 提供全局模块帮助信息\n版本: 1.0" +} diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/InvitationCodesModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/InvitationCodesModule.kt index d402c76..86b18fa 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/InvitationCodesModule.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/InvitationCodesModule.kt @@ -8,11 +8,18 @@ import top.r3944realms.ltdmanager.blessingskin.request.invitecode.GenerateInvita import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult import top.r3944realms.ltdmanager.blessingskin.response.invitecode.InvitationCodeGenerationResponse import top.r3944realms.ltdmanager.core.mail.mail +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.CooldownFilter +import top.r3944realms.ltdmanager.module.common.filter.type.IgnoreSelfFilter +import top.r3944realms.ltdmanager.module.common.filter.type.KeywordFilter +import top.r3944realms.ltdmanager.module.common.filter.type.NewMessageFilter import top.r3944realms.ltdmanager.module.exception.InvitationCodeException 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.event.message.GetFriendMsgHistoryEvent import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest import top.r3944realms.ltdmanager.utils.HtmlTemplateUtil @@ -63,24 +70,67 @@ api格式 https://skins.r3944realms.top/api/invitation-codes/generate?token=XXXX */ class InvitationCodesModule( + moduleName: String, private val groupMessagePollingModule: GroupMessagePollingModule, private val mailModule: MailModule, private val apiToken: String, - private val selfId: Long, + selfId: Long, private val cooldownMillis: Long = 120_000, private val keywords: Set = setOf("申请邀请码") -) : BaseModule(), PersistentState { +) : BaseModule("InvitationCodesModule", moduleName), PersistentState { - override val name: String = "InvitationCodesModule" private var scope: CoroutineScope? = null + private val stateFile: File = getStateFileInternal("invitation_codes_quarry_state.json", name) + private val stateBackupFile: File = getStateFileInternal("invitation_codes_quarry_state.json.bak", name) + private val cooldownManager by lazy{ CooldownManager( + cooldownMillis = cooldownMillis, + scope = CooldownScope.PerUser, + stateProvider = object : CooldownStateProvider { + override fun load() = loadState() + override fun save(state: LastTriggerMapState) = 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) + state.updateLastTrigger(id, realId, time) + }, + updateCooldownRealId = { state, qq, realId -> + val id = requireNotNull(qq) + state.updateLastCooldownRealId(id, realId) + }, + groupId = groupMessagePollingModule.targetGroupId + ) + } + // 在 InvitationCodesModule 类里添加: + private val triggerFilter = TriggerMessageFilter( + listOf( + IgnoreSelfFilter(selfId), + NewMessageFilter { qq -> + lastTriggerMapState.getLastTriggerTime(qq) to lastTriggerMapState.getLastTriggerRealId(qq) + }, + KeywordFilter(keywords), + CooldownFilter( + cooldownManager = cooldownManager, + sendCooldown = { msg, remain -> + sendCooldownMessage( + napCatClient, + msg.userId, + msg.realId, + "⏳ 申请邀请码过于频繁(剩余 $remain 秒后自动申请)" + ) + } + ) + ) + ) + - // 持久化文件(带锁 + 备份) - private val stateFile = getStateFile("mc_server_status_state.json") - private val stateBackupFile = getStateFile("invitation_codes_quarry_state.json.bak") private val fileLock = ReentrantLock() private var lastTriggerMapState = loadState() - override fun getStateFile(): File = stateFile + override fun getStateFileInternal(): File = stateFile override fun getState(): LastTriggerMapState = lastTriggerMapState override fun onLoad() { LoggerUtil.logger.info("[$name] 模块已装载,目标群组: ${groupMessagePollingModule.targetGroupId}") @@ -135,29 +185,17 @@ class InvitationCodesModule( } /** 过滤出符合条件的触发消息 */ - private fun filterTriggerMessages(messages: List) - : List { + private suspend fun filterTriggerMessages( + messages: List + ): List { - val filtered = messages.asSequence() - .filter { msg -> - msg.userId != selfId && - (msg.time > lastTriggerMapState.getLastTriggerTime(msg.userId) || - (msg.time == lastTriggerMapState.getLastTriggerTime(msg.userId) - && msg.realId > lastTriggerMapState.getLastTriggerRealId(msg.userId))) && - msg.message.any { seg -> - seg.type == MessageType.Text && - seg.data.text?.let { text -> keywords.any { keyword -> text == keyword } } == true - } - } + // 先应用通用过滤器 + val filtered = triggerFilter.filter(messages) + + // 再做 groupBy -> 只保留每个用户最新一条 + return filtered .groupBy { it.userId } .mapNotNull { (_, msgs) -> msgs.maxByOrNull { it.time } } - .filter { runBlocking { filterCoolDownMessage(it) } } - .toList() - - if (filtered.isNotEmpty()) { - LoggerUtil.logger.info("[$name] 待处理消息队列: $filtered") - } - return filtered } private suspend fun getIdAndSelectSituation(msgs: List, @@ -358,33 +396,6 @@ class InvitationCodesModule( } } - // ========================= - // 冷却逻辑 - // ========================= - private suspend fun filterCoolDownMessage(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean { - val triggerDetail = lastTriggerMapState.map[msg.userId] - val lastTriggerTime = triggerDetail?.time ?: -1L - val lastCooldownRealId = triggerDetail?.lastCooldownRealId ?: -1L - val nowSec = System.currentTimeMillis() / 1000 // 转成秒 - - if (lastTriggerTime == -1L || nowSec - lastTriggerTime >= cooldownMillis / 1000) { - // 正常触发 - return true - } - - // 冷却中,如果本消息未发送过冷却提示 - if (msg.realId != lastCooldownRealId) { - val remaining = ((cooldownMillis / 1000) - (nowSec - lastTriggerTime)).coerceAtLeast(1) - val msgText = "⏳ 申请邀请码过于频繁(剩余 $remaining 秒后将为你自动申请)" - sendCooldownMessage(napCatClient, msg.userId, msg.realId, msgText) - - // 记录这条消息已发送过冷却提示 - lastTriggerMapState = lastTriggerMapState.updateLastCooldownRealId(msg.userId, msg.realId) - } - - return false - } - private suspend fun sendCooldownMessage(client: NapCatClient, qq: Long, realId: Long, msg: String) { val request = SendGroupMsgRequest( MessageElement.reply(ID.long(realId), msg), @@ -646,5 +657,34 @@ class InvitationCodesModule( } } } + // 在 InvitationCodesModule 类中补全: + override fun info(): String { + return """ + 模块: $name + 功能: 自动处理群组内“申请邀请码”消息 + 描述: + 1. 监听群消息,过滤关键词和冷却 + 2. 根据QQ号查询白名单状态 + 3. 自动创建或发送邀请码,并通过邮件发送 + 4. 已触发和未触发状态会持久化保存 + 关键词: $keywords + 冷却时间: ${cooldownMillis / 1000} 秒 + 目标群组: ${groupMessagePollingModule.targetGroupId} + """.trimIndent() + } + + override fun help(): String { + return """ + 使用说明: + 1. 在群里发送${keywords}触发本模块 + 2. 模块会自动判断你的白名单状态 + - 若已使用过邀请码,会提醒你不要重复申请 + - 若已有邀请码但未使用,会重新发送邮件提醒 + - 若未生成邀请码,会调用API生成并发送邮件 + 3. 请求过于频繁时,会有冷却提示 + 4. 所有操作都有日志记录,可供管理员审计 + 5. 异常情况会发送失败提示消息 + """.trimIndent() + } } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/MailModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/MailModule.kt index d430d20..78291fb 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/MailModule.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/MailModule.kt @@ -10,6 +10,7 @@ import java.util.concurrent.LinkedBlockingQueue import kotlin.concurrent.thread class MailModule( + moduleName: String, private val protocol: String = "SMTP", private val host: String, private val port: Int, @@ -18,9 +19,7 @@ class MailModule( private val enableAuth: Boolean = true, private val enableTLS: Boolean = true, private val intervalMillis: Long = 2000L // 每封邮件之间的间隔(默认 2s) -) : BaseModule() { - - override val name: String = "MailModule" +) : BaseModule("MailModule", moduleName) { private lateinit var session: Session private val queue = LinkedBlockingQueue() // 邮件队列 @@ -142,4 +141,36 @@ class MailModule( Transport.send(message) } + override fun info(): String { + return buildString { + appendLine("[$name] 邮件发送模块") + appendLine("功能: 异步发送邮件,支持收件人/抄送/密送,支持 HTML 或纯文本邮件。") + appendLine("SMTP 配置:") + appendLine(" - 协议: $protocol") + appendLine(" - 主机: $host") + appendLine(" - 端口: $port") + appendLine(" - 发件人邮箱: $senderEmailAddress") + appendLine(" - 身份认证: ${if (enableAuth) "启用" else "禁用"}") + appendLine(" - TLS/SSL: ${if (enableTLS) "启用" else "禁用"}") + appendLine("队列行为:") + appendLine(" - 邮件发送间隔: ${intervalMillis}ms") + appendLine(" - 队列长度: ${queue.size}") + appendLine(" - 当前发送线程状态: ${if (workerThread?.isAlive == true) "运行中" else "未运行"}") + } + } + + override fun help(): String { + return buildString { + appendLine("📖 [$name] 使用帮助:") + appendLine("1. 创建 Mail 对象,设置收件人、主题和正文") + appendLine(" 例如: Mail(to = listOf(\"example@mail.com\"), subject = \"测试\", body = \"Hello\")") + appendLine("2. 调用 enqueue(mail) 加入发送队列") + appendLine(" 邮件将异步发送,间隔 $intervalMillis ms") + appendLine("3. 模块卸载时会自动停止发送线程") + appendLine() + appendLine("注意:") + appendLine(" - 确保 SMTP 配置正确,否则发送失败") + appendLine(" - 发件人邮箱需要允许 SMTP/授权码登录") + } + } } diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/McServerStatusModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/McServerStatusModule.kt index 11dbc2c..2a6c157 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/McServerStatusModule.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/McServerStatusModule.kt @@ -4,6 +4,15 @@ 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 @@ -17,30 +26,72 @@ 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 cooldownSeconds: Long = 60, + private val cooldownMillis: Long = 60_000L, private val commands: List = listOf("/mcs", "/s"), private val presetServer: Map, String> = mapOf( setOf("hp", "hypixel") to "mc.hypixel.net", setOf("pm", "mineplex") to "play.mineplex.com" ) -) : BaseModule(), PersistentState { +) : BaseModule("McServerStatusModule", moduleName), PersistentState { + 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 { + 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 by lazy { presetServer.flatMap { (aliases, ip) -> aliases.map { it.lowercase() to ip } }.toMap() } fun getServerIp(alias: String): String? = presetServerByAlias[alias.lowercase()] - override val name: String = "McServerStatusModule" private var scope: CoroutineScope? = null - private val stateFile = getStateFile("mc_server_status_state.json") - private val stateBackupFile = getStateFile("mc_server_status_state.json.bak") + private val fileLock = ReentrantLock() private var cooldownState = loadState() - override fun getStateFile(): File = stateFile + override fun getStateFileInternal(): File = stateFile override fun getState(): CooldownState = cooldownState override fun onLoad() { @@ -76,32 +127,11 @@ class McServerStatusModule( saveState(cooldownState) } } - private suspend fun filterTriggerMessages(messages: List) - : List { - val filtered = messages.asSequence() - .filter { msg -> - // 忽略自己消息 - msg.userId != selfId && - // 新消息判断 - (msg.time > cooldownState.getLastTriggerTime(msg.userId) || - (msg.time == cooldownState.getLastTriggerTime(msg.userId) && - msg.realId > cooldownState.getLastTriggerRealId(msg.userId))) - } - .filter { msg -> - // 检查命令 - msg.message.any { seg -> - seg.type == MessageType.Text && - ( - seg.data.text?.let { text -> commands.any { cmd -> text.startsWith(cmd) } } == true - ) - } - } - .filter { runBlocking { handleCooldown(it) } } // 这里处理冷却 - .toList() + private suspend fun filterTriggerMessages( + messages: List + ): List = triggerFilter.filter(messages) - return filtered - } private suspend fun sendFailedMessage( client: NapCatClient, qq: Long? = null, @@ -129,31 +159,7 @@ class McServerStatusModule( LoggerUtil.logger.info("[$name] 已发送 失败消息[无指定对象]") } } - /** 冷却提示消息 */ - - private suspend fun handleCooldown(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean { - val trigger = cooldownState.map[msg.userId] - val lastTriggerTime = trigger?.time ?: -1L - val lastCooldownRealId = trigger?.lastCooldownRealId ?: -1L - val nowSec = System.currentTimeMillis() / 1000 - - // 未触发过或者已超过冷却 - if (lastTriggerTime == -1L || nowSec - lastTriggerTime >= cooldownSeconds) { - return true - } - - // 冷却中且未发送过冷却提示 - if (msg.realId != lastCooldownRealId) { - val remaining = ((cooldownSeconds - (nowSec - lastTriggerTime))).coerceAtLeast(1) - val msgText = "⏳ 查询过于频繁, $remaining 秒后执行查询,切勿重复发送" - sendCooldownMessage(napCatClient, msg.userId, msg.realId, msgText) - cooldownState = cooldownState.updateLastCooldownRealId(msg.userId, msg.realId) - } - - return false - } - - private suspend fun sendCooldownMessage(client: NapCatClient, qq: Long, realId: Long, text: String) { + private suspend fun sendCooldownMessage(client: NapCatClient, realId: Long, text: String) { val request = SendGroupMsgRequest( MessageElement.reply(ID.long(realId), text), ID.long(groupMessagePollingModule.targetGroupId) @@ -171,16 +177,18 @@ class McServerStatusModule( ?.trim() ?: return - // 解析命令 - val matchedCommand = commands.firstOrNull { text.startsWith(it) } ?: return - var address = text.removePrefix(matchedCommand).trim() + // 使用命令解析器解析命令 + val parsedCommand = commandParser.parseCommand(text) ?: return + val (_, address) = parsedCommand // 使用预设别名替换 - presetServerByAlias[address.lowercase()]?.let { presetIp -> - address = presetIp + val finalAddress = if (address.isNotEmpty()) { + presetServerByAlias[address.lowercase()] ?: address + } else { + "" } - if (address.isEmpty()) { + if (finalAddress.isEmpty()) { sendFailedMessage( napCatClient, msg.userId, @@ -192,9 +200,8 @@ class McServerStatusModule( } try { - val status = mcSrvStatusClient.getServerStatus(address) // 返回 McServerStatus + val status = mcSrvStatusClient.getServerStatus(finalAddress) - // 检查是否查询失败 if (!status.online) { sendFailedMessage( napCatClient, msg.userId, msg.realId, msg.time, @@ -203,9 +210,7 @@ class McServerStatusModule( return } - // 查询成功,发送状态消息 - sendStatusForwardMessage(napCatClient, msg, address, status, msg.realId, msg.time) - + sendStatusForwardMessage(napCatClient, msg, finalAddress, status, msg.realId, msg.time) } catch (e: Exception) { LoggerUtil.logger.error("查询服务器状态失败: $address", e) sendFailedMessage( @@ -311,23 +316,36 @@ class McServerStatusModule( data class CooldownState( val map: Map = emptyMap() ) { + // 获取上次处理时间 fun getLastTriggerTime(qq: Long): Long = map[qq]?.time ?: -1 + + // 获取上次处理消息ID fun getLastTriggerRealId(qq: Long): Long = map[qq]?.realId ?: -1 - fun updateLastTrigger(qq: Long, realId: Long, time: Long = -1): CooldownState { + + // 获取上次冷却消息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 newTime = if (time != -1L) time else old?.time ?: -1 val newMap = map.toMutableMap().apply { - put(qq, TriggerDetail(realId, newTime, old?.lastCooldownRealId ?: -1)) + 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, - time = old?.time ?: -1, - lastCooldownRealId = realId + realId = old?.realId ?: -1, // 保持上次允许处理的消息ID + time = old?.time ?: -1, // 保持上次允许处理的时间 + lastCooldownRealId = realId // 更新当前冷却拒绝的消息ID )) } return copy(map = newMap) @@ -336,9 +354,9 @@ class McServerStatusModule( @Serializable data class TriggerDetail( - val realId: Long, - val time: Long, - val lastCooldownRealId: Long = -1L + val realId: Long, // 上次允许处理消息ID + val time: Long, // 上次允许处理消息时间(秒) + val lastCooldownRealId: Long = -1 // 上次被冷却拒绝的消息ID ) override fun loadState(): CooldownState { @@ -368,4 +386,33 @@ class McServerStatusModule( } } } + 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(" - 查询结果会以转发消息形式发送到群组") + } } diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/ModGroupHandlerModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/ModGroupHandlerModule.kt index 19edac2..66e4bb0 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/ModGroupHandlerModule.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/ModGroupHandlerModule.kt @@ -1,4 +1,209 @@ package top.r3944realms.ltdmanager.module -class ModGroupHandleModule { +import kotlinx.coroutines.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import top.r3944realms.ltdmanager.napcat.data.ID +import top.r3944realms.ltdmanager.napcat.data.MessageElement +import top.r3944realms.ltdmanager.napcat.event.NapCatEvent +import top.r3944realms.ltdmanager.napcat.event.account.GetStrangerInfoEvent +import top.r3944realms.ltdmanager.napcat.event.group.GetGroupIgnoredNotifiesEvent +import top.r3944realms.ltdmanager.napcat.event.group.GetGroupSystemMsgEvent +import top.r3944realms.ltdmanager.napcat.request.account.GetStrangerInfoRequest +import top.r3944realms.ltdmanager.napcat.request.group.GetGroupIgnoredNotifiesRequest +import top.r3944realms.ltdmanager.napcat.request.group.GetGroupSystemMsgRequest +import top.r3944realms.ltdmanager.napcat.request.group.SetGroupAddRequestRequest +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 + +/** + * 模块: 入群申请自动处理 + * 功能: + * 1. 监听目标群的入群申请事件 + * 2. 根据 answers 列表自动同意或拒绝 + */ +class ModGroupHandlerModule( + moduleName: String, + private val targetGroupId: Long, + private val answers: List = listOf("正确答案"), + private val pollIntervalMillis: Long = 30_000L +) : BaseModule("ModGroupHandlerModule", moduleName), PersistentState { + + private var scope: CoroutineScope? = null + private val stateFile: File = getStateFileInternal("reject_records.json", name) + private val fileLock = ReentrantLock() + private var stateCache: RejectRecords? = null + private val json = Json { prettyPrint = true; encodeDefaults = true } + + @Serializable + data class RejectRecord( + val userId: Long, + var reason: MutableList = mutableListOf(), + var rejectCount: Int = 0 + ) + + /** + * 记录所有被拒绝用户的Map,key = userId + */ + @Serializable + data class RejectRecords( + val records: MutableMap = mutableMapOf() + ) + + override fun getStateFileInternal(): File = stateFile + + override fun getState(): RejectRecords { + if (stateCache == null) stateCache = loadState() + return stateCache!! + } + + override fun saveState(state: RejectRecords) { + fileLock.withLock { + try { + stateFile.writeText(json.encodeToString(state)) + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 保存拒绝记录失败", e) + } + } + } + + override fun loadState(): RejectRecords { + return try { + if (!stateFile.exists()) return RejectRecords() + val text = stateFile.readText() + json.decodeFromString(RejectRecords.serializer(), text) + } catch (e: Exception) { + LoggerUtil.logger.warn("[$name] 拒绝记录加载失败,使用默认值", e) + RejectRecords() + } + } + + private fun addReject(userId: Long, reason: String) { + val state = getState() + val record = state.records[userId] + if (record != null) { + record.rejectCount += 1 + record.reason.add(reason) + } else { + state.records[userId] = RejectRecord(userId, mutableListOf(reason), 1) + } + saveState(state) + } + fun getRejectRecord(userId: Long): RejectRecord? { + return getState().records[userId] + } + + override fun onLoad() { + LoggerUtil.logger.info("[$name] 模块已装载,目标群组: $targetGroupId") + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + scope!!.launch { + LoggerUtil.logger.info("[$name] 轮询协程启动") + while (isActive && loaded) { + try { + handleEvents() + delay(pollIntervalMillis) + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 轮询异常", e) + } + } + } + } + + override suspend fun onUnload() { + LoggerUtil.logger.info("[$name] 模块卸载") + scope?.cancel() + } + + private suspend fun handleEvents() { + val systemEvent: GetGroupSystemMsgEvent = napCatClient.send(GetGroupSystemMsgRequest()) + handleEvent(systemEvent) + + val ignoredEvent: GetGroupIgnoredNotifiesEvent = napCatClient.send(GetGroupIgnoredNotifiesRequest()) + handleEvent(ignoredEvent) + } + + private suspend fun handleEvent(event: Any) { + if (!loaded) return + val provider: GroupRequestProvider? = when (event) { + is GetGroupSystemMsgEvent -> event.asProvider() + is GetGroupIgnoredNotifiesEvent -> event.asProvider() + else -> null + } + + provider?.getAllRequests()?.forEach { request -> + if (!request.checked && request.groupId == targetGroupId) { + LoggerUtil.logger.info("[$name] 处理请求: requestId=${request.requestId}, actor=${request.actor}") + val answerAllow = answers.contains(request.message) + if(answerAllow) { + val info = napCatClient.send(GetStrangerInfoRequest(ID.long(request.requestId))) + val levelAllow = info.data.qqLevel >= 16 + val setRequest = SetGroupAddRequestRequest( + levelAllow, + request.requestId.toString(), + if(!levelAllow) "QQ等级低于16级" else "" + ) + napCatClient.send(setRequest) + if (levelAllow) napCatClient.send(SendGroupMsgRequest(listOf(MessageElement.text(formatRejectRecordMessage(request.requestId))), ID.long(targetGroupId))) + LoggerUtil.logger.info("[$name] 已${if (levelAllow) "同意" else "拒绝"}请求${if(!levelAllow) ",等级不够,${info.data.qqLevel}" else "" }: ${request.requestId}") + } else { + napCatClient.sendUnit(SetGroupAddRequestRequest(false, request.requestId.toString(), "答案错误,拒绝次数:${getRejectRecord(request.requestId)?.rejectCount}")) + addReject(request.actor, "答案错误:${request.message}") + LoggerUtil.logger.info("[$name] 答案错误:${request.message},已拒绝请求: ${request.requestId}") + } + + } + } + } + fun formatRejectRecordMessage(userId: Long): String { + val record = getRejectRecord(userId) + return if (record != null) { + """ + 用户QQ号:${record.userId} + 尝试次数:${record.rejectCount} + 最终评分:${rate(record.rejectCount)} + 尝试答案:【${record.reason.joinToString(",")}】 + """.trimIndent() + } else { + """ + 用户QQ号:${userId} + 尝试次数:0 + 最终评分:SSS + """.trimIndent() + } + } + private fun rate(count: Int): String = when (count) { + 0 -> "S" + 1 -> "A" + 2 -> "B" + 3 -> "C" + 4 -> "D" + else -> "F" + } + interface GroupRequestProvider { + fun getAllRequests(): List + } + + private fun GetGroupSystemMsgEvent.asProvider(): GroupRequestProvider = object : GroupRequestProvider { + override fun getAllRequests(): List = + data.invitedRequest + data.joinRequests + } + + private fun GetGroupIgnoredNotifiesEvent.asProvider(): GroupRequestProvider = object : GroupRequestProvider { + override fun getAllRequests(): List = + data.invitedRequest + data.joinRequests + } + + override fun info(): String = """ + 模块: $name + 功能: 自动处理指定群组的入群申请 + 1. 根据答案列表自动同意或拒绝 + 2. 拒绝记录会保存到本地,并可查询尝试次数和尝试答案 + 3. 用户通过验证且等级满足要求时,会向群里发送消息,显示用户QQ号、尝试次数、评分和尝试答案 + 版本: 1.0 + """.trimIndent() + override fun help(): String = "轮询群组入群申请,根据答案列表自动同意或拒绝,并记录拒绝用户信息" } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleManager.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleManager.kt index b668e0c..925e891 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleManager.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleManager.kt @@ -6,6 +6,9 @@ class ModuleManager { private val modules = mutableMapOf() + fun getModules(): Map { + return (modules).toMap() + } /** * 注册模块到管理器 */ @@ -74,6 +77,13 @@ class ModuleManager { } } + /** + * 提供获取所有模块信息的方法 + */ + fun getAllModuleInfo(): Map { + return modules.mapValues { it.value.info() } + } + /** * 获取所有模块名称 */ diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/PersistentState.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/PersistentState.kt index d03ef9a..9098176 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/PersistentState.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/PersistentState.kt @@ -1,15 +1,16 @@ package top.r3944realms.ltdmanager.module +import top.r3944realms.ltdmanager.utils.FileNameFilter import java.io.File interface PersistentState { - fun getStateFile(): File + fun getStateFileInternal(): File fun getState(): T fun saveState(state: T) fun loadState(): T // 默认实现:统一管理 data 目录下的文件 - fun getStateFile(name: String): File { - val dataDir = File("data") + fun getStateFileInternal(name: String, moduleName: String): File { + val dataDir = File("data", FileNameFilter.filterFileName(moduleName)) if (!dataDir.exists()) dataDir.mkdirs() return File(dataDir, name) } diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/RconPlayerListModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/RconPlayerListModule.kt index ef95d66..f474eb0 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/RconPlayerListModule.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/RconPlayerListModule.kt @@ -5,6 +5,14 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import top.r3944realms.ltdmanager.module.RconPlayerListModule.LastTriggerState +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.CooldownFilter +import top.r3944realms.ltdmanager.module.common.filter.type.IgnoreSelfFilter +import top.r3944realms.ltdmanager.module.common.filter.type.KeywordFilter +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 @@ -18,26 +26,61 @@ import java.io.File import java.util.concurrent.TimeoutException class RconPlayerListModule( + moduleName: String, private val groupMessagePollingModule: GroupMessagePollingModule, private val rconTimeOut: Long = 2_000L, private val cooldownMillis: Long = 30_000L, - private var lastSuccessTime: Long = 0L, private val selfId: Long, private val selfNickName: String, private val rconPath: String, private val rconConfigPath: String, private val keywords: Set = setOf("查看玩家列表", "玩家列表", "在线玩家") -) : BaseModule(), PersistentState { - - override val name: String = "RconPlayerListModule" +) : BaseModule("RconPlayerListModule", moduleName), PersistentState { + private val cooldownManager by lazy { + CooldownManager( + cooldownMillis = cooldownMillis, + scope = CooldownScope.Global, + stateProvider = object : CooldownStateProvider { + override fun load() = loadState() + override fun save(state: LastTriggerState) = saveState(state) + }, + getLastTrigger = { state, _ -> state.lastTriggerTime to state.lastTriggeredRealId }, + updateTrigger = { state, _, realId, time -> + // ✅ 消息成功触发时更新状态 + state.updateTrigger(realId, time) + state + }, + updateCooldownRealId = { state, _, realId -> + // ✅ 消息被冷却拒绝时更新 lastCooldownRealId + state.updateCooldownRealId(realId) + state + }, + groupId = groupMessagePollingModule.targetGroupId + ) + } + /** 抽象过滤器组合 —— lazy 避免初始化顺序问题 */ + private val triggerFilter by lazy { + TriggerMessageFilter( + listOf( + IgnoreSelfFilter(selfId), + NewMessageFilter { _ -> + lastTriggerState.lastTriggerTime to lastTriggerState.lastTriggeredRealId + }, + KeywordFilter(keywords), + CooldownFilter(cooldownManager) { msg, remain -> + sendCooldownMessage(napCatClient, msg.realId, remain) + } + ) + ) + } private var scope : CoroutineScope? = null // 持久化文件路径 - private val stateFile = getStateFile("rcon_playerlist_state.json") + private val stateFile: File = getStateFileInternal("rcon_playerlist_state.json", name) - private val stateBackupFile = getStateFile("invitation_codes_quarry_state.json.bak") + private val stateBackupFile: File = getStateFileInternal("rcon_playerlist_state.json.bak", name) - override fun getStateFile(): File = stateFile + override fun getStateFileInternal(): File = stateFile // 保存最新触发过的消息 realId 和 time private var lastTriggerState: LastTriggerState = loadState() @@ -68,103 +111,54 @@ class RconPlayerListModule( } private suspend fun handleMessages(messages: List) { - val triggerMessages = messages - .asSequence() // 使用序列提高性能,特别是消息量大时 - .filter { msg -> - ((msg.time > lastTriggerState.lastTriggerTime || - (msg.time == lastTriggerState.lastTriggerTime && msg.realId > lastTriggerState.lastTriggeredRealId)) - && msg.userId != selfId) && - msg.message.any { seg -> - seg.type == MessageType.Text && - seg.data.text?.let { text -> keywords.any { keyword -> text == keyword } } == true - } - }.toList() + val filtered = triggerFilter.filter(messages) - if (triggerMessages.isNotEmpty()) { - val triggerMsg = triggerMessages.maxBy { it.time } - LoggerUtil.logger.info("[$name] 找到触发消息 realId=${triggerMsg.realId}, time=${triggerMsg.time}, userId=${triggerMsg.userId}") - processTrigger(triggerMsg) + // RCON 模块只取最新的一条消息 + val triggerMsg = filtered.maxByOrNull { it.time } + if (triggerMsg != null) { + try { + processTrigger(triggerMsg) + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 处理触发消息失败", e) + sendFailedMessage(napCatClient, triggerMsg.realId, triggerMsg.time, "处理异常: ${e.message}") + } } } private suspend fun processTrigger(msg: GetFriendMsgHistoryEvent.SpecificMsg) { - val now = System.currentTimeMillis() + LoggerUtil.logger.info("[$name] 执行 RCON 查询") - // ✅ 冷却检查(首次触发直接允许) - val canTrigger = (lastSuccessTime == 0L) || (now - lastSuccessTime >= cooldownMillis) - if (!canTrigger) { - val remaining = ((cooldownMillis - (now - lastSuccessTime)) / 1000).coerceAtLeast(1) - LoggerUtil.logger.info("[$name] 冷却中,拒绝执行,剩余 $remaining 秒") - sendCooldownMessage(napCatClient, msg.realId, msg.time) - return - } - - // ✅ 执行 RCON 命令 val commands = listOf("forge tps", "list") LoggerUtil.logger.info("[$name] 执行 RCON 命令: $commands") runCatching { - val tpsOutput = runCatching { - CmdUtil.runExeCommand( - rconPath, - "-c", rconConfigPath, - "-T", (rconTimeOut / 1000).toString() + "s", - "forge tps" - ) - }.getOrElse { ex -> - LoggerUtil.logger.warn("[$name] 执行 forge tps 失败: ${ex.message}") - throw ex - } - - val listOutput = runCatching { - CmdUtil.runExeCommand( - rconPath, - "-c", rconConfigPath, - "-T", (rconTimeOut / 1000).toString() + "s", - "list" - ) - }.getOrElse { ex -> - LoggerUtil.logger.warn("[$name] 执行 list 失败: ${ex.message}") - throw ex - } + val tpsOutput = CmdUtil.runExeCommand( + rconPath, "-c", rconConfigPath, + "-T", (rconTimeOut / 1000).toString() + "s", "forge tps" + ) + val listOutput = CmdUtil.runExeCommand( + rconPath, "-c", rconConfigPath, + "-T", (rconTimeOut / 1000).toString() + "s", "list" + ) if (tpsOutput.contains("i/o timeout") || listOutput.contains("i/o timeout")) { throw TimeoutException() } - // 合并输出,后续一起解析 buildString { appendLine(tpsOutput.trim()) appendLine("--------") appendLine(listOutput.trim()) } }.onFailure { ex -> - lastSuccessTime = now // ✅ 成功/失败都要刷新冷却开始时间 - + LoggerUtil.logger.error("[$name] RCON 查询失败", ex) if (ex is TimeoutException) { - LoggerUtil.logger.warn("[$name] RCON 连接超时: ${ex.message}") - sendFailedMessage(napCatClient, msg.realId, msg.time) - } else { - LoggerUtil.logger.error("[$name] RCON 命令执行失败", ex) - sendFailedMessage( - napCatClient, - msg.realId, - msg.time, - "系统内部错误请联系管理员:${ex.message}" - ) - throw ex + sendFailedMessage(napCatClient, msg.realId, msg.time, "⏳ RCON 连接超时") } + throw ex }.onSuccess { output -> - lastSuccessTime = now - LoggerUtil.logger.info("[$name] RCON 命令执行成功,输出长度: ${output.length}") - LoggerUtil.logger.debug("[$name] RCON 输出内容: $output") - val tpsInfo = parseTPS(output) val playerListInfo = parsePlayerList(output) - LoggerUtil.logger.info( - "[$name] 解析成功: TPS=${tpsInfo.overall.meanTPS}, 在线 ${playerListInfo.onlineCount} 人" - ) - sendForwardMessage(napCatClient, tpsInfo, playerListInfo, msg.realId, msg.time) } @@ -175,11 +169,8 @@ class RconPlayerListModule( } - private suspend fun sendCooldownMessage(client: NapCatClient, realId: Long, time: Long) { - val now = System.currentTimeMillis() - val remaining = ((cooldownMillis - (now - lastSuccessTime)) / 1000).coerceAtLeast(1) // 至少显示 1 秒 + private suspend fun sendCooldownMessage(client: NapCatClient, realId: Long, remaining: Long) { val msg = "⏳ 查询过于频繁,请稍后再试(剩余 $remaining 秒)" - LoggerUtil.logger.info("[$name] 发送冷却提示: $msg") val request = SendGroupMsgRequest( @@ -187,11 +178,6 @@ class RconPlayerListModule( ID.long(groupMessagePollingModule.targetGroupId) ) client.sendUnit(request) - - // 更新触发状态,但不更新 lastSuccessTime(避免延长冷却) - lastTriggerState.lastTriggeredRealId = realId - lastTriggerState.lastTriggerTime = time - saveState(lastTriggerState) } private val failedMessages = listOf( @@ -479,13 +465,30 @@ class RconPlayerListModule( // ---------------- 持久化部分 ---------------- @Serializable - data class LastTriggerState(var lastTriggeredRealId: Long, var lastTriggerTime: Long) + data class LastTriggerState( + var lastTriggeredRealId: Long = -1, // 上次允许处理消息ID + var lastTriggerTime: Long = 0, // 上次允许处理时间(毫秒或秒都可以,根据你的逻辑) + var lastCooldownRealId: Long = -1 // 上次冷却期间被拒绝的消息ID + ) { + /** ✅ 冷却结束,更新触发状态 */ + fun updateTrigger(realId: Long, time: Long) { + lastTriggeredRealId = realId + lastTriggerTime = time + // 保留 lastCooldownRealId 不变 + } + + /** ⚠️ 冷却中,更新冷却消息ID */ + fun updateCooldownRealId(realId: Long) { + lastCooldownRealId = realId + // 保留 lastTriggeredRealId 和 lastTriggerTime + } + } override fun saveState(state: LastTriggerState) { try { // 先备份现有主文件 if (stateFile.exists()) { - stateFile.copyTo(File(stateFile.parent, stateFile.name + ".bak"), overwrite = true) + stateFile.copyTo(stateBackupFile, overwrite = true) } // 写入主文件 @@ -500,7 +503,7 @@ class RconPlayerListModule( return try { val fileToRead = when { stateFile.exists() -> stateFile - File(stateFile.parent, stateFile.name + ".bak").exists() -> File(stateFile.parent, stateFile.name + ".bak") + stateBackupFile.exists() -> stateBackupFile else -> null } @@ -517,5 +520,36 @@ class RconPlayerListModule( LastTriggerState(-1L, 0L) } } + // 返回模块基本信息 + override fun info(): String = buildString { + appendLine("模块名称: $name") + appendLine("模块类型: RconPlayerListModule") + appendLine("目标群组: ${groupMessagePollingModule.targetGroupId}") + appendLine("机器人昵称: $selfNickName (ID: $selfId)") + appendLine("冷却时间: ${cooldownMillis / 1000} 秒") + appendLine("RCON 命令路径: $rconPath") + appendLine("RCON 配置文件路径: $rconConfigPath") + appendLine("RCON 超时时间: $rconTimeOut ms") + appendLine("关键词触发: ${keywords.joinToString(", ")}") + appendLine("状态文件路径: ${stateFile.absolutePath}") + appendLine("状态备份文件路径: ${stateBackupFile.absolutePath}") + appendLine("上次触发消息ID: ${lastTriggerState.lastTriggeredRealId}") + appendLine("上次触发时间: ${lastTriggerState.lastTriggerTime}") + } + + // 返回模块使用帮助 + override fun help(): String = buildString { + appendLine("使用帮助 - RconPlayerListModule") + appendLine("功能: 查询服务器 TPS 和在线玩家列表,通过关键词触发或冷却机制限制频率") + appendLine("触发关键词: ${keywords.joinToString(", ")}") + appendLine("示例:") + keywords.forEach { keyword -> + appendLine(" - 在群里发送 \"$keyword\" 将触发 RCON 查询") + } + appendLine("注意事项:") + appendLine(" - 查询冷却时间为 ${cooldownMillis / 1000} 秒") + appendLine(" - RCON 查询可能受服务器响应时间影响") + appendLine(" - 查询结果会以转发消息形式发送到群组") + } } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/CommandParser.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/CommandParser.kt index 0d9f78a..3daadb6 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/CommandParser.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/CommandParser.kt @@ -1,11 +1,14 @@ -package top.r3944realms.ltdmanager.module.util +package top.r3944realms.ltdmanager.module.common /** * 命令解析器 * 严格模式:只支持命令后带空格的情况,避免误读 */ class CommandParser(private val commands: List) { - + /** + * 获取指令 + */ + fun getCommands(): List = commands /** * 解析命令 * @param text 输入的文本 diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownManager.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownManager.kt index b48d003..af25b13 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownManager.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownManager.kt @@ -1,30 +1,72 @@ -package top.r3944realms.ltdmanager.module.common +package top.r3944realms.ltdmanager.module.common.cooldown +import top.r3944realms.ltdmanager.GlobalManager.napCatClient +import top.r3944realms.ltdmanager.napcat.data.ID +import top.r3944realms.ltdmanager.napcat.request.group.SetGroupBanRequest +import kotlin.math.max + + +/** + * 管理冷却 + * @param S 状态类型 + */ class CooldownManager( private val cooldownMillis: Long, private val scope: CooldownScope, private val stateProvider: CooldownStateProvider, - private val getLastTrigger: (S, Long?) -> Pair, // (time, realId) - private val updateTrigger: (S, Long?, Long, Long) -> S, // (qq, realId, time) - private val updateCooldownRealId: (S, Long?, Long) -> S // (qq, realId) + private val getLastTrigger: (S, Long?) -> Pair, // (lastTimeSec, lastRealId) + private val updateTrigger: (S, Long?, Long, Long) -> S, // 更新 lastTimeSec, lastRealId + private val updateCooldownRealId: (S, Long?, Long) -> S, + private val groupId: Long, // 所属群组 ID,用于禁言 + private val banSeconds: Int = 60 // 重复发送禁言时间 ) { + private var state: S = stateProvider.load() - fun check(userId: Long?, realId: Long, msgTime: Long): CooldownResult { - val (lastTime, lastCooldownRealId) = getLastTrigger(state, if (scope == CooldownScope.PerUser) userId else null) - val nowSec = System.currentTimeMillis() / 1000 + /** + * 检查冷却 + * @param userId PerUser 模式必须 + * @param realId 消息 realId + */ + suspend fun checkAndHandle(userId: Long?, realId: Long): CooldownResult { + require(scope != CooldownScope.PerUser || userId != null) { "userId required for per-user cooldown" } + + val (lastTimeSec, lastCooldownRealId) = getLastTrigger(state, userId) val cooldownSec = cooldownMillis / 1000 - return if (lastTime == -1L || nowSec - lastTime >= cooldownSec) { - state = updateTrigger(state, userId, realId, msgTime) + val now = System.currentTimeMillis() / 1000 + val elapsed = if (lastTimeSec == -1L) Long.MAX_VALUE else now - lastTimeSec + + return if (elapsed >= cooldownSec) { + // ✅ 冷却结束,允许处理消息 + state = updateTrigger(state, userId, realId, now) stateProvider.save(state) - CooldownResult(true) + CooldownResult(allowed = true, remaining = 0, notify = false) } else { - if (realId != lastCooldownRealId) { + val remaining = max(0, cooldownSec - elapsed) + val notify = realId != lastCooldownRealId // 第一次触发冷却提示 + + if (notify) { + // 第一次冷却提示,记录消息 ID state = updateCooldownRealId(state, userId, realId) stateProvider.save(state) + } else { +// // ⚠️ 重复发送冷却消息 -> 禁言 +// if (userId != null) { +// banUser(userId, groupId, banSeconds) +// } } - CooldownResult(false, cooldownSec - (nowSec - lastTime)) + + CooldownResult(allowed = false, remaining = remaining, notify = notify) } } + + private suspend fun banUser(userId: Long, groupId: Long, seconds: Int) { + val request = SetGroupBanRequest( + duration = seconds.toDouble(), + groupId = ID.long(groupId), + userId = ID.long(userId) + ) + napCatClient.sendUnit(request) + } } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownResult.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownResult.kt index 5718441..5420599 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownResult.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownResult.kt @@ -1,6 +1,13 @@ -package top.r3944realms.ltdmanager.module.common +package top.r3944realms.ltdmanager.module.common.cooldown +/** + * 冷却结果 + * @param allowed 是否允许触发 + * @param remaining 剩余秒数(如果未允许触发) + * @param notify 是否可以发送冷却提示 + */ data class CooldownResult( - val canTrigger: Boolean, - val remainingSeconds: Long = 0 + val allowed: Boolean, + val remaining: Long = 0L, + val notify: Boolean = true ) \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownScope.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownScope.kt index 2f4e1a7..50523f7 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownScope.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownScope.kt @@ -1,6 +1,6 @@ -package top.r3944realms.ltdmanager.module.common +package top.r3944realms.ltdmanager.module.common.cooldown sealed class CooldownScope { - object Global : CooldownScope() - object PerUser : CooldownScope() + data object Global : CooldownScope() + data object PerUser : CooldownScope() } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownStateProvider.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownStateProvider.kt index 63683ab..7a190e7 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownStateProvider.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownStateProvider.kt @@ -1,4 +1,4 @@ -package top.r3944realms.ltdmanager.module.common +package top.r3944realms.ltdmanager.module.common.cooldown interface CooldownStateProvider { fun load(): S diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/MessageFilter.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/MessageFilter.kt index 465bbc4..c9d11a6 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/MessageFilter.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/MessageFilter.kt @@ -1,4 +1,7 @@ package top.r3944realms.ltdmanager.module.common.filter +import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent + interface MessageFilter { + suspend fun test(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/TriggerMessageFilter.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/TriggerMessageFilter.kt index ba2d874..394461b 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/TriggerMessageFilter.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/TriggerMessageFilter.kt @@ -1,7 +1,5 @@ -package top.r3944realms.ltdmanager.module.common +package top.r3944realms.ltdmanager.module.common.filter -import top.r3944realms.ltdmanager.module.common.cooldown.CooldownManager -import top.r3944realms.ltdmanager.napcat.data.MessageType import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent class TriggerMessageFilter(private val filters: List) { @@ -16,55 +14,4 @@ class TriggerMessageFilter(private val filters: List) { } return result } -} -interface MessageFilter { - suspend fun test(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean -} -/** 忽略机器人自己的消息 */ -class IgnoreSelfFilter(private val selfId: Long) : MessageFilter { - override suspend fun test(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean { - return msg.userId != selfId - } -} - -/** 只保留比上次触发更新的消息 */ -class NewMessageFilter( - private val getLastTrigger: (Long) -> Pair // (time, realId) -) : MessageFilter { - override suspend fun test(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean { - val (lastTime, lastRealId) = getLastTrigger(msg.userId) - return msg.time > lastTime || (msg.time == lastTime && msg.realId > lastRealId) - } -} - -/** 文本关键词匹配 */ -class KeywordFilter(private val keywords: Set) : MessageFilter { - override suspend fun test(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean { - return msg.message.any { seg -> - seg.type == MessageType.Text && seg.data.text?.let { it in keywords } == true - } - } -} - -/** 命令解析器匹配 */ -class CommandFilter(private val parser: CommandParser) : MessageFilter { - override suspend fun test(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean { - return msg.message.any { seg -> - seg.type == MessageType.Text && seg.data.text?.let { parser.containsCommand(it) } == true - } - } -} - -/** 冷却检查 */ -class CooldownFilter( - private val cooldownManager: CooldownManager<*>, - private val sendCooldown: suspend (GetFriendMsgHistoryEvent.SpecificMsg, Long) -> Unit -) : MessageFilter { - override suspend fun test(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean { - val result = cooldownManager.check(msg.userId, msg.realId, msg.time) - if (!result.canTrigger) { - sendCooldown(msg, result.remainingSeconds) - } - return result.canTrigger - } } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/CommandFilter.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/CommandFilter.kt index d61273f..738d712 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/CommandFilter.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/CommandFilter.kt @@ -1,4 +1,15 @@ package top.r3944realms.ltdmanager.module.common.filter.type -class CommandFilter { +import top.r3944realms.ltdmanager.module.common.CommandParser +import top.r3944realms.ltdmanager.module.common.filter.MessageFilter +import top.r3944realms.ltdmanager.napcat.data.MessageType +import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent + +/** 命令解析器匹配 */ +class CommandFilter(private val parser: CommandParser) : MessageFilter { + override suspend fun test(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean { + return msg.message.any { seg -> + seg.type == MessageType.Text && seg.data.text?.let { parser.containsCommand(it) } == true + } + } } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/CooldownFilter.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/CooldownFilter.kt index a5dccca..0244489 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/CooldownFilter.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/CooldownFilter.kt @@ -1,4 +1,19 @@ package top.r3944realms.ltdmanager.module.common.filter.type -class CooldownFilter { +import top.r3944realms.ltdmanager.module.common.cooldown.CooldownManager +import top.r3944realms.ltdmanager.module.common.filter.MessageFilter +import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent + +class CooldownFilter( + private val cooldownManager: CooldownManager<*>, + private val sendCooldown: suspend (GetFriendMsgHistoryEvent.SpecificMsg, Long) -> Unit +) : MessageFilter { + + override suspend fun test(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean { + val result = cooldownManager.checkAndHandle(msg.userId, msg.realId) + if (!result.allowed && result.notify) { + sendCooldown(msg, result.remaining) + } + return result.allowed + } } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/IgnoreSelfFilter.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/IgnoreSelfFilter.kt index 58e0874..60b49ec 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/IgnoreSelfFilter.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/IgnoreSelfFilter.kt @@ -1,4 +1,11 @@ package top.r3944realms.ltdmanager.module.common.filter.type -class IgnoreSelfFilter { +import top.r3944realms.ltdmanager.module.common.filter.MessageFilter +import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent + +/** 忽略机器人自己的消息 */ +class IgnoreSelfFilter(private val selfId: Long) : MessageFilter { + override suspend fun test(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean { + return msg.userId != selfId + } } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/KeywordFilter.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/KeywordFilter.kt index ab7ef96..c4690bd 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/KeywordFilter.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/KeywordFilter.kt @@ -1,4 +1,14 @@ package top.r3944realms.ltdmanager.module.common.filter.type -class KeywordFilter { +import top.r3944realms.ltdmanager.module.common.filter.MessageFilter +import top.r3944realms.ltdmanager.napcat.data.MessageType +import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent + +/** 文本关键词匹配 */ +class KeywordFilter(private val keywords: Set) : MessageFilter { + override suspend fun test(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean { + return msg.message.any { seg -> + seg.type == MessageType.Text && seg.data.text?.let { it in keywords } == true + } + } } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/NewMessageFilter.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/NewMessageFilter.kt index 44cbf45..136dfbc 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/NewMessageFilter.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/NewMessageFilter.kt @@ -1,4 +1,18 @@ package top.r3944realms.ltdmanager.module.common.filter.type -class NewMessageFilter { +import top.r3944realms.ltdmanager.module.common.filter.MessageFilter +import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent +import top.r3944realms.ltdmanager.utils.Environment +import top.r3944realms.ltdmanager.utils.LoggerUtil + +/** 只保留比上次触发更新的消息 */ +class NewMessageFilter( + private val getLastTrigger: (Long) -> Pair // (time, realId) +) : MessageFilter { + override suspend fun test(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean { + val (lastTime, lastRealId) = getLastTrigger(msg.userId) + val result = msg.time > lastTime || (msg.time == lastTime && msg.realId > lastRealId) + if (Environment.isDevelopment()) LoggerUtil.logger.debug("NewMessageFilter: msg.time=${msg.time}, msg.realId=${msg.realId}, lastTime=$lastTime, lastRealId=$lastRealId, result=$result") + return result + } } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/utils/FileNameFilter.kt b/src/main/kotlin/top/r3944realms/ltdmanager/utils/FileNameFilter.kt index da29379..4587fda 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/utils/FileNameFilter.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/utils/FileNameFilter.kt @@ -1,4 +1,120 @@ package top.r3944realms.ltdmanager.utils +/** + * 文件名非法字符过滤器 + */ object FileNameFilter { + + // Windows系统非法字符 + private val WINDOWS_ILLEGAL_CHARS = setOf('\\', '/', ':', '*', '?', '"', '<', '>', '|') + + // Unix系统非法字符(主要是/和空字符) + private val UNIX_ILLEGAL_CHARS = setOf('/', '\u0000') + + // 通用非法字符(控制字符) + private val CONTROL_CHARS = (0x00..0x1F).map { it.toChar() }.toSet() + + // Windows保留文件名 + private val WINDOWS_RESERVED_NAMES = setOf( + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + ) + + /** + * 过滤文件名中的非法字符 + * @param fileName 原始文件名(不包含路径) + * @param systemType 目标系统类型 + * @param replaceChar 替换字符 + * @param handleReservedNames 是否处理保留名称 + * @return 过滤后的安全文件名 + */ + fun filterFileName( + fileName: String, + systemType: SystemType = SystemType.CROSS_PLATFORM, + replaceChar: Char = '_', + handleReservedNames: Boolean = true + ): String { + if (fileName.isEmpty()) return fileName + + var filtered = when (systemType) { + SystemType.WINDOWS -> filterForWindows(fileName, replaceChar) + SystemType.UNIX -> filterForUnix(fileName, replaceChar) + SystemType.CROSS_PLATFORM -> filterCrossPlatform(fileName, replaceChar) + } + + // 处理保留名称 + if (handleReservedNames && systemType != SystemType.UNIX) { + filtered = handleReservedName(filtered, replaceChar) + } + + // 确保文件名不以点或空格结尾(某些系统有问题) + filtered = filtered.trimEnd('.', ' ') + + // 如果过滤后为空,返回默认名称 + return if (filtered.isEmpty()) "unnamed_file" else filtered + } + + /** + * 为Windows系统过滤 + */ + private fun filterForWindows(fileName: String, replaceChar: Char): String { + val illegalChars = WINDOWS_ILLEGAL_CHARS + CONTROL_CHARS + return fileName.map { if (it in illegalChars) replaceChar else it }.joinToString("") + } + + /** + * 为Unix系统过滤 + */ + private fun filterForUnix(fileName: String, replaceChar: Char): String { + val illegalChars = UNIX_ILLEGAL_CHARS + CONTROL_CHARS + return fileName.map { if (it in illegalChars) replaceChar else it }.joinToString("") + } + + /** + * 跨平台过滤 + */ + private fun filterCrossPlatform(fileName: String, replaceChar: Char): String { + val illegalChars = WINDOWS_ILLEGAL_CHARS + UNIX_ILLEGAL_CHARS + CONTROL_CHARS + return fileName.map { if (it in illegalChars) replaceChar else it }.joinToString("") + } + + /** + * 处理Windows保留名称 + */ + private fun handleReservedName(fileName: String, replaceChar: Char): String { + val nameWithoutExt = fileName.substringBeforeLast('.') + val extension = fileName.substringAfterLast('.', "") + + return if (WINDOWS_RESERVED_NAMES.contains(nameWithoutExt.uppercase())) { + val newName = "${nameWithoutExt}$replaceChar" + if (extension.isNotEmpty()) "$newName.$extension" else newName + } else { + fileName + } + } + + /** + * 批量处理文件名 + */ + fun batchFilterFileNames( + fileNames: List, + systemType: SystemType = SystemType.CROSS_PLATFORM, + replaceChar: Char = '_' + ): Map { + return fileNames.associateWith { filterFileName(it, systemType, replaceChar) } + } + + /** + * 验证文件名是否安全 + */ + fun isFileNameSafe( + fileName: String, + systemType: SystemType = SystemType.CROSS_PLATFORM + ): Boolean { + if (fileName.isEmpty()) return false + + val filtered = filterFileName(fileName, systemType, '_', true) + return fileName == filtered && fileName == filtered.trimEnd('.', ' ') + } } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/utils/SystemType.kt b/src/main/kotlin/top/r3944realms/ltdmanager/utils/SystemType.kt index da4bd69..f755df7 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/utils/SystemType.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/utils/SystemType.kt @@ -1,4 +1,8 @@ package top.r3944realms.ltdmanager.utils +/** + * 系统类型枚举 + */ enum class SystemType { + WINDOWS, UNIX, CROSS_PLATFORM } \ No newline at end of file diff --git a/src/test/kotlin/top/r394realms/ltdmanagertest/mail/MailTest.kt b/src/test/kotlin/top/r394realms/ltdmanagertest/mail/MailTest.kt index 58c231b..944f670 100644 --- a/src/test/kotlin/top/r394realms/ltdmanagertest/mail/MailTest.kt +++ b/src/test/kotlin/top/r394realms/ltdmanagertest/mail/MailTest.kt @@ -13,6 +13,7 @@ fun main() = GlobalManager.runBlockingMain { val mailModule = mailConfig.port?.let { portIt -> mailConfig.mailAddress?.let { mailAddressIt -> MailModule( + moduleName = "WhiteListGroup", host = mailConfig.host.toString(), authToken = mailConfig.decryptedPassword.toString(), port = portIt, @@ -29,7 +30,7 @@ fun main() = GlobalManager.runBlockingMain { val expireHours = 24 // 有效期 24 小时 val expireTime = LocalDateTime.now().plusHours(expireHours.toLong()) .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) - val bodyC = HtmlTemplateUtil.renderTemplate(template.file.toString(), mapOf( + val bodyC = HtmlTemplateUtil.renderTemplateFromClasspath(template.file.toString(), mapOf( "player_name" to "小明", "activation_code" to "ABC123", "expire_time" to expireTime, diff --git a/src/test/kotlin/top/r394realms/ltdmanagertest/misc/StringTest.kt b/src/test/kotlin/top/r394realms/ltdmanagertest/misc/StringTest.kt index 330e9fb..d022564 100644 --- a/src/test/kotlin/top/r394realms/ltdmanagertest/misc/StringTest.kt +++ b/src/test/kotlin/top/r394realms/ltdmanagertest/misc/StringTest.kt @@ -1,2 +1,12 @@ package top.r394realms.ltdmanagertest.misc +fun main() { + val test = "/s 222" + val startsWith = test.startsWith("/s") + var removePrefix = ""; + if (startsWith) { + removePrefix = test.removePrefix("/s") + } + println(startsWith) + println(removePrefix) +} \ No newline at end of file diff --git a/src/test/kotlin/top/r394realms/ltdmanagertest/test.kt b/src/test/kotlin/top/r394realms/ltdmanagertest/test.kt index 4e8686b..6466e44 100644 --- a/src/test/kotlin/top/r394realms/ltdmanagertest/test.kt +++ b/src/test/kotlin/top/r394realms/ltdmanagertest/test.kt @@ -7,6 +7,7 @@ import top.r3944realms.ltdmanager.module.GroupRequestHandlerModule fun main() = GlobalManager.runBlockingMain { // 创建模块实例 val groupModule = GroupRequestHandlerModule( + moduleName = "WhiteListGroup", client = GlobalManager.napCatClient, targetGroupId = 538751386 )