fix: 修正BanModule 中初始化顺序导致的NPE问题

This commit is contained in:
叁玖领域 2025-09-13 03:26:07 +08:00
parent 95e21f8b84
commit 9f83026e56
31 changed files with 1200 additions and 336 deletions

View File

@ -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

View File

@ -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)
}

View File

@ -21,33 +21,36 @@ import kotlin.random.Random
/**
* 指令触发禁言模块
*/
class CommandBanModule(
class BanModule(
moduleName: String,
private val groupMessagePollingModule : GroupMessagePollingModule,
private val selfId: Long,
commandPrefixList: List<String> = listOf("/mute"), // 默认命令前缀
private val minBanMinutes: Int = 1,
private val maxBanMinutes: Int = 15
) : BaseModule("CommandBanModule", moduleName), PersistentState<CommandBanModule.BanState> {
) : BaseModule("BanModule", moduleName), PersistentState<BanModule.BanState> {
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<Long, UserBanDetail> = 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)

View File

@ -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()
}

View File

@ -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 存消息,其他模块可以订阅

View File

@ -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 = "本模块会轮询群组加群请求并根据数据库白名单自动同意或拒绝"
}

View File

@ -1,4 +1,245 @@
package top.r3944realms.ltdmanager.module
class HelpModule {
}
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<String> = listOf("help", "帮助"),
private val cooldownMillis: Long = 30_000L
) : BaseModule("HelpModule", moduleName), PersistentState<HelpModule.HelpState> {
// 命令解析器
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<HelpState> {
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<GetFriendMsgHistoryEvent.SpecificMsg>) {
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<HelpState>(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"
}

View File

@ -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<String> = setOf("申请邀请码")
) : BaseModule(), PersistentState<InvitationCodesModule.LastTriggerMapState> {
) : BaseModule("InvitationCodesModule", moduleName), PersistentState<InvitationCodesModule.LastTriggerMapState> {
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<LastTriggerMapState> {
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<GetFriendMsgHistoryEvent.SpecificMsg>)
: List<GetFriendMsgHistoryEvent.SpecificMsg> {
private suspend fun filterTriggerMessages(
messages: List<GetFriendMsgHistoryEvent.SpecificMsg>
): List<GetFriendMsgHistoryEvent.SpecificMsg> {
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<GetFriendMsgHistoryEvent.SpecificMsg>,
@ -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()
}
}

View File

@ -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<Mail>() // 邮件队列
@ -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/授权码登录")
}
}
}

View File

@ -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<String> = listOf("/mcs", "/s"),
private val presetServer: Map<Set<String>, String> = mapOf(
setOf("hp", "hypixel") to "mc.hypixel.net",
setOf("pm", "mineplex") to "play.mineplex.com"
)
) : BaseModule(), PersistentState<McServerStatusModule.CooldownState> {
) : BaseModule("McServerStatusModule", moduleName), PersistentState<McServerStatusModule.CooldownState> {
private val stateFile:File = getStateFileInternal("mc_server_status_state.json", name)
private val stateBackupFile:File = getStateFileInternal("mc_server_status_state.json.bak", name)
private val commandParser: CommandParser = CommandParser(commands)
private val cooldownManager by lazy {
CooldownManager(
cooldownMillis = cooldownMillis,
scope = CooldownScope.PerUser,
stateProvider = object : CooldownStateProvider<CooldownState> {
override fun load() = loadState()
override fun save(state: CooldownState) = saveState(state)
},
getLastTrigger = { state, qq ->
val detail = state.map[qq]
(detail?.time ?: -1L) to (detail?.lastCooldownRealId ?: -1L)
},
updateTrigger = { state, qq, realId, time ->
val id = requireNotNull(qq) { "userId required for per-user cooldown" }
state.updateLastTrigger(id, realId, time) }
,
updateCooldownRealId = { state, qq, realId ->
val id = requireNotNull(qq) { "userId required for per-user cooldown" }
state.updateLastCooldownRealId(id, realId)
},
groupId = groupMessagePollingModule.targetGroupId
)
}
private val triggerFilter = TriggerMessageFilter(
listOf(
IgnoreSelfFilter(selfId),
NewMessageFilter { qq ->
cooldownState.getLastTriggerTime(qq) to cooldownState.getLastTriggerRealId(qq)
},
CommandFilter(commandParser),
CooldownFilter(
cooldownManager = cooldownManager,
sendCooldown = { msg, remaining ->
sendCooldownMessage(napCatClient, msg.realId, "⏳ 查询过于频繁, $remaining 秒后执行查询,切勿重复发送")
}
)
)
)
private val presetServerByAlias: Map<String, String> by lazy {
presetServer.flatMap { (aliases, ip) ->
aliases.map { it.lowercase() to ip }
}.toMap()
}
fun getServerIp(alias: String): String? = presetServerByAlias[alias.lowercase()]
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<GetFriendMsgHistoryEvent.SpecificMsg>)
: List<GetFriendMsgHistoryEvent.SpecificMsg> {
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<GetFriendMsgHistoryEvent.SpecificMsg>
): List<GetFriendMsgHistoryEvent.SpecificMsg> = 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<Long, TriggerDetail> = 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(" - 查询结果会以转发消息形式发送到群组")
}
}

View File

@ -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<String> = listOf("正确答案"),
private val pollIntervalMillis: Long = 30_000L
) : BaseModule("ModGroupHandlerModule", moduleName), PersistentState<ModGroupHandlerModule.RejectRecords> {
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<String> = mutableListOf(),
var rejectCount: Int = 0
)
/**
* 记录所有被拒绝用户的Mapkey = userId
*/
@Serializable
data class RejectRecords(
val records: MutableMap<Long, RejectRecord> = 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<GetStrangerInfoEvent>(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<NapCatEvent>(setRequest)
if (levelAllow) napCatClient.send<NapCatEvent>(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<GetGroupSystemMsgEvent.SystemInfo>
}
private fun GetGroupSystemMsgEvent.asProvider(): GroupRequestProvider = object : GroupRequestProvider {
override fun getAllRequests(): List<GetGroupSystemMsgEvent.SystemInfo> =
data.invitedRequest + data.joinRequests
}
private fun GetGroupIgnoredNotifiesEvent.asProvider(): GroupRequestProvider = object : GroupRequestProvider {
override fun getAllRequests(): List<GetGroupSystemMsgEvent.SystemInfo> =
data.invitedRequest + data.joinRequests
}
override fun info(): String = """
模块: $name
功能: 自动处理指定群组的入群申请
1. 根据答案列表自动同意或拒绝
2. 拒绝记录会保存到本地并可查询尝试次数和尝试答案
3. 用户通过验证且等级满足要求时会向群里发送消息显示用户QQ号尝试次数评分和尝试答案
版本: 1.0
""".trimIndent()
override fun help(): String = "轮询群组入群申请,根据答案列表自动同意或拒绝,并记录拒绝用户信息"
}

View File

@ -6,6 +6,9 @@ class ModuleManager {
private val modules = mutableMapOf<String, BaseModule>()
fun getModules(): Map<String, BaseModule> {
return (modules).toMap()
}
/**
* 注册模块到管理器
*/
@ -74,6 +77,13 @@ class ModuleManager {
}
}
/**
* 提供获取所有模块信息的方法
*/
fun getAllModuleInfo(): Map<String, String> {
return modules.mapValues { it.value.info() }
}
/**
* 获取所有模块名称
*/

View File

@ -1,15 +1,16 @@
package top.r3944realms.ltdmanager.module
import top.r3944realms.ltdmanager.utils.FileNameFilter
import java.io.File
interface PersistentState<T> {
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)
}

View File

@ -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<String> = setOf("查看玩家列表", "玩家列表", "在线玩家")
) : BaseModule(), PersistentState<LastTriggerState> {
override val name: String = "RconPlayerListModule"
) : BaseModule("RconPlayerListModule", moduleName), PersistentState<LastTriggerState> {
private val cooldownManager by lazy {
CooldownManager(
cooldownMillis = cooldownMillis,
scope = CooldownScope.Global,
stateProvider = object : CooldownStateProvider<LastTriggerState> {
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<GetFriendMsgHistoryEvent.SpecificMsg>) {
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(" - 查询结果会以转发消息形式发送到群组")
}
}

View File

@ -1,11 +1,14 @@
package top.r3944realms.ltdmanager.module.util
package top.r3944realms.ltdmanager.module.common
/**
* 命令解析器
* 严格模式只支持命令后带空格的情况避免误读
*/
class CommandParser(private val commands: List<String>) {
/**
* 获取指令
*/
fun getCommands(): List<String> = commands
/**
* 解析命令
* @param text 输入的文本

View File

@ -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<S>(
private val cooldownMillis: Long,
private val scope: CooldownScope,
private val stateProvider: CooldownStateProvider<S>,
private val getLastTrigger: (S, Long?) -> Pair<Long, Long>, // (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<Long, Long>, // (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)
}
}

View File

@ -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
)

View File

@ -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()
}

View File

@ -1,4 +1,4 @@
package top.r3944realms.ltdmanager.module.common
package top.r3944realms.ltdmanager.module.common.cooldown
interface CooldownStateProvider<S> {
fun load(): S

View File

@ -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
}

View File

@ -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<MessageFilter>) {
@ -16,55 +14,4 @@ class TriggerMessageFilter(private val filters: List<MessageFilter>) {
}
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<Long, Long> // (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<String>) : 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
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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<String>) : 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
}
}
}

View File

@ -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<Long, Long> // (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
}
}

View File

@ -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<String>,
systemType: SystemType = SystemType.CROSS_PLATFORM,
replaceChar: Char = '_'
): Map<String, String> {
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('.', ' ')
}
}

View File

@ -1,4 +1,8 @@
package top.r3944realms.ltdmanager.utils
/**
* 系统类型枚举
*/
enum class SystemType {
WINDOWS, UNIX, CROSS_PLATFORM
}

View File

@ -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,

View File

@ -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)
}

View File

@ -7,6 +7,7 @@ import top.r3944realms.ltdmanager.module.GroupRequestHandlerModule
fun main() = GlobalManager.runBlockingMain {
// 创建模块实例
val groupModule = GroupRequestHandlerModule(
moduleName = "WhiteListGroup",
client = GlobalManager.napCatClient,
targetGroupId = 538751386
)