diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/BanModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/BanModule.kt new file mode 100644 index 0000000..3199dfd --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/BanModule.kt @@ -0,0 +1,183 @@ +package top.r3944realms.ltdmanager.module + +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.filter.TriggerMessageFilter +import top.r3944realms.ltdmanager.module.common.filter.type.CommandFilter +import top.r3944realms.ltdmanager.module.common.filter.type.IgnoreSelfFilter +import top.r3944realms.ltdmanager.module.common.filter.type.NewMessageFilter +import top.r3944realms.ltdmanager.napcat.data.ID +import top.r3944realms.ltdmanager.napcat.data.MessageElement +import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent +import top.r3944realms.ltdmanager.napcat.request.group.SetGroupBanRequest +import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest +import top.r3944realms.ltdmanager.utils.LoggerUtil +import java.io.File +import kotlin.random.Random + +/** + * 指令触发禁言模块 + */ +class CommandBanModule( + 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 { + + private val commandParser = CommandParser(commandPrefixList) + private val commandFilter = CommandFilter(commandParser) + private val banState = loadState() + override fun getState(): BanState = banState + + private val triggerFilter by lazy { + TriggerMessageFilter( + listOf( + IgnoreSelfFilter(selfId), + NewMessageFilter { _ -> banState.lastTriggerTime to banState.lastTriggerRealId }, + 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 + + override fun onLoad() { + LoggerUtil.logger.info("[$name] 模块已装载,监听群组: ${groupMessagePollingModule.targetGroupId}") + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + scope!!.launch { + LoggerUtil.logger.info("[$name] 启动消息监听协程") + groupMessagePollingModule.messagesFlow.collect { messages -> + handleMessages(messages) + } + } + } + + override suspend fun onUnload() { + LoggerUtil.logger.info("[$name] 模块卸载,取消协程") + scope?.cancel() + } + + private suspend fun handleMessages(messages: List) { + // 先过一遍过滤器,只有符合条件的才进入后续处理 + val filtered = triggerFilter.filter(messages) + for (msg in filtered) { + processBanCommand(msg) + } + } + /** + * 将 SpecificMsg 中的 message 段拼成一条“可解析文本”。 + * - text 段直接拼接 + * - 如果消息段里包含 @(在 MessageData 中为 qq 字段),则拼成 "@{qq}",方便 parseMentionToUserId 解析 + */ + private fun GetFriendMsgHistoryEvent.SpecificMsg.plainText(): String { + return this.message.joinToString(" ") { seg -> + // 如果 message element 包含 qq 字段(即@用户),优先使用它 + seg.data.qq?.let { "@${it}" } ?: (seg.data.text ?: "") + }.trim() + } + private suspend fun processBanCommand(msg: GetFriendMsgHistoryEvent.SpecificMsg) { + try { + val parsed = commandParser.parseCommand(msg.plainText()) ?: return + val (command, argument) = parsed + + // 参数格式: [分钟] + // 示例:/mute 5 → 自己禁言 5 分钟 + // /mute → 自己随机禁言 + val parts = argument.split(" ").filter { it.isNotBlank() } + + val durationMinutes = parts.getOrNull(0)?.toIntOrNull() + ?: Random.nextInt(minBanMinutes, maxBanMinutes + 1) + val durationSeconds = durationMinutes.coerceIn(minBanMinutes, maxBanMinutes) * 60 + + val targetUserId = msg.sender.userId + + banUser(targetUserId, groupMessagePollingModule.targetGroupId, durationSeconds) + sendGroupMessage("✅ 你已被禁言 $durationMinutes 分钟", msg.realId) + + // 更新状态(保证状态保存正确) + banState.lastTriggerRealId = msg.realId + banState.lastTriggerTime = msg.time + saveState(banState) + + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 执行禁言指令失败", e) + sendGroupMessage("❌ 执行禁言失败,请检查指令格式或权限", msg.realId) + } + } + 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) + LoggerUtil.logger.info("[$name] 已对用户 $userId 执行 $seconds 秒禁言") + } + + private suspend fun sendGroupMessage(text: String, replyTo: Long? = null) { + val request = SendGroupMsgRequest( + MessageElement.reply(ID.long(replyTo ?: 0), text), + ID.long(groupMessagePollingModule.targetGroupId) + ) + napCatClient.sendUnit(request) + } + + override fun info(): String { + return "[$name] 指令禁言模块:用户发送 ${commandParser.getCommands().joinToString("、")} 来禁言自己," + + "支持指定分钟数或随机分钟数,范围 $minBanMinutes-$maxBanMinutes 分钟。" + } + + override fun help(): String { + return buildString { + appendLine("📖 [$name] 使用帮助:") + appendLine(" - ${commandParser.getCommands().joinToString("、")} [分钟]") + appendLine(" · 不写分钟数 → 随机禁言 (范围 $minBanMinutes-$maxBanMinutes 分钟)") + appendLine(" · 写分钟数 → 自己禁言指定分钟数") + appendLine() + appendLine("示例:") + appendLine(" - /mute → 随机禁言自己") + appendLine(" - /mute 5 → 禁言自己 5 分钟") + } + } + + // ---------------- 持久化 ---------------- + @Serializable + data class BanState( + var lastTriggerRealId: Long = -1, + var lastTriggerTime: Long = 0 + ) + + override fun saveState(state: BanState) { + try { + if (stateFile.exists()) stateFile.copyTo(stateBackupFile, overwrite = true) + stateFile.writeText(Json.encodeToString(state)) + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 保存状态失败", e) + } + } + + override fun loadState(): BanState { + return try { + val fileToRead = when { + stateFile.exists() -> stateFile + stateBackupFile.exists() -> stateBackupFile + else -> null + } ?: return BanState() + + Json.decodeFromString(fileToRead.readText()) + } catch (e: Exception) { + LoggerUtil.logger.warn("[$name] 读取状态失败", e) + BanState() + } + } +} diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/HelpModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/HelpModule.kt new file mode 100644 index 0000000..acd6a67 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/HelpModule.kt @@ -0,0 +1,4 @@ +package top.r3944realms.ltdmanager.module + +class HelpModule { +} \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/ModGroupHandlerModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/ModGroupHandlerModule.kt new file mode 100644 index 0000000..19edac2 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/ModGroupHandlerModule.kt @@ -0,0 +1,4 @@ +package top.r3944realms.ltdmanager.module + +class ModGroupHandleModule { +} \ 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 new file mode 100644 index 0000000..0d9f78a --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/CommandParser.kt @@ -0,0 +1,52 @@ +package top.r3944realms.ltdmanager.module.util + +/** + * 命令解析器 + * 严格模式:只支持命令后带空格的情况,避免误读 + */ +class CommandParser(private val commands: List) { + + /** + * 解析命令 + * @param text 输入的文本 + * @return Pair<命令, 参数> 或 null(如果不是有效命令) + */ + fun parseCommand(text: String): Pair? { + val trimmedText = text.trim() + + // 查找匹配的命令(必须后面跟着空格或字符串结束) + val matchedCommand = commands.firstOrNull { command -> + trimmedText.startsWith("$command ") || trimmedText == command + } ?: return null + + // 获取参数部分 + val argument = if (trimmedText.length > matchedCommand.length) { + trimmedText.substring(matchedCommand.length).trim() + } else { + "" + } + + return Pair(matchedCommand, argument) + } + + /** + * 检查文本是否包含有效命令 + */ + fun containsCommand(text: String): Boolean { + return parseCommand(text.trim()) != null + } + + /** + * 获取命令部分(不包含参数) + */ + fun getCommandOnly(text: String): String? { + return parseCommand(text.trim())?.first + } + + /** + * 获取参数部分(不包含命令) + */ + fun getArgumentOnly(text: String): String { + return parseCommand(text.trim())?.second ?: "" + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..b48d003 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownManager.kt @@ -0,0 +1,30 @@ +package top.r3944realms.ltdmanager.module.common + +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 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 + val cooldownSec = cooldownMillis / 1000 + + return if (lastTime == -1L || nowSec - lastTime >= cooldownSec) { + state = updateTrigger(state, userId, realId, msgTime) + stateProvider.save(state) + CooldownResult(true) + } else { + if (realId != lastCooldownRealId) { + state = updateCooldownRealId(state, userId, realId) + stateProvider.save(state) + } + CooldownResult(false, cooldownSec - (nowSec - lastTime)) + } + } +} \ 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 new file mode 100644 index 0000000..5718441 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownResult.kt @@ -0,0 +1,6 @@ +package top.r3944realms.ltdmanager.module.common + +data class CooldownResult( + val canTrigger: Boolean, + val remainingSeconds: Long = 0 +) \ 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 new file mode 100644 index 0000000..2f4e1a7 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownScope.kt @@ -0,0 +1,6 @@ +package top.r3944realms.ltdmanager.module.common + +sealed class CooldownScope { + object Global : CooldownScope() + 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 new file mode 100644 index 0000000..63683ab --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/cooldown/CooldownStateProvider.kt @@ -0,0 +1,6 @@ +package top.r3944realms.ltdmanager.module.common + +interface CooldownStateProvider { + fun load(): S + fun save(state: S) +} \ No newline at end of file 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 new file mode 100644 index 0000000..465bbc4 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/MessageFilter.kt @@ -0,0 +1,4 @@ +package top.r3944realms.ltdmanager.module.common.filter + +interface MessageFilter { +} \ 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 new file mode 100644 index 0000000..ba2d874 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/TriggerMessageFilter.kt @@ -0,0 +1,70 @@ +package top.r3944realms.ltdmanager.module.common + +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) { + suspend fun filter(messages: List) + : List { + + val result = mutableListOf() + for (msg in messages) { + if (filters.all { it.test(msg) }) { + result.add(msg) + } + } + 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 new file mode 100644 index 0000000..d61273f --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/CommandFilter.kt @@ -0,0 +1,4 @@ +package top.r3944realms.ltdmanager.module.common.filter.type + +class CommandFilter { +} \ 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 new file mode 100644 index 0000000..a5dccca --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/CooldownFilter.kt @@ -0,0 +1,4 @@ +package top.r3944realms.ltdmanager.module.common.filter.type + +class CooldownFilter { +} \ 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 new file mode 100644 index 0000000..58e0874 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/IgnoreSelfFilter.kt @@ -0,0 +1,4 @@ +package top.r3944realms.ltdmanager.module.common.filter.type + +class IgnoreSelfFilter { +} \ 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 new file mode 100644 index 0000000..ab7ef96 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/KeywordFilter.kt @@ -0,0 +1,4 @@ +package top.r3944realms.ltdmanager.module.common.filter.type + +class KeywordFilter { +} \ 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 new file mode 100644 index 0000000..44cbf45 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/common/filter/type/NewMessageFilter.kt @@ -0,0 +1,4 @@ +package top.r3944realms.ltdmanager.module.common.filter.type + +class NewMessageFilter { +} \ 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 new file mode 100644 index 0000000..da29379 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/utils/FileNameFilter.kt @@ -0,0 +1,4 @@ +package top.r3944realms.ltdmanager.utils + +object FileNameFilter { +} \ 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 new file mode 100644 index 0000000..da4bd69 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/utils/SystemType.kt @@ -0,0 +1,4 @@ +package top.r3944realms.ltdmanager.utils + +enum class SystemType { +} \ No newline at end of file diff --git a/src/test/kotlin/top/r394realms/ltdmanagertest/misc/StringTest.kt b/src/test/kotlin/top/r394realms/ltdmanagertest/misc/StringTest.kt new file mode 100644 index 0000000..330e9fb --- /dev/null +++ b/src/test/kotlin/top/r394realms/ltdmanagertest/misc/StringTest.kt @@ -0,0 +1,2 @@ +package top.r394realms.ltdmanagertest.misc +