feat: 添加邀请码申请模块

This commit is contained in:
叁玖领域 2025-08-31 21:14:35 +08:00
parent ba46c18fc1
commit 6e433b3377
46 changed files with 2518 additions and 374 deletions

2
.gitignore vendored
View File

@ -43,3 +43,5 @@ bin/
/logs/
/config/
/rcon_playerlist_state.json
/invitation_codes_quarry_state.json.bak
/invitation_codes_quarry_state.json

View File

@ -2,6 +2,7 @@
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/src/main/kotlin/top/r3944realms/ltdmanager/module/GroupRequestHandlerModule.kt" dialect="GenericSQL" />
<file url="file://$PROJECT_DIR$/src/main/resources/init.sql" dialect="MySQL" />
<file url="PROJECT" dialect="MySQL" />
</component>
</project>

View File

@ -39,6 +39,10 @@ repositories {
implementation("com.mysql:mysql-connector-j:8.0.33") // 使用MySQL 8.x驱动
implementation("com.zaxxer:HikariCP:5.0.1") // 连接池
// 邮箱相关
implementation("jakarta.mail:jakarta.mail-api:2.0.1") //API
implementation("com.sun.mail:jakarta.mail:2.0.1") // 实现
// 日志系统
implementation("org.slf4j:slf4j-api:2.0.7")
implementation("org.apache.logging.log4j:log4j-slf4j2-impl:2.20.0")
@ -70,7 +74,7 @@ repositories {
}
tasks {
// ShadowJar 配置
named<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar>("shadowJar") {
named<ShadowJar>("shadowJar") {
archiveClassifier.set("") // 去掉 -all 后缀
mergeServiceFiles()
manifest {

View File

@ -1,3 +1,40 @@
# NapCat
将回应抽象为event模型
将请求抽象为request模型
将请求抽象为request模型
## InvitationCodesModule 模块设计时序表
```mermaid
sequenceDiagram
participant User as 用户
participant Bot as 机器人
participant DB as 数据库视图/表
participant API as Token API
User->>Bot: 发送消息触发关键词
Bot->>DB: 根据QQ查询 qualified_user_info 获取id, effective, is_used, token
alt id不存在
DB-->>Bot: 无记录
Bot-->>User: 提示无法查询id请联系管理员
else id存在
alt effective=1 && is_used=1
Bot-->>User: 提示邀请码已使用,勿重复发送
else effective=1 && is_used=0
DB-->>Bot: 返回token
Bot->>User: 构造邮件并发送token
Bot-->>User: 邮件已发送
else effective=0
Bot->>API: 请求生成新Token
alt API返回 success=false
API-->>Bot: 返回错误信息
Bot-->>User: 提示API错误消息
else API返回 success=true
API-->>Bot: 返回新Token
Bot->>User: 构造邮件并发送新Token
Bot-->>User: 邮件已发送
Bot->>DB: 查询邀请码数据库获取 token_id
DB-->>Bot: 返回 token_id
Bot->>DB: 写入/更新 invitation_code_ascription 映射
end
end
end
````

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.2-SNAPSHOT
project_version=1.3-SNAPSHOT

View File

@ -1,18 +1,18 @@
package top.r3944realms.ltdmanager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.*
import top.r3944realms.ltdmanager.blessingskin.BlessingSkinClient
import top.r3944realms.ltdmanager.core.mysql.MysqlHikariConnectPool
import top.r3944realms.ltdmanager.module.ModuleManager
import top.r3944realms.ltdmanager.napcat.NapCatClient
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.sql.Connection
import java.util.concurrent.atomic.AtomicBoolean
object GlobalManager {
// 单例作用域,可在模块中使用协程
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val isRunning = AtomicBoolean(true)
// Hikari 数据源
private val dataSource: MysqlHikariConnectPool by lazy {
MysqlHikariConnectPool()
@ -22,6 +22,9 @@ object GlobalManager {
val napCatClient: NapCatClient by lazy {
NapCatClient.create()
}
val blessingSkinClient: BlessingSkinClient by lazy {
BlessingSkinClient.create()
}
val moduleManager: ModuleManager by lazy { ModuleManager() }
@ -32,24 +35,58 @@ object GlobalManager {
fun getConnection(): Connection {
return dataSource.getConnection()
}
/**
* 关闭全局资源
* 例如在应用退出时调用
*/
fun shutdown() {
try {
LoggerUtil.logger.info("关闭 NapCatClient")
napCatClient.close()
} catch (e: Exception) {
LoggerUtil.logger.warn("关闭 NapCatClient 失败", e)
fun runBlockingMain(block: suspend () -> Unit) = runBlocking {
// 注册全局关闭钩子
LoggerUtil.addShutdownHook {
shutdownResources()
}
// 启动逻辑交给外部传入
block()
try {
LoggerUtil.logger.info("关闭 Hikari 数据源")
dataSource.close()
} catch (e: Exception) {
LoggerUtil.logger.warn("关闭 Hikari 数据源失败", e)
// 注册优雅关闭
Runtime.getRuntime().addShutdownHook(Thread {
shutdownGracefully()
})
// 保持运行
keepRunning()
}
private fun keepRunning() = runBlocking {
while (isRunning.get()) {
delay(1000L)
}
}
private fun shutdownResources() {
val resources = listOf(
"NapCatClient" to { napCatClient.close() },
"BlessingSkinClient" to { blessingSkinClient.close() },
"Hikari 数据源" to { dataSource.close() }
)
resources.forEach { (name, closer) ->
try {
LoggerUtil.syncInfo("关闭 $name")
closer()
LoggerUtil.syncInfo("$name 关闭完成")
} catch (e: Exception) {
LoggerUtil.syncError("关闭 $name 失败", e)
}
}
}
fun shutdownGracefully() = runBlocking {
LoggerUtil.syncInfo("\n收到退出信号,正在停止所有模块...")
moduleManager.stopAllModules()
LoggerUtil.syncInfo("模块卸载完成,开始关闭资源...")
// 这会触发 LoggerUtil 中注册的关闭钩子
LoggerUtil.shutdownGracefully()
isRunning.set(false)
}
}

View File

@ -0,0 +1,212 @@
package top.r3944realms.ltdmanager.blessingskin
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import top.r3944realms.ltdmanager.utils.Environment
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.net.URLEncoder
import java.util.*
class BlessingSkinClient private constructor() : AutoCloseable {
private val client = HttpClient(CIO) {
expectSuccess = false
// 安装 HttpTimeout 插件
install(HttpTimeout) {
// 默认超时配置,会被具体请求的配置覆盖
requestTimeoutMillis = 30000
connectTimeoutMillis = 10000
socketTimeoutMillis = 15000
}
}
private val blessingSkinServerConfig = YamlConfigLoader.loadBlessingSkinServerConfig()
// 限流控制
private val semaphore = Semaphore(5)
private val requestMutex = Mutex()
private val requestQueue = PriorityQueue<BlessingSkinQueueItem<BlessingSkinResponse, FailedBlessingSkinResponse>>(compareBy { it.priority })
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
startQueueProcessor()
}
/**
* 提交请求
*/
suspend fun <T : BlessingSkinResponse, F : FailedBlessingSkinResponse> submitRequest(
request: BlessingSkinRequest<T, F>,
priority: Int = 5,
maxRetries: Int = 3
): ResponseResult<T, F> {
val deferred = CompletableDeferred<ResponseResult<T, F>>()
requestMutex.withLock {
requestQueue.add(BlessingSkinQueueItem(request, deferred, priority, maxRetries, true))
}
return deferred.await()
}
/**
* 启动队列处理器
*/
private fun startQueueProcessor() {
scope.launch {
while (isActive) {
val item = requestMutex.withLock {
requestQueue.poll()
}
if (item == null) {
delay(50)
continue
}
processQueueItem(item)
}
}
}
/**
* 处理队列项
*/
private suspend fun processQueueItem(item: BlessingSkinQueueItem<BlessingSkinResponse, FailedBlessingSkinResponse>) {
semaphore.withPermit {
val (request, deferred, _, maxRetries, expectsResponse) = item
var attempt = 0
var lastError: Exception? = null
while (attempt < maxRetries) {
try {
// 构建完整的URL包括查询参数
val fullUrl = buildFullUrlWithQueryParams(request)
if (!Environment.isProduction()) {
LoggerUtil.logger.debug("发送请求到: $fullUrl")
LoggerUtil.logger.debug("请求方法: {}", request.method())
}
val response = client.request(fullUrl) {
method = request.method()
// 设置请求头
headers {
request.headers().invoke(this)
}
// 对于非GET请求设置请求体
if (request.method() != HttpMethod.Get) {
setBody(request.toJSON())
}
}
val responseText: String = response.body()
if (!Environment.isProduction()) {
LoggerUtil.logger.debug("响应状态: {}", response.status)
LoggerUtil.logger.debug("响应内容: $responseText")
}
// 检查是否是HTML响应重定向
if (isHtmlResponse(responseText)) {
throw IllegalStateException("接收到HTML重定向响应请检查API URL配置")
}
// 解析响应
val result = request.getResponse(responseText, response.status)
@Suppress("UNCHECKED_CAST")
(deferred as CompletableDeferred<ResponseResult<BlessingSkinResponse, FailedBlessingSkinResponse>>).complete(result)
return
} catch (e: Exception) {
lastError = e
attempt++
if (!request.shouldRetryOnFailure() || attempt >= maxRetries) {
break
}
LoggerUtil.logger.warn("BlessingSkin请求失败 (尝试 $attempt/$maxRetries): ${e.message}")
delay((attempt * 1000L)) // 指数退避
}
}
// 所有重试都失败或不应重试
val errorResponse = createFailureResponse(lastError, request)
@Suppress("UNCHECKED_CAST")
(deferred as CompletableDeferred<ResponseResult<BlessingSkinResponse, FailedBlessingSkinResponse>>).complete(
ResponseResult.Failure(errorResponse)
)
}
}
/**
* 构建完整的URL包含查询参数
*/
private fun buildFullUrlWithQueryParams(request: BlessingSkinRequest<*, *>): String {
val baseUrl = blessingSkinServerConfig.url?.removeSuffix("/")
val path = request.path().removePrefix("/")
// 构建基础URL
val urlBuilder = StringBuilder("$baseUrl/$path")
// 添加查询参数
val queryParams = request.queryParameters().entries.joinToString("&") { (key, value) ->
"${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}"
}
if (queryParams.isNotEmpty()) {
urlBuilder.append("?").append(queryParams)
}
return urlBuilder.toString()
}
/**
* 检查是否是HTML响应
*/
private fun isHtmlResponse(text: String): Boolean {
return text.contains("<!DOCTYPE html>", ignoreCase = true) ||
text.contains("<html>", ignoreCase = true) ||
text.contains("Redirecting", ignoreCase = true)
}
/**
* 创建失败响应
*/
private fun createFailureResponse(
exception: Exception?,
request: BlessingSkinRequest<*, *>
): FailedBlessingSkinResponse {
return FailedBlessingSkinResponse.Default(
failedResult = exception?.message ?: "未知错误",
)
}
override fun close() {
scope.cancel()
runBlocking {
client.close()
}
}
companion object {
fun create(): BlessingSkinClient = BlessingSkinClient()
}
}

View File

@ -0,0 +1,16 @@
package top.r3944realms.ltdmanager.blessingskin
import kotlinx.coroutines.CompletableDeferred
import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
data class BlessingSkinQueueItem<out T:BlessingSkinResponse,out F:FailedBlessingSkinResponse>(
val request: BlessingSkinRequest<T,F>,
val deferred: CompletableDeferred<*>,
var retries: Int,
val priority: Int,
val expectsResponse: Boolean // true 表示返回 BlessingSkinResponse, false 表示 Unit
) : Comparable<BlessingSkinQueueItem<@UnsafeVariance T, @UnsafeVariance F>> {
override fun compareTo(other: BlessingSkinQueueItem<@UnsafeVariance T, @UnsafeVariance F>): Int = priority.compareTo(other.priority)
}

View File

@ -0,0 +1,79 @@
package top.r3944realms.ltdmanager.blessingskin.request
import io.ktor.http.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult
@Serializable
abstract class BlessingSkinRequest<out T : BlessingSkinResponse, out F : FailedBlessingSkinResponse>(
@Transient
open val createTime: Long = System.currentTimeMillis()
) {
/**
* 转换为JSON字符串
*/
abstract fun toJSON(): String
/**
* 获取API路径不包含基础URL
* 例如: "invitation-codes/generate"
*/
abstract fun path(): String
/**
* 获取HTTP方法默认为GET因为大多数API使用GET+查询参数
*/
open fun method(): HttpMethod = HttpMethod.Get
/**
* 自定义请求头
*/
open fun headers(): HeadersBuilder.() -> Unit = {
// 默认添加Content-Type
append(HttpHeaders.ContentType, ContentType.Application.Json.toString())
// 添加Accept头
append(HttpHeaders.Accept, "application/json")
}
/**
* 获取查询参数用于URL参数
* 例如: mapOf("token" to "abc123", "amount" to "1")
*/
open fun queryParameters(): Map<String, String> = emptyMap()
/**
* 获取请求体参数用于POST请求的JSON body
* 例如: mapOf("token" to "abc123", "amount" to 1)
*/
open fun bodyParameters(): Map<String, Any> = emptyMap()
/**
* 获取请求体内容类型默认为Application.Json
*/
open fun contentType(): ContentType = ContentType.Application.Json
/**
* 解析响应JSON字符串
* @param responseJson 响应JSON字符串
* @param httpStatusCode HTTP状态码
*/
abstract fun getResponse(responseJson: String, httpStatusCode: HttpStatusCode): ResponseResult<T, F>
/**
* 获取预期的成功响应类型名称用于日志和调试
*/
abstract fun expectedResponseType(): String
/**
* 获取预期的失败响应类型名称用于日志和调试
*/
abstract fun expectedFailureType(): String
/**
* 是否需要在失败时重试默认重试
*/
open fun shouldRetryOnFailure(): Boolean = true
}

View File

@ -0,0 +1,86 @@
package top.r3944realms.ltdmanager.blessingskin.request.invitecode
import io.ktor.http.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult
import top.r3944realms.ltdmanager.blessingskin.response.invitecode.InvitationCodeGenerationResponse
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import java.util.*
@Serializable
class GenerateInvitationCodeRequest(
@Transient
val token: String? = null,
@Transient
val amount: Int? = 1,
@Transient
override val createTime: Long = System.currentTimeMillis()
) : BlessingSkinRequest<InvitationCodeGenerationResponse, FailedBlessingSkinResponse.Default>() {
override fun toJSON(): String {
// 对于GET请求参数在URL中body可以为空
return "{}"
}
override fun path(): String {
return YamlConfigLoader.loadBlessingSkinServerConfig().invitationApi?.path ?: "api/invitation-codes/generate"
}
override fun method(): HttpMethod {
return HttpMethod.Post // 使用POST方法参数在查询JSON中
}
override fun queryParameters(): Map<String, String> {
val params = mutableMapOf<String, String>()
// 添加token参数如果提供
token?.let { params["token"] = it }
// 添加amount参数如果提供
amount?.let { params["amount"] = it.toString() }
return params
}
override fun headers(): HeadersBuilder.() -> Unit = {
// 调用父类的默认headers
super.headers().invoke(this)
// 可以添加额外的header
append("X-Request-ID", UUID.randomUUID().toString())
}
override fun getResponse(
responseJson: String,
httpStatusCode: HttpStatusCode
): ResponseResult<InvitationCodeGenerationResponse, FailedBlessingSkinResponse.Default> {
return try {
// 使用BlessingSkinResponse的伴生对象方法解析
val response = BlessingSkinResponse.decode(responseJson) as? InvitationCodeGenerationResponse
?: throw IllegalArgumentException("响应类型不匹配")
ResponseResult.Success(response)
} catch (e: Exception) {
ResponseResult.Failure(
FailedBlessingSkinResponse.Default(
failedResult = "解析响应失败: ${e.message}"
)
)
}
}
override fun expectedResponseType(): String {
return "invitation_code_generation"
}
override fun expectedFailureType(): String {
return "default_failure"
}
override fun shouldRetryOnFailure(): Boolean {
return false
}
}

View File

@ -0,0 +1,36 @@
package top.r3944realms.ltdmanager.blessingskin.response
import io.ktor.http.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import top.r3944realms.ltdmanager.blessingskin.response.invitecode.InvitationCodeGenerationResponse
@Serializable
abstract class BlessingSkinResponse (
@Transient
open val httpStatusCode: HttpStatusCode = HttpStatusCode.OK,
@Transient
open val createTime: Long = System.currentTimeMillis()
) {
companion object {
// 通用的反序列化方法
inline fun <reified T : BlessingSkinResponse> decode(jsonString: String): T {
return json.decodeFromString(jsonString)
}
val json: Json by lazy {
Json {
ignoreUnknownKeys = true
serializersModule = SerializersModule {
polymorphic(BlessingSkinResponse::class) {
subclass(FailedBlessingSkinResponse.Default::class, FailedBlessingSkinResponse.Default.serializer())
subclass(InvitationCodeGenerationResponse::class, InvitationCodeGenerationResponse.serializer())
}
}
}
}
}
}

View File

@ -0,0 +1,14 @@
package top.r3944realms.ltdmanager.blessingskin.response
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@Serializable
abstract class FailedBlessingSkinResponse: BlessingSkinResponse() {
abstract fun failedMessage(): String
@Serializable
class Default(@Transient val failedResult: String? = "未知错误") : FailedBlessingSkinResponse() {
override fun failedMessage(): String = failedResult!!
}
}

View File

@ -0,0 +1,38 @@
package top.r3944realms.ltdmanager.blessingskin.response
// 响应结果封装
sealed class ResponseResult<out T : BlessingSkinResponse, out F : FailedBlessingSkinResponse> {
data class Success<T : BlessingSkinResponse>(val response: T) : ResponseResult<T, Nothing>()
data class Failure<F : FailedBlessingSkinResponse>(val failure: F) : ResponseResult<Nothing, F>()
/**
* 检查是否成功
*/
fun isSuccess(): Boolean = this is Success
/**
* 获取成功响应如果存在
*/
fun getSuccessResponse(): T? = (this as? Success)?.response
/**
* 获取失败响应如果存在
*/
fun getFailureResponse(): F? = (this as? Failure)?.failure
/**
* 成功时执行操作
*/
inline fun onSuccess(action: (T) -> Unit): ResponseResult<T, F> {
if (this is Success) action(response)
return this
}
/**
* 失败时执行操作
*/
inline fun onFailure(action: (F) -> Unit): ResponseResult<T, F> {
if (this is Failure) action(failure)
return this
}
}

View File

@ -0,0 +1,21 @@
package top.r3944realms.ltdmanager.blessingskin.response.invitecode
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
@Serializable
data class InvitationCodeGenerationResponse(
val success: Boolean,
val message: String,
val data: List<InvitationCode>? = null
) : BlessingSkinResponse() {
@Serializable
data class InvitationCode(
val code: String,
@SerialName("generated_at")
val generatedAt: String,
@SerialName("expires_at")
val expiresAt: String
)
}

View File

@ -0,0 +1,76 @@
package top.r3944realms.ltdmanager.core.config
import top.r3944realms.ltdmanager.utils.CryptoUtil
import top.r3944realms.ltdmanager.utils.YamlUpdater
data class BlessingSkinServerConfig(
var url: String ?= null,
var invitationApi: BlessingSkinServerConfig.InvitationApiConfig?= null
) {
data class BlessingSkinServerConfig(
var url: String? = null,
var invitationApi: InvitationApiConfig? = null
) {
data class InvitationApiConfig(
var path: String? = null,
var encryptedToken: String? = null
) {
/**
* 获取解密后的 token如果未加密返回原值
*/
val decryptedToken: String?
get() {
if (encryptedToken == null) return null
if (!isEncrypted()) return encryptedToken
return try {
val cipherText = encryptedToken!!.substring(4, encryptedToken!!.length - 1)
CryptoUtil.decrypt(cipherText)
} catch (e: Exception) {
throw IllegalStateException("API token 解密失败", e)
}
}
/**
* 加密 token如果未加密并写回 YAML
*/
fun encryptToken() {
if (encryptedToken == null || isEncrypted()) return
try {
encryptedToken = "ENC(${CryptoUtil.encrypt(encryptedToken!!)})"
YamlUpdater.updateYaml(
YamlConfigLoader.configFilePath.toString(),
"blessing-skin-server.invitation-api.encrypted-token",
encryptedToken!!
)
} catch (e: Exception) {
throw IllegalStateException("API token 加密失败", e)
}
}
/**
* 检查是否已加密
*/
private fun isEncrypted(): Boolean {
return encryptedToken != null &&
encryptedToken!!.startsWith("ENC(") &&
encryptedToken!!.endsWith(")")
}
/**
* 获取完整 API URL
*/
fun getFullUrl(baseUrl: String?): String? {
if (baseUrl == null || path.isNullOrBlank()) return null
return baseUrl.trimEnd('/') + "/" + path!!.trimStart('/')
}
override fun toString(): String {
return "InvitationApiConfig(path=$path, token=***)"
}
}
override fun toString(): String {
return "BlessingSkinServerConfig(url=$url, invitationApi=$invitationApi)"
}
}
}

View File

@ -0,0 +1,58 @@
package top.r3944realms.ltdmanager.core.config
import top.r3944realms.ltdmanager.utils.CryptoUtil
import top.r3944realms.ltdmanager.utils.YamlUpdater
data class MailConfig(
var host: String? = null, // SMTP 主机
var port: Int? = 587, // 端口25/465/587
var mailAddress: String? = null, // 邮箱账号
var encryptedPassword: String? = null, // 加密后的密码或明文
var auth: Boolean? = true, // 是否需要认证
var tls: Boolean? = true, // 是否启用 STARTTLS
var protocol: String = "smtp" // 协议,默认 smtp
) {
val decryptedPassword: String?
get() {
if (encryptedPassword == null) return null
if (!isEncrypted()) return encryptedPassword
try {
val cipherText = encryptedPassword!!.substring(4, encryptedPassword!!.length - 1)
return CryptoUtil.decrypt(cipherText)
} catch (e: Exception) {
throw IllegalStateException("邮件密码解密失败", e)
}
}
/**
* 加密密码如果未加密并写回配置文件
*/
fun encryptPassword() {
if (encryptedPassword == null || isEncrypted()) {
return
}
try {
encryptedPassword = "ENC(${CryptoUtil.encrypt(encryptedPassword!!)})"
YamlUpdater.updateYaml(
YamlConfigLoader.configFilePath.toString(),
"mail.encrypted-password",
this.encryptedPassword!!
)
} catch (e: Exception) {
throw IllegalStateException("邮件密码加密失败", e)
}
}
/**
* 检查密码是否已加密
*/
private fun isEncrypted(): Boolean {
return encryptedPassword != null &&
encryptedPassword!!.startsWith("ENC(") &&
encryptedPassword!!.endsWith(")")
}
override fun toString(): String {
return "MailConfig(host=$host, port=$port, emailAddress=$mailAddress, password=***)"
}
}

View File

@ -6,6 +6,9 @@ import top.r3944realms.ltdmanager.utils.YamlUpdater
data class ToolConfig(
var rcon: RconConfig = RconConfig()
) {
fun encryptPassword() {
rcon.encryptPassword()
}
data class RconConfig(
var mcRconToolPath: String? = null,
var mcRconToolConfigPath: String? = null,
@ -30,12 +33,12 @@ data class ToolConfig(
/**
* 加密 rcon 密码如果未加密
*/
fun encryptPassword(configFilePath: String) {
fun encryptPassword() {
if (rconPassword == null || isEncrypted()) return
try {
rconPassword = "ENC(${CryptoUtil.encrypt(rconPassword!!)})"
YamlUpdater.updateYaml(
configFilePath,
YamlConfigLoader.configFilePath.toString(),
"tools.rcon.rcon-password",
rconPassword!!
)

View File

@ -21,9 +21,7 @@ object YamlConfigLoader {
// 初始化后加密(确保只执行一次)
runCatching {
_config.database.encryptPassword()
_config.websocket.encryptToken()
_config.http.encryptToken()
ensureConfigEncrypted(_config)
}.onFailure { e ->
println("初始化加密失败: ${e.message}")
e.printStackTrace()
@ -33,6 +31,9 @@ object YamlConfigLoader {
config?.database?.encryptPassword()
config?.websocket?.encryptToken()
config?.http?.encryptToken()
config?.mail?.encryptPassword()
config?.tools?.rcon?.encryptPassword()
config?.blessingSkinServer?.invitationApi?.encryptToken()
}
private fun loadConfig(): ConfigWrapper {
if (!Files.exists(configFilePath)) {
@ -73,6 +74,8 @@ object YamlConfigLoader {
fun loadHttpConfig(): HttpConfig = config.http
fun loadModeConfig(): ModeConfig = config.mode
fun loadToolConfig(): ToolConfig = config.tools
fun loadMailConfig(): MailConfig = config.mail
fun loadBlessingSkinServerConfig(): BlessingSkinServerConfig = config.blessingSkinServer
data class ConfigWrapper(
var database: DatabaseConfig = DatabaseConfig(),
var crypto: CryptoConfig = CryptoConfig(),
@ -80,6 +83,8 @@ object YamlConfigLoader {
var websocket: WebsocketConfig = WebsocketConfig(),
var http: HttpConfig = HttpConfig(),
var tools: ToolConfig = ToolConfig(),
var mail: MailConfig = MailConfig(),
var blessingSkinServer: BlessingSkinServerConfig = BlessingSkinServerConfig(),
)
}

View File

@ -0,0 +1,38 @@
package top.r3944realms.ltdmanager.core.mail
data class Mail(
val from: String? = null, // 发件人
val to: List<String>, // 收件人(至少一个)
val subject: String, // 邮件主题
val body: String, // 邮件正文
val isHtml: Boolean = false, // 是否 HTML
val cc: List<String> = emptyList(), // 抄送
val bcc: List<String> = emptyList() // 密送
) {
companion object {
fun simple(
to: String,
subject: String,
body: String,
isHtml: Boolean = false
): Mail = Mail(
to = listOf(to),
subject = subject,
body = body,
isHtml = isHtml
)
fun multiple(
to: List<String>,
subject: String,
body: String,
isHtml: Boolean = false
): Mail = Mail(
to = to,
subject = subject,
body = body,
isHtml = isHtml
)
}
}

View File

@ -0,0 +1,20 @@
package top.r3944realms.ltdmanager.core.mail
class MailBuilder {
var from: String? = null
val to = mutableListOf<String>()
var subject: String = ""
var body: String = ""
var isHtml: Boolean = false
val cc = mutableListOf<String>()
val bcc = mutableListOf<String>()
fun build() = Mail(from, to, subject, body, isHtml, cc, bcc)
}
fun mail(block: MailBuilder.() -> Unit): Mail {
val builder = MailBuilder()
builder.block()
return builder.build()
}

View File

@ -1,29 +1,31 @@
package top.r3944realms.ltdmanager
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import top.r3944realms.ltdmanager.module.GroupRequestHandlerModule
import top.r3944realms.ltdmanager.module.RconPlayerListModule
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.util.concurrent.atomic.AtomicBoolean
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import top.r3944realms.ltdmanager.module.*
fun main() = runBlocking {
// 标记程序是否运行
val isRunning = AtomicBoolean(true)
fun main() = GlobalManager.runBlockingMain {
val groupId:Long = 538751386
val selfQQId = 3327379836
// 创建模块实例
val groupModule = GroupRequestHandlerModule(
client = GlobalManager.napCatClient,
targetGroupId = 538751386
targetGroupId = groupId
)
val groupMsgPollingModule = GroupMessagePollingModule(
targetGroupId = groupId,
pollIntervalMillis = 5_000L,
msgHistoryCheck = 15
)
val toolConfig = YamlConfigLoader.loadToolConfig()
val rconModule = RconPlayerListModule(
pollIntervalMillis = 3_000L,
timeout = 2_000L,
groupMessagePollingModule = groupMsgPollingModule,
rconTimeOut = 2_000L,
cooldownMillis = 10_000L,
targetGroupId = 538751386,
selfId = 3327379836,
selfId = selfQQId,
selfNickName = "闲趣老土豆",
rconPath = toolConfig.rcon.mcRconToolPath.toString(),
rconConfigPath = toolConfig.rcon.mcRconToolConfigPath.toString(),
keywords = setOf(
//形容
"土豆", "马铃薯", "Potato", "potato", "POTATO",
@ -31,30 +33,39 @@ fun main() = runBlocking {
//正经
"列表","服务器状态", "TPS", "tps", "list", "List"
)
);
)
val mailConfig = YamlConfigLoader.loadMailConfig()
val mailModule = MailModule(
host = mailConfig.host.toString(),
authToken = mailConfig.decryptedPassword.toString(),
port = mailConfig.port!!,
senderEmailAddress = mailConfig.mailAddress!!,
)
val blessingSkinConfig = YamlConfigLoader.loadBlessingSkinServerConfig()
val invitationCodesModule = InvitationCodesModule(
groupMessagePollingModule = groupMsgPollingModule,
mailModule = mailModule,
apiToken = blessingSkinConfig.invitationApi?.decryptedToken!!,
selfId = selfQQId,
keywords = setOf(
"申请皮肤站注册邀请码",
"申请土豆服务器注册邀请码",
"申请LTD邀请码",
"Apply for an invitation code"
)
)
// 注册模块到全局模块管理器
GlobalManager.moduleManager.registerModule(groupModule)
GlobalManager.moduleManager.registerModule(groupMsgPollingModule)
GlobalManager.moduleManager.registerModule(rconModule)
GlobalManager.moduleManager.registerModule(mailModule)
GlobalManager.moduleManager.registerModule(invitationCodesModule)
// 加载模块
GlobalManager.moduleManager.loadModule(groupModule.name)
GlobalManager.moduleManager.loadModule(groupMsgPollingModule.name)
GlobalManager.moduleManager.loadModule(rconModule.name)
// 捕获 JVM 关闭信号,优雅退出
Runtime.getRuntime().addShutdownHook(Thread {
runBlocking {
LoggerUtil.logger.info("\n收到退出信号,正在停止所有模块...")
GlobalManager.moduleManager.stopAllModules() // 批量 stop
LoggerUtil.logger.info("模块卸载完成,程序退出。")
GlobalManager.shutdown()
}
isRunning.set(false)
})
// 持续挂起,保持主线程运行
while (isRunning.get()) {
delay(1000L)
}
GlobalManager.moduleManager.loadModule(mailModule.name)
GlobalManager.moduleManager.loadModule(invitationCodesModule.name)
}

View File

@ -1,6 +1,9 @@
package top.r3944realms.ltdmanager.module
import kotlinx.coroutines.CompletableDeferred
import top.r3944realms.ltdmanager.GlobalManager
import top.r3944realms.ltdmanager.utils.LoggerUtil
import kotlin.coroutines.cancellation.CancellationException
/**
* 模块抽象基类
@ -13,6 +16,10 @@ abstract class BaseModule {
*/
abstract val name: String
/**
* 停止信号
*/
private val stopSignal = CompletableDeferred<Unit>()
/**
* 模块是否加载
*/
@ -28,6 +35,7 @@ abstract class BaseModule {
if (!loaded) {
loaded = true
onLoad()
LoggerUtil.logger.info("模块加载: $name")
}
}
@ -35,10 +43,12 @@ abstract class BaseModule {
* 模块卸载
* 清理资源取消协程关闭监听器等
*/
open fun unload() {
open suspend fun unload() {
if (loaded) {
loaded = false
onUnload()
stopSignal.complete(Unit)
LoggerUtil.logger.info("模块卸载: $name")
}
}
@ -50,18 +60,27 @@ abstract class BaseModule {
/**
* 模块卸载时的实际逻辑由子类实现
*/
protected abstract fun onUnload()
protected abstract suspend fun onUnload()
/**
* 可选的停止方法模块内部协程等后台任务在这里被取消
*/
open suspend fun stop() {
if (!loaded) return
LoggerUtil.syncInfo("[$name] 收到停止命令")
unload() // 默认实现直接卸载
try {
stopSignal.await()
} catch (_: CancellationException) {}
LoggerUtil.syncInfo("[$name] 模块已安全停止")
}
/**
* 提供访问全局 NapCatClient 的快捷方式
*/
protected val napCatClient get() = GlobalManager.napCatClient
/**
* 提供访问全局 blessingSkinClient 的快捷方式
*/
protected val blessingSkinClient get() = GlobalManager.blessingSkinClient
/**
* 获取数据库连接
* 使用 try-with-resources 时会自动关闭

View File

@ -0,0 +1,58 @@
package top.r3944realms.ltdmanager.module
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import top.r3944realms.ltdmanager.napcat.data.ID
import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent
import top.r3944realms.ltdmanager.napcat.event.message.GetGroupMsgHistoryEvent
import top.r3944realms.ltdmanager.napcat.request.message.GetGroupMsgHistoryRequest
import top.r3944realms.ltdmanager.utils.LoggerUtil
class GroupMessagePollingModule(
val targetGroupId: Long,
private val pollIntervalMillis: Long = 5_000L,
private val msgHistoryCheck: Int = 15
) : BaseModule() {
override val name: String = "MessagePollingModule"
private var scope: CoroutineScope? = null
// 用 Flow 存消息,其他模块可以订阅
private val _messagesFlow = MutableSharedFlow<List<GetFriendMsgHistoryEvent.SpecificMsg>>(
replay = 1, // 保留最近一份消息
extraBufferCapacity = 1
)
val messagesFlow: SharedFlow<List<GetFriendMsgHistoryEvent.SpecificMsg>> = _messagesFlow.asSharedFlow()
override fun onLoad() {
LoggerUtil.logger.info("[$name] 启动消息轮询 (群: $targetGroupId)")
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope!!.launch {
while (isActive && loaded) {
try {
val event = napCatClient.send(
GetGroupMsgHistoryRequest(
count = msgHistoryCheck,
groupId = ID.long(targetGroupId)
)
) as? GetGroupMsgHistoryEvent
val messages = event?.data?.messages ?: emptyList()
LoggerUtil.logger.debug("[$name] 拉取到 ${messages.size} 条消息")
_messagesFlow.emit(messages)
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 拉取消息失败", e)
}
delay(pollIntervalMillis)
}
}
}
override suspend fun onUnload() {
LoggerUtil.logger.info("[$name] 模块卸载,停止轮询")
scope?.cancel() // 取消协程
}
}

View File

@ -18,66 +18,53 @@ class GroupRequestHandlerModule(
override val name: String = "GroupRequestHandlerModule"
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope? = null
private val stopSignal = CompletableDeferred<Unit>()
override fun onLoad() {
LoggerUtil.logger.info("模块[$name]已装载,目标群组: $targetGroupId,轮询间隔: ${pollIntervalMillis}ms")
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// 启动轮询协程
scope.launch {
scope!!.launch {
LoggerUtil.logger.info("[$name] 轮询协程启动")
try {
while (isActive) {
try {
LoggerUtil.logger.debug("[$name] 开始轮询群组请求...")
while (isActive && loaded) {
try {
LoggerUtil.logger.debug("[$name] 开始轮询群组请求...")
// 获取正常请求
LoggerUtil.logger.debug("[$name] 获取正常群系统消息...")
val systemEvent: GetGroupSystemMsgEvent =
client.send(GetGroupSystemMsgRequest())
LoggerUtil.logger.debug("[$name] 获取到 ${systemEvent.data.invitedRequest.size} 个邀请请求和 ${systemEvent.data.joinRequests.size} 个加群请求")
// 获取正常请求
LoggerUtil.logger.debug("[$name] 获取正常群系统消息...")
val systemEvent: GetGroupSystemMsgEvent =
client.send(GetGroupSystemMsgRequest())
LoggerUtil.logger.debug("[$name] 获取到 ${systemEvent.data.invitedRequest.size} 个邀请请求和 ${systemEvent.data.joinRequests.size} 个加群请求")
handleEvent(systemEvent)
handleEvent(systemEvent)
// 获取被过滤的请求
LoggerUtil.logger.debug("[$name] 获取被过滤的群系统消息...")
val ignoredEvent: GetGroupIgnoredNotifiesEvent =
client.send(GetGroupIgnoredNotifiesRequest())
LoggerUtil.logger.debug("[$name] 获取到 ${ignoredEvent.data.invitedRequest.size} 个被过滤的邀请请求和 ${ignoredEvent.data.joinRequests.size} 个被过滤的加群请求")
// 获取被过滤的请求
LoggerUtil.logger.debug("[$name] 获取被过滤的群系统消息...")
val ignoredEvent: GetGroupIgnoredNotifiesEvent =
client.send(GetGroupIgnoredNotifiesRequest())
LoggerUtil.logger.debug("[$name] 获取到 ${ignoredEvent.data.invitedRequest.size} 个被过滤的邀请请求和 ${ignoredEvent.data.joinRequests.size} 个被过滤的加群请求")
handleEvent(ignoredEvent)
handleEvent(ignoredEvent)
LoggerUtil.logger.debug("[$name] 本轮轮询完成,等待 ${pollIntervalMillis}ms 后继续")
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 轮询执行异常", e)
}
delay(pollIntervalMillis)
LoggerUtil.logger.debug("[$name] 本轮轮询完成,等待 ${pollIntervalMillis}ms 后继续")
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 轮询执行异常", e)
}
} catch (e: CancellationException) {
LoggerUtil.logger.info("[$name] 轮询协程收到取消信号")
} finally {
LoggerUtil.logger.info("[$name] 轮询协程退出,完成 stopSignal")
stopSignal.complete(Unit)
delay(pollIntervalMillis)
}
}
}
override suspend fun stop() {
LoggerUtil.logger.info("[$name] 收到停止命令,开始关闭协程...")
scope.cancel()
LoggerUtil.logger.info("[$name] 等待协程退出...")
stopSignal.await()
LoggerUtil.logger.info("[$name] 协程已退出,卸载模块资源")
onUnload()
}
public override fun onUnload() {
public override suspend fun onUnload() {
LoggerUtil.logger.info("[$name] 已卸载")
scope?.cancel()
}
private suspend fun handleEvent(event: Any) {
if (!loaded) return
LoggerUtil.logger.debug("[$name] 处理群请求事件: ${event.javaClass.simpleName}")
val provider: GroupRequestProvider? = when (event) {
@ -115,7 +102,7 @@ class GroupRequestHandlerModule(
}
2, 3 -> {
val reason = if (status == 2) "审核未通过" else "待审核"
val reason = if (status == 3) "审核未通过或请使用填写白名单所用QQ号加群" else "白名单待审核,请通过后再加"
LoggerUtil.logger.info("[$name] 拒绝加群: groupId=${request.groupId}, invitorUin=${request.invitorUin}, status=$status, reason=$reason, requestId=${request.requestId}")
val request1 = SetGroupAddRequestRequest(
false,

View File

@ -0,0 +1,19 @@
package top.r3944realms.ltdmanager.module
/**
* 自定义异常类
*/
sealed class InvitationCodeException(message: String) : Exception(message) {
// 使用 public 构造函数
class QuantityMismatchException(
val expectedCount: Int,
val actualCount: Int
) : InvitationCodeException("数量不一致: 期望 $expectedCount, 实际 $actualCount")
// 添加其他类型的异常
class DatabaseException(message: String) : InvitationCodeException(message)
class NetworkException(message: String) : InvitationCodeException(message)
class ApiFailureException(message: String) : InvitationCodeException(message)
}

View File

@ -0,0 +1,648 @@
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.blessingskin.request.invitecode.GenerateInvitationCodeRequest
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.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
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.io.File
import java.sql.Timestamp
import java.util.*
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
/*
1. 订阅消息模块 (触发关键词, 注意过滤自己的消息避免重复触发) [Done]
2. 根据QQ号去查询机器人数据库中的视图表的id (此操作耗时应设置针对指定用户的持久化冷却)
3. id存在 [错误处理: id不存在提醒用户为无法查询到你的id请联系管理员检查状态]
i. effective is_used 均为 1
则回复提醒你已经使用了你的邀请码切勿重复发送
ii. effective 1 is_used 0
则查询token_id对应的token记录然后构造发送邮件
提醒用户邮件已发送
iii. effective 0
则先通过API创建Token 获取来的响应 [错误处理: 当获取的json消息解析中success为false则回复用户message中的错误信息]
用Token去邀请码数据库中查询token_id,将其记录在机器人数据库对应白名单id映射token_id表中 [存在则更新不存在则插入],
然后按ii.执行
*/
/*
api格式 https://skins.r3944realms.top/api/invitation-codes/generate?token=XXXX&amount=1
成功消息
{
"success": true,
"message": "邀请码生成成功",
"data": [
{
"code": "XXXXXXX",
"generated_at": "2025-08-29T09:36:36.910623Z",
"expires_at": "2025-09-05T09:36:36.910506Z"
}
]
}
失败消息
{
"success": false,
"message": "无效的 API Token"
}
*/
class InvitationCodesModule(
private val groupMessagePollingModule: GroupMessagePollingModule,
private val mailModule: MailModule,
private val apiToken: String,
private val selfId: Long,
private val cooldownMillis: Long = 120_000,
private val keywords: Set<String> = setOf("申请邀请码")
) : BaseModule(), PersistentState<InvitationCodesModule.LastTriggerMapState> {
override val name: String = "InvitationCodesModule"
private var scope: CoroutineScope? = null
// 持久化文件(带锁 + 备份)
private val stateFile = File("invitation_codes_quarry_state.json")
private val stateBackupFile = File("invitation_codes_quarry_state.json.bak")
private val fileLock = ReentrantLock()
private var lastTriggerMapState = loadState()
override fun getStateFile(): File = stateFile
override fun getState(): LastTriggerMapState = lastTriggerMapState
override fun onLoad() {
LoggerUtil.logger.info("[$name] 模块已装载,目标群组: ${groupMessagePollingModule.targetGroupId}")
LoggerUtil.logger.info("[$name] 上次触发状态: lastTriggerMap=${lastTriggerMapState.map}")
LoggerUtil.logger.info("[$name] 关键词列表: $keywords")
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope!!.launch {
LoggerUtil.logger.info("[$name] 轮询协程启动")
groupMessagePollingModule.messagesFlow.collect { messages ->
if (loaded) handleMessages(messages)
}
}
// 定时落盘(防止异常退出丢状态)
scope!!.launch {
while (isActive) {
delay(60_000) // 每分钟保存一次
saveState(lastTriggerMapState)
}
}
}
override suspend fun onUnload() {
LoggerUtil.logger.info("[$name] 模块卸载,保存状态...")
saveState(lastTriggerMapState)
LoggerUtil.logger.info("[$name] 模块卸载,取消协程...")
scope?.cancel()
LoggerUtil.logger.info("[$name] 模块已卸载完成")
}
// =========================
// 消息处理主流程
// =========================
private suspend fun handleMessages(messages: List<GetFriendMsgHistoryEvent.SpecificMsg>) {
val triggerMsgs = filterTriggerMessages(messages)
if (triggerMsgs.isEmpty()) return
try {
val hadValidCodeButNotUsed = mutableListOf<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>()
val needNewCode = mutableListOf<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>()
getIdAndSelectSituation(triggerMsgs, hadValidCodeButNotUsed, needNewCode)
createAndSearchInvitationCodeIdsThenUpdateDate(needNewCode)
hadVaildCodeButNotUseListHandler(hadValidCodeButNotUsed + needNewCode)
} catch (e: Exception) {
sendFailedMessage(napCatClient, text = "系统错误,请联系管理员: $e")
} finally {
saveState(lastTriggerMapState)
}
}
/** 过滤出符合条件的触发消息 */
private 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
}
}
.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>,
hadVaildCodeButNotUseList : MutableList<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>,
needNewCodeList: MutableList<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>) {
if (msgs.isEmpty()) return
val qqIds = msgs.map { it.userId }
val placeholders = java.lang.String.join(",", Collections.nCopies(qqIds.size, "?"))
// 修正SQL语句的表名引用
val sql = """
SELECT q.player_id, q.effective, q.is_used, q.qq
FROM ltd_manager_bot.qualified_user_info q
WHERE q.qq IN ($placeholders)
""".trimIndent()
try {
getConnection().use { conn ->
conn.prepareStatement(sql).use { pstmt ->
// 设置所有参数
for (i in qqIds.indices) {
pstmt.setLong(i + 1, qqIds[i])
}
pstmt.executeQuery().use { rs ->
// 创建结果映射表
val resultMap = mutableMapOf<Long, Triple<Long?, Boolean?, Boolean?>>()
while (rs.next()) {
val qq = rs.getLong("qq")
val playerId = rs.getLong("player_id")
// 处理可能的null值
val playerIdValue = if (rs.wasNull()) null else playerId
val effective = rs.getBoolean("effective")
val isUsed = rs.getBoolean("is_used")
resultMap[qq] = Triple(playerIdValue, effective, isUsed)
}
// 分类处理每个消息
for (msg in msgs) {
val result = resultMap[msg.userId]
when {
result == null -> {
// 数据库中没有记录, 属于是异常
LoggerUtil.logger.error("[$name] 无法查询该QQ号为:${msg.userId}的白名单ID可能该用户非白名单成员")
sendFailedMessage(napCatClient, msg.userId, msg.realId, msg.time, "无法查询到你的白名单应用id请联系管理员检查状态对应QQ号${msg.userId}")
}
result.first != null && result.second == true && result.third == true -> {
// 有player_id且已使用
LoggerUtil.logger.info("[$name] 该QQ号为:${msg.userId}的白名单ID是${result.first},已使用对应激活码")
sendMessage(napCatClient, msg.userId, msg.realId, msg.time, "你已经使用了你的邀请码,切勿重复发送")
}
result.first != null && result.second == true && result.third == false -> {
// 有player_id、有效且未使用
LoggerUtil.logger.info("[$name] 该QQ号为:${msg.userId}的白名单ID是${result.first},已有对应激活码但未使用")
hadVaildCodeButNotUseList.add(result.first!! to msg)
}
result.first != null && result.second == false -> {
// 没有player_id但有效需要新code或处理
needNewCodeList.add(result.first!! to msg)
}
else -> {
//其它情况,异常,不应该出现
sendFailedMessage(napCatClient, msg.userId, msg.realId, msg.time, "非法状态,请联系管理员:$result")
}
}
}
}
}
}
} catch (e: Exception) {
// 更好的错误处理
LoggerUtil.logger.error("[$name] 批量查询用户资格信息失败: ${e.message}", e)
sendFailedMessage(napCatClient, text = "批量查询用户资格信息失败,请联系管理员: ${e.message}")
}
}
private suspend fun hadVaildCodeButNotUseListHandler(list: List<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>) {
if (list.isEmpty()) return
val whiteListIds = list.map { it.first }
val placeholders = java.lang.String.join(",", Collections.nCopies(whiteListIds.size, "?"))
val sql = """
SELECT q.player_id, q.player_name, q.token, q.expires_at
FROM ltd_manager_bot.qualified_user_info q
WHERE q.player_id IN ($placeholders)
""".trimIndent()
try {
getConnection().use { conn ->
conn.prepareStatement(sql).use { pstmt ->
for (i in whiteListIds.indices) {
pstmt.setLong(i + 1, whiteListIds[i])
}
pstmt.executeQuery().use { rs ->
val resultMap = mutableMapOf<Long, Triple<String?, String?, Timestamp?>>()
while (rs.next()) {
val playerId = rs.getLong("player_id")
val playerName = rs.getString("player_name")
val token = rs.getString("token")
val tokenValue = if (rs.wasNull()) null else token
val expiresAt = rs.getTimestamp("expires_at")
val expiresAtValue = if (rs.wasNull()) null else expiresAt
resultMap[playerId] = Triple(playerName, tokenValue, expiresAtValue)
}
// 直接遍历原始列表,不需要额外的映射
for ((playerId, specificMsg) in list) {
val mailRequestArgument = resultMap[playerId]
if (mailRequestArgument?.second != null && mailRequestArgument.third != null) {
mailModule.enqueue(mail {
to += specificMsg.userId.toString() + "@qq.com" // 直接使用 specificMsg
// 根据需要配置邮件内容
subject = "LTD邀请码邮件"
isHtml = true
body = HtmlTemplateUtil.tokenMailHtmlTemplate(
mailRequestArgument.first!!,
mailRequestArgument.second!!,
mailRequestArgument.third!!,
7,2025
)
})
sendMessage(napCatClient, specificMsg.userId, specificMsg.realId, specificMsg.time,"已发送邮件注意查收QQ邮箱")
} else if (mailRequestArgument?.second != null) {
mailModule.enqueue(mail {
to += specificMsg.userId.toString() + "@qq.com" // 直接使用 specificMsg
// 根据需要配置邮件内容
subject = "LTD邀请码邮件"
isHtml = true
body = HtmlTemplateUtil.tokenMailHtmlTemplate(
mailRequestArgument.first!!,
mailRequestArgument.second!!,
timeYear = 2025
)
})
sendMessage(napCatClient, specificMsg.userId, specificMsg.realId, specificMsg.time,"已发送邮件注意查收QQ邮箱")
} else {
LoggerUtil.logger.error("[$name] 异常情况code为 空值")
sendFailedMessage(napCatClient, specificMsg.userId, specificMsg.realId, specificMsg.time, "系统内部异常,请联系管理员")
}
}
}
}
}
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 查询已获取邀请码但未使用或未过期用户Code信息失败: ${e.message}", e)
sendFailedMessage(napCatClient, text = "查询已获取邀请码但未使用或未过期用户Code信息失败: ${e.message}")
}
}
private suspend fun sendMessage(
client: NapCatClient,
qq: Long,
realId: Long,
time: Long,
text: String = "正常消息"
) {
LoggerUtil.logger.info("[$name] 发送消息: realId=$realId, text=$text")
val request = SendGroupMsgRequest(
MessageElement.reply(ID.long(realId), text),
ID.long(groupMessagePollingModule.targetGroupId)
)
client.sendUnit(request)
LoggerUtil.logger.info("[$name] 已发送 消息")
// 更新触发的最大 realId
lastTriggerMapState = lastTriggerMapState.updateLastTrigger(qq, realId, time)
}
private suspend fun sendFailedMessage(
client: NapCatClient,
qq: Long? = null,
realId: Long? = null,
time: Long? = null,
text: String = "失败消息"
) {
LoggerUtil.logger.info("[$name] 发送失败消息: realId=$realId, text=$text")
if (realId != null && qq != null && time != null) {
val request = SendGroupMsgRequest(
MessageElement.reply(ID.long(realId), text),
ID.long(groupMessagePollingModule.targetGroupId)
)
client.sendUnit(request)
LoggerUtil.logger.info("[$name] 已发送 失败消息")
// 更新触发的最大 realId
lastTriggerMapState = lastTriggerMapState.updateLastTrigger(qq, realId, time)
} else {
val request = SendGroupMsgRequest(
listOf(MessageElement.text(text)),
ID.long(groupMessagePollingModule.targetGroupId)
)
client.sendUnit(request)
LoggerUtil.logger.info("[$name] 已发送 失败消息[无指定对象]")
}
}
// =========================
// 冷却逻辑
// =========================
private suspend fun 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),
ID.long(groupMessagePollingModule.targetGroupId)
)
client.sendUnit(request)
lastTriggerMapState = lastTriggerMapState.updateLastTrigger(qq, realId, -1)
}
private suspend fun createAndSearchInvitationCodeIdsThenUpdateDate(
needNewTokenIdAndMsgPairs: List<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>,
) {
if (needNewTokenIdAndMsgPairs.isEmpty()) return
try {
// 1. 创建邀请码
val invitationCodes = createInvitationCodes(needNewTokenIdAndMsgPairs.size)
// 2. 验证数量匹配
validateCodeCountMatch(invitationCodes, needNewTokenIdAndMsgPairs)
// 3. 获取邀请码ID
val codeToIdMap = getInvitationCodeIds(invitationCodes!!.map { it.code })
// 4. 更新或插入关联关系
updateInvitationCodeAscription(needNewTokenIdAndMsgPairs.map { it.first }, codeToIdMap.values.toList())
} catch (e: Exception) {
handleCreationError(e)
}
}
/**
* 1. 创建邀请码
*/
private suspend fun createInvitationCodes(amount: Int): List<InvitationCodeGenerationResponse.InvitationCode>? {
return try {
val response = blessingSkinClient.submitRequest(
GenerateInvitationCodeRequest(amount = amount, token = apiToken)
)
when (response) {
is ResponseResult.Success -> {
if (response.response.success) {
response.response.data
} else {
LoggerUtil.logger.warn("[$name] API返回失败: ${response.response.message}")
null
}
}
is ResponseResult.Failure -> {
LoggerUtil.logger.warn("[$name] 创建邀请码失败: ${response.failure.failedResult}")
null
}
}
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 创建邀请码异常", e)
null
}
}
/**
* 2. 验证数量匹配
*/
private fun validateCodeCountMatch(
invitationCodes: List<InvitationCodeGenerationResponse.InvitationCode>?,
needNewTokenIdAndMsgPairs: List<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>
) {
if (invitationCodes == null) {
throw InvitationCodeException.ApiFailureException("获取邀请码请求失败")
}
if (invitationCodes.size != needNewTokenIdAndMsgPairs.size) {
throw InvitationCodeException.QuantityMismatchException(
expectedCount = needNewTokenIdAndMsgPairs.size,
actualCount = invitationCodes.size
)
}
}
/**
* 3. 获取邀请码ID
*/
private fun getInvitationCodeIds(invitationCodes: List<String>): Map<String, Long> {
if (invitationCodes.isEmpty()) return emptyMap()
val placeholders = invitationCodes.joinToString(",") { "?" }
val sql = """
SELECT i.id, i.code
FROM blessingskin.invitation_codes i
WHERE i.code IN ($placeholders)
""".trimIndent()
return getConnection().use { conn ->
conn.prepareStatement(sql).use { pstmt ->
// 设置参数
invitationCodes.forEachIndexed { index, code ->
pstmt.setString(index + 1, code)
}
val resultMap = mutableMapOf<String, Long>()
pstmt.executeQuery().use { rs ->
while (rs.next()) {
val id = rs.getLong("id")
val code = rs.getString("code")
resultMap[code] = id
}
}
resultMap
}
}
}
/**
* 4. 更新或插入关联关系
*/
private fun updateInvitationCodeAscription(playerIds: List<Long>, codeIds: List<Long>) {
if (playerIds.size != codeIds.size) {
throw IllegalStateException("playerIds和codeIds数量不匹配: ${playerIds.size} vs ${codeIds.size}")
}
if (playerIds.isEmpty()) return
val placeholders = playerIds.joinToString(",") { "(?, ?)" }
val sql = """
INSERT INTO ltd_manager_bot.invitation_code_ascription (id, token_id)
VALUES $placeholders
ON DUPLICATE KEY UPDATE token_id = VALUES(token_id)
""".trimIndent()
getConnection().use { conn ->
conn.prepareStatement(sql).use { pstmt ->
var paramIndex = 1
for (i in playerIds.indices) {
pstmt.setLong(paramIndex++, playerIds[i])
pstmt.setLong(paramIndex++, codeIds[i])
}
val affectedRows = pstmt.executeUpdate()
LoggerUtil.logger.debug("[$name] 更新了 $affectedRows 条关联记录")
}
}
}
/**
* 5. 错误处理
*/
private suspend fun handleCreationError(e: Exception) {
when (e) {
is InvitationCodeException -> {
LoggerUtil.logger.error("[$name] ${e.message}")
if (e is InvitationCodeException.QuantityMismatchException) {
// 数量不匹配的特殊处理
handleQuantityMismatch(e.expectedCount, e.actualCount)
} else {
sendFailedMessage(napCatClient, text = "邀请码创建失败,请联系管理员")
}
}
else -> {
LoggerUtil.logger.error("[$name] 捕获异常", e)
sendFailedMessage(napCatClient, text = "系统内部问题,请联系管理员")
}
}
}
/**
* 数量不匹配的特殊处理
*/
private suspend fun handleQuantityMismatch(expectedCount: Int, actualCount: Int) {
LoggerUtil.logger.error(
"[$name] 数量不一致BUG期望: $expectedCount, 实际: $actualCount"
)
sendFailedMessage(napCatClient, text = "系统内部BUG请联系管理员")
// TODO: 清理已创建的邀请码
cleanupCreatedInvitationCodes(actualCount)
}
/**
* 清理已创建的邀请码TODO实现
*/
private fun cleanupCreatedInvitationCodes(createdCount: Int) {
// 实现清理逻辑,删除多余的邀请码
LoggerUtil.logger.warn("[$name] 需要清理 $createdCount 个邀请码")
}
// =========================
// 状态持久化
// =========================
@Serializable
data class LastTriggerMapState(
val map: Map<Long, TriggerDetail> = emptyMap()
) {
fun getLastTriggerTime(qq: Long): Long = map[qq]?.time ?: -1
fun getLastTriggerRealId(qq: Long): Long = map[qq]?.realId ?: -1
fun updateLastTrigger(qq: Long, realId: Long, time: Long = -1): LastTriggerMapState {
val old = map[qq]
val newTime = if (time != -1L) time else old?.time ?: -1
val newMap = map.toMutableMap().apply {
put(qq, TriggerDetail(realId, newTime))
}
return copy(map = newMap)
}
fun updateLastCooldownRealId(qq: Long, realId: Long): LastTriggerMapState {
val old = map[qq]
val newMap = map.toMutableMap().apply {
put(qq, TriggerDetail(
realId = old?.realId ?: -1,
time = old?.time ?: -1,
lastCooldownRealId = realId
))
}
return copy(map = newMap)
}
}
@Serializable
data class TriggerDetail(val realId : Long, val time: Long, val lastCooldownRealId: Long = -1L )
override fun loadState(): LastTriggerMapState {
return try {
if (!stateFile.exists()) {
LoggerUtil.logger.info("[$name] 状态文件不存在,使用默认值")
return LastTriggerMapState()
}
val content = stateFile.readText()
val state = Json.decodeFromString<LastTriggerMapState>(content)
LoggerUtil.logger.info("[$name] 成功加载状态: ${state.map}, 文件路径=${stateFile.absolutePath}")
state
} catch (e: Exception) {
LoggerUtil.logger.warn("[$name] 读取状态失败,尝试从备份恢复", e)
try {
if (stateBackupFile.exists()) {
val backup = stateBackupFile.readText()
val state = Json.decodeFromString<LastTriggerMapState>(backup)
LoggerUtil.logger.info("[$name] 成功从备份恢复状态: ${state.map}")
state
} else {
LastTriggerMapState()
}
} catch (e2: Exception) {
LoggerUtil.logger.error("[$name] 备份也损坏,使用默认值", e2)
LastTriggerMapState()
}
}
}
override fun saveState(state: LastTriggerMapState) {
fileLock.withLock {
try {
val json = Json.encodeToString(state)
// 先写备份
if (stateFile.exists()) stateFile.copyTo(stateBackupFile, overwrite = true)
// 写入新文件
stateFile.writeText(json)
LoggerUtil.logger.info("[$name] 已保存状态: ${state.map}, 文件路径=${stateFile.absolutePath}")
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 保存状态失败", e)
}
}
}
}

View File

@ -0,0 +1,145 @@
package top.r3944realms.ltdmanager.module
import jakarta.mail.*
import jakarta.mail.internet.InternetAddress
import jakarta.mail.internet.MimeMessage
import top.r3944realms.ltdmanager.core.mail.Mail
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.util.*
import java.util.concurrent.LinkedBlockingQueue
import kotlin.concurrent.thread
class MailModule(
private val protocol: String = "SMTP",
private val host: String,
private val port: Int,
private val senderEmailAddress: String,
private val authToken: String,
private val enableAuth: Boolean = true,
private val enableTLS: Boolean = true,
private val intervalMillis: Long = 2000L // 每封邮件之间的间隔(默认 2s
) : BaseModule() {
override val name: String = "MailModule"
private lateinit var session: Session
private val queue = LinkedBlockingQueue<Mail>() // 邮件队列
private var workerThread: Thread? = null
@Volatile private var running = false
override fun onLoad() {
LoggerUtil.logger.info("[$name] 模块加载中,初始化邮件会话...")
/*
163 邮箱以及大多数 SMTP 服务商区别是
465 👉 隐式 SSL必须启用 mail.smtp.ssl.enable=true
587 👉 明文 + STARTTLS必须启用 mail.smtp.starttls.enable=true
而注释中 onLoad() 写死了
put("mail.smtp.starttls.enable", enableTLS)
所以当用 465 端口时服务端要求立即握手 SSL但程序还在用明文 STARTTLS直接就被 EOF 掉了
* */
// val props = Properties().apply {
// put("mail.transport.protocol", protocol)
// put("mail.smtp.host", host)
// put("mail.smtp.port", port)
// put("mail.smtp.auth", enableAuth)
// put("mail.smtp.starttls.enable", enableTLS)
// }
val props = Properties().apply {
put("mail.transport.protocol", protocol)
put("mail.smtp.host", host)
put("mail.smtp.port", port)
put("mail.smtp.auth", enableAuth)
when (port) {
465 -> {
// 隐式 SSL
put("mail.smtp.ssl.enable", "true")
put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory")
put("mail.smtp.socketFactory.port", port.toString())
}
587 -> {
// STARTTLS
if (enableTLS) {
put("mail.smtp.starttls.enable", "true")
put("mail.smtp.starttls.required", "true")
}
}
else -> {
// 普通 25 端口或其他情况
if (enableTLS) {
put("mail.smtp.starttls.enable", "true")
}
}
}
}
session = Session.getInstance(props, object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication {
return PasswordAuthentication(senderEmailAddress, authToken)
}
})
running = true
workerThread = thread(start = true, name = "MailSender-Worker") {
LoggerUtil.logger.info("[$name] 邮件发送线程启动")
while (running && loaded) {
try {
val mail = queue.take() // 阻塞等待新任务
LoggerUtil.logger.info("[$name] 开始发送邮件 -> 收件人: ${mail.to.joinToString(",")}")
sendInternal(mail)
LoggerUtil.logger.info("[$name] 邮件发送完成 -> ${mail.to.joinToString(",")}")
Thread.sleep(intervalMillis) // 限流
} catch (e: InterruptedException) {
LoggerUtil.logger.info("[$name] 邮件发送线程被中断,准备退出")
break
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 邮件发送出现异常", e)
}
}
}
}
override suspend fun onUnload() {
LoggerUtil.logger.info("[$name] 模块卸载,停止邮件发送线程")
running = false
workerThread?.interrupt()
workerThread = null
}
/**
* 加入发送队列
*/
fun enqueue(mail: Mail) {
if (!loaded) throw IllegalStateException("MailModule 未加载,不能发送邮件")
LoggerUtil.logger.info("[$name] 邮件加入队列 -> 收件人: ${mail.to.joinToString(",")}, 主题: ${mail.subject}")
queue.put(mail)
}
/**
* 真正的发送逻辑内部调用
*/
private fun sendInternal(mail: Mail) {
val message = MimeMessage(session).apply {
setFrom(InternetAddress(senderEmailAddress,mail.from ?: senderEmailAddress, "UTF-8"))
setRecipients(Message.RecipientType.TO, mail.to.joinToString(","))
if (mail.cc.isNotEmpty()) {
setRecipients(Message.RecipientType.CC, mail.cc.joinToString(","))
}
if (mail.bcc.isNotEmpty()) {
setRecipients(Message.RecipientType.BCC, mail.bcc.joinToString(","))
}
subject = mail.subject
setContent(
mail.body,
if (mail.isHtml) "text/html;charset=UTF-8" else "text/plain;charset=UTF-8"
)
}
Transport.send(message)
}
}

View File

@ -33,7 +33,6 @@ class ModuleManager {
}
try {
module.load()
LoggerUtil.logger.info("模块加载: $name")
} catch (e: Exception) {
LoggerUtil.logger.error("加载模块 $name 失败", e)
}
@ -42,7 +41,7 @@ class ModuleManager {
/**
* 卸载指定模块
*/
fun unloadModule(name: String) {
suspend fun unloadModule(name: String) {
val module = modules[name]
if (module == null) {
LoggerUtil.logger.warn("尝试卸载不存在的模块: $name")
@ -54,7 +53,6 @@ class ModuleManager {
}
try {
module.unload()
LoggerUtil.logger.info("模块卸载: $name")
} catch (e: Exception) {
LoggerUtil.logger.warn("卸载模块 $name 失败", e)
}
@ -63,12 +61,12 @@ class ModuleManager {
/**
* 卸载所有模块
*/
fun unloadAll() {
suspend fun unloadAll() {
modules.values.forEach { module ->
try {
if (module.loaded) {
module.unload()
LoggerUtil.logger.info("模块卸载: ${module.name}")
}
} catch (e: Exception) {
LoggerUtil.logger.warn("卸载模块 ${module.name} 失败", e)
@ -97,7 +95,7 @@ class ModuleManager {
/**
* 扩展方法批量卸载模块
*/
fun ModuleManager.unloadModules(vararg names: String) {
suspend fun ModuleManager.unloadModules(vararg names: String) {
names.forEach { unloadModule(it) }
}
/**

View File

@ -0,0 +1,10 @@
package top.r3944realms.ltdmanager.module
import java.io.File
interface PersistentState<T> {
fun getStateFile(): File
fun getState(): T
fun saveState(state: T)
fun loadState(): T
}

View File

@ -4,13 +4,12 @@ import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import top.r3944realms.ltdmanager.module.RconPlayerListModule.LastTriggerState
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.GetGroupMsgHistoryEvent
import top.r3944realms.ltdmanager.napcat.request.message.GetGroupMsgHistoryRequest
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.CmdUtil
@ -19,173 +18,161 @@ import java.io.File
import java.util.concurrent.TimeoutException
class RconPlayerListModule(
private val pollIntervalMillis: Long = 30_000L,
private val timeout: Long = 2_000L,
private val cooldownMillis: Long = 30_000L, // 默认 30 秒
private val groupMessagePollingModule: GroupMessagePollingModule,
private val rconTimeOut: Long = 2_000L,
private val cooldownMillis: Long = 30_000L,
private var lastSuccessTime: Long = 0L,
private var msgHistoryCheck: Int = 5,
private val targetGroupId: Long,
private val selfId: Long,
private val selfNickName: String,
private val rconPath: String,
private val rconConfigPath: String,
private val keywords: Set<String> = setOf("查看玩家列表", "玩家列表", "在线玩家")
) : BaseModule() {
) : BaseModule(), PersistentState<LastTriggerState> {
private val stopSignal = CompletableDeferred<Unit>() // 用于等待协程退出
override val name: String = "RconPlayerListModule"
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope : CoroutineScope? = null
// 持久化文件路径
private val stateFile = File("rcon_playerlist_state.json")
override fun getStateFile(): File = stateFile
// 保存最新触发过的消息 realId 和 time
private var moduleState: ModuleState = loadState()
private var lastTriggerState: LastTriggerState = loadState()
private val rconPath: String
get() = YamlConfigLoader.loadToolConfig().rcon.mcRconToolPath
?: throw IllegalStateException("RCON 工具路径未配置")
private val rconConfigPath: String
get() = YamlConfigLoader.loadToolConfig().rcon.mcRconToolConfigPath
?: throw IllegalStateException("Rcon配置路径未配置")
override fun getState(): LastTriggerState = lastTriggerState
override fun onLoad() {
LoggerUtil.logger.info("[$name] 模块已装载,目标群组: $targetGroupId,轮询间隔: ${pollIntervalMillis}ms")
LoggerUtil.logger.info("[$name] 上次触发状态: realId=${moduleState.lastTriggeredRealId}, time=${moduleState.lastTriggerTime}")
LoggerUtil.logger.info("[$name] 模块已装载,目标群组: ${groupMessagePollingModule.targetGroupId}")
LoggerUtil.logger.info("[$name] 上次触发状态: realId=${lastTriggerState.lastTriggeredRealId}, time=${lastTriggerState.lastTriggerTime}")
LoggerUtil.logger.info("[$name] 关键词列表: $keywords")
scope.launch {
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope!!.launch {
LoggerUtil.logger.info("[$name] 轮询协程启动")
try {
while (isActive) {
LoggerUtil.logger.debug("[$name] 开始轮询群消息历史...")
try {
val historyEvent = napCatClient.send(
GetGroupMsgHistoryRequest(
count = msgHistoryCheck,
groupId = ID.long(targetGroupId)
)
) as? GetGroupMsgHistoryEvent
val messages = historyEvent?.data?.messages ?: emptyList()
LoggerUtil.logger.debug("[$name] 获取到 ${messages.size} 条最近消息")
// 找到比 lastTriggeredRealId 更新的触发消息
val triggerMessages = messages.filter { msg ->
((msg.time > moduleState.lastTriggerTime ||
(msg.time == moduleState.lastTriggerTime && msg.realId > moduleState.lastTriggeredRealId)) && msg.userId != selfId) &&
msg.message.any { seg ->
seg.type == MessageType.Text &&
seg.data.text?.let { text ->
keywords.any { keyword ->
text == keyword
}
} == true
}
}
LoggerUtil.logger.debug("[$name] 找到 ${triggerMessages.size} 条符合条件的触发消息")
if (triggerMessages.isNotEmpty()) {
val triggerMsg = triggerMessages.maxBy { it.time }
LoggerUtil.logger.info("[$name] 找到触发消息 realId=${triggerMsg.realId}, time=${triggerMsg.time}, userId=${triggerMsg.userId}")
val now = System.currentTimeMillis()
// ✅ 首次触发允许直接执行
val canTrigger = (lastSuccessTime == 0L) || (now - lastSuccessTime >= cooldownMillis)
if (!canTrigger) {
val remaining = ((cooldownMillis - (now - lastSuccessTime)) / 1000).coerceAtLeast(1)
LoggerUtil.logger.info("[$name] 冷却中,拒绝执行,剩余 $remaining")
sendCooldownMessage(napCatClient, triggerMsg.realId, triggerMsg.time)
continue
}
// 执行 RCON
val commands = listOf("forge tps","list")
LoggerUtil.logger.info("[$name] 执行 RCON 命令: $commands")
runCatching {
val tpsOutput = runCatching {
CmdUtil.runExeCommand(rconPath, "-c", rconConfigPath, "-T", (timeout / 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", (timeout / 1000).toString() + "s", "list")
}.getOrElse { ex ->
LoggerUtil.logger.warn("[$name] 执行 list 失败: ${ex.message}")
throw ex
}
if (tpsOutput.contains("i/o timeout") || listOutput.contains("i/o timeout")) {
throw TimeoutException()
}
// 合并输出,再解析
buildString {
appendLine(tpsOutput.trim())
appendLine("--------")
appendLine(listOutput.trim())
}
} .onFailure { ex ->
if (ex is TimeoutException) {
lastSuccessTime = now // ✅ 成功后记录冷却开始时间
LoggerUtil.logger.warn("[$name] RCON 连接超时: ${ex.message}")
sendFailedMessage(napCatClient, triggerMsg.realId, triggerMsg.time)
} else {
lastSuccessTime = now // ✅ 成功后记录冷却开始时间
LoggerUtil.logger.error("[$name] RCON 命令执行失败", ex)
sendFailedMessage(
napCatClient,
triggerMsg.realId,
triggerMsg.time,
"系统内部错误请联系管理员:${ex.message}"
)
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, triggerMsg.realId, triggerMsg.time)
}
} else {
LoggerUtil.logger.debug("[$name] 未找到新的触发消息")
}
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 轮询玩家列表或发送转发消息失败", e)
}
LoggerUtil.logger.debug("[$name] 本轮轮询完成,等待 ${pollIntervalMillis}ms")
delay(pollIntervalMillis)
scope!!.launch {
LoggerUtil.logger.info("[$name] 开始订阅消息流")
groupMessagePollingModule.messagesFlow.collect { messages ->
if(loaded) handleMessages(messages)
}
} catch (e: CancellationException) {
LoggerUtil.logger.info("[$name] 轮询协程收到取消信号")
} finally {
LoggerUtil.logger.info("[$name] 轮询协程退出,完成 stopSignal")
stopSignal.complete(Unit)
}
}
}
public override fun onUnload() {
LoggerUtil.logger.info("[$name] 模块已卸载")
saveState(moduleState.lastTriggeredRealId, moduleState.lastTriggerTime) // 卸载时保存
override suspend fun onUnload() {
LoggerUtil.logger.info("[$name] 模块卸载,取消协程...")
scope?.cancel()
saveState(lastTriggerState)
LoggerUtil.logger.info("[$name] 模块已卸载完成")
}
override suspend fun stop() {
LoggerUtil.logger.info("[$name] 收到停止命令,开始关闭协程...")
scope.cancel() // 取消协程
LoggerUtil.logger.info("[$name] 等待协程退出...")
stopSignal.await() // 等待协程完成
LoggerUtil.logger.info("[$name] 协程已退出,卸载模块资源")
onUnload() // 卸载模块资源,保存状态
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()
if (triggerMessages.isNotEmpty()) {
val triggerMsg = triggerMessages.maxBy { it.time }
LoggerUtil.logger.info("[$name] 找到触发消息 realId=${triggerMsg.realId}, time=${triggerMsg.time}, userId=${triggerMsg.userId}")
processTrigger(triggerMsg)
}
}
private suspend fun processTrigger(msg: GetFriendMsgHistoryEvent.SpecificMsg) {
val now = System.currentTimeMillis()
// ✅ 冷却检查(首次触发直接允许)
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
}
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 // ✅ 成功/失败都要刷新冷却开始时间
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
}
}.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)
}
// ✅ 更新触发状态 & 持久化
lastTriggerState.lastTriggeredRealId = msg.realId
lastTriggerState.lastTriggerTime = msg.time
saveState(lastTriggerState)
}
private suspend fun sendCooldownMessage(client: NapCatClient, realId: Long, time: Long) {
val now = System.currentTimeMillis()
val remaining = ((cooldownMillis - (now - lastSuccessTime)) / 1000).coerceAtLeast(1) // 至少显示 1 秒
@ -195,14 +182,14 @@ class RconPlayerListModule(
val request = SendGroupMsgRequest(
MessageElement.reply(ID.long(realId), msg),
ID.long(targetGroupId)
ID.long(groupMessagePollingModule.targetGroupId)
)
client.sendUnit(request)
// 更新触发状态,但不更新 lastSuccessTime避免延长冷却
moduleState.lastTriggeredRealId = realId
moduleState.lastTriggerTime = time
saveState(realId, time)
lastTriggerState.lastTriggeredRealId = realId
lastTriggerState.lastTriggerTime = time
saveState(lastTriggerState)
}
private val failedMessages = listOf(
@ -211,7 +198,21 @@ class RconPlayerListModule(
"🐌 RCON 响应太慢,像蜗牛一样",
"🛠️ 系统开小差了,请联系管理员",
"⚠️ 服务器没理我,可能在打盹",
"🔥 电路冒烟了!查询失败"
"🔥 电路冒烟了!查询失败",
// 新增的
"⏳ 等了半天也没回应,土豆睡着了?",
"📡 信号迷路了RCON 连接失败",
"🌀 数据转圈圈,一直出不来",
"🚧 前方施工中,暂时无法获取数据",
"🤖 RCON 小机器人宕机,请稍后重启",
"🌩️ 网络打雷了,数据全跑丢了",
"🕳️ 请求掉进黑洞了,没有回音",
"🎭 服务器玩消失,不肯理我",
"📉 查询失败RCON 掉线了",
"🥶 服务器结冰了,冻得说不出话",
"📵 RCON 拒绝通信,像开飞行模式",
"💤 服务器打瞌睡,回应超时"
)
private suspend fun sendFailedMessage(
client: NapCatClient,
@ -226,15 +227,15 @@ class RconPlayerListModule(
val request = SendGroupMsgRequest(
MessageElement.reply(ID.long(realId), finalText),
ID.long(targetGroupId)
ID.long(groupMessagePollingModule.targetGroupId)
)
client.sendUnit(request)
LoggerUtil.logger.info("[$name] 已发送 RCON 失败消息")
// 更新触发的最大 realId
moduleState.lastTriggeredRealId = realId
moduleState.lastTriggerTime = time
saveState(realId, time) // 保存到文件
lastTriggerState.lastTriggeredRealId = realId
lastTriggerState.lastTriggerTime = time
saveState(lastTriggerState) // 保存到文件
}
private suspend fun sendForwardMessage(client: NapCatClient, tps: TPSInfo, info: PlayerListInfo, realId: Long, time: Long) {
LoggerUtil.logger.info("[$name] 发送转发消息: realId=$realId, TPS=${tps.overall.meanTPS}, 在线玩家数=${info.onlineCount}")
@ -279,7 +280,7 @@ class RconPlayerListModule(
} else {
messages.add(
SendForwardMsgRequest.Message(
data = SendForwardMsgRequest.PurpleData("😴 当前没有玩家在线"),
data = SendForwardMsgRequest.PurpleData("😴 当前没有玩家在线\n"),
type = MessageType.Text
)
)
@ -311,7 +312,7 @@ class RconPlayerListModule(
)
val request = SendForwardMsgRequest(
groupId = ID.long(targetGroupId),
groupId = ID.long(groupMessagePollingModule.targetGroupId),
messages = listOf(topMessage),
news = listOf(
SendForwardMsgRequest.ForwardModelNews("点击查看服务器状态与玩家列表"),
@ -325,9 +326,9 @@ class RconPlayerListModule(
client.sendUnit(request)
LoggerUtil.logger.info("[$name] 已发送 TPS+玩家列表 转发消息")
moduleState.lastTriggeredRealId = realId
moduleState.lastTriggerTime = time
saveState(realId, time)
lastTriggerState.lastTriggeredRealId = realId
lastTriggerState.lastTriggerTime = time
saveState(lastTriggerState)
}
// 添加时间格式化函数
@ -476,30 +477,29 @@ class RconPlayerListModule(
// ---------------- 持久化部分 ----------------
@Serializable
data class ModuleState(var lastTriggeredRealId: Long, var lastTriggerTime: Long)
data class LastTriggerState(var lastTriggeredRealId: Long, var lastTriggerTime: Long)
private fun saveState(realId: Long, time: Long) {
override fun saveState(state: LastTriggerState) {
try {
val state = ModuleState(realId, time)
stateFile.writeText(Json.encodeToString(state))
LoggerUtil.logger.info("[$name] 已保存状态: lastTriggeredRealId=$realId, lastTriggerTime=$time")
LoggerUtil.logger.info("[$name] 已保存状态: lastTriggeredRealId=${state.lastTriggeredRealId}, lastTriggerTime=${state.lastTriggerTime}")
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 保存状态失败", e)
}
}
private fun loadState(): ModuleState {
override fun loadState(): LastTriggerState {
return try {
if (!stateFile.exists()) {
LoggerUtil.logger.info("[$name] 状态文件不存在,使用默认值")
return ModuleState(-1L, 0L)
return LastTriggerState(-1L, 0L)
}
val state = Json.decodeFromString<ModuleState>(stateFile.readText())
val state = Json.decodeFromString<LastTriggerState>(stateFile.readText())
LoggerUtil.logger.info("[$name] 成功加载状态: lastTriggeredRealId=${state.lastTriggeredRealId}, lastTriggerTime=${state.lastTriggerTime}")
state
} catch (e: Exception) {
LoggerUtil.logger.warn("[$name] 读取状态失败,使用默认值", e)
ModuleState(-1L, 0L)
LastTriggerState(-1L, 0L)
}
}
}

View File

@ -18,7 +18,6 @@ import top.r3944realms.ltdmanager.utils.Environment
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.util.*
import kotlin.collections.ArrayDeque
import kotlin.collections.isNotEmpty
import kotlin.time.Duration.Companion.seconds
class NapCatClient private constructor() : Closeable {
@ -30,11 +29,11 @@ class NapCatClient private constructor() : Closeable {
private val semaphore = Semaphore(3)
// 普通优先级队列
private val requestQueue = PriorityQueue<QueueItem>(compareBy { it.priority })
private val requestQueue = PriorityQueue<NapCatQueueItem>(compareBy { it.priority })
private val queueMutex = Mutex()
// 紧急队列 (先进先出,最多 10 个)
private val urgentQueue = ArrayDeque<QueueItem>(10)
private val urgentQueue = ArrayDeque<NapCatQueueItem>(10)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@ -71,7 +70,7 @@ class NapCatClient private constructor() : Closeable {
checkRequest(request)
val deferred = CompletableDeferred<Unit>()
queueMutex.withLock {
requestQueue.add(QueueItem(request, deferred, retries, priority, expectsEvent = false))
requestQueue.add(NapCatQueueItem(request, deferred, retries, priority, expectsEvent = false))
}
deferred.await()
}
@ -90,7 +89,7 @@ class NapCatClient private constructor() : Closeable {
if (urgentQueue.size >= 10) {
throw IllegalStateException("紧急任务队列已满 (最多 10 个)")
}
urgentQueue.addLast(QueueItem(request, deferred, retries, priority = Int.MIN_VALUE, expectsEvent = false))
urgentQueue.addLast(NapCatQueueItem(request, deferred, retries, priority = Int.MIN_VALUE, expectsEvent = false))
}
deferred.await()
}
@ -106,7 +105,7 @@ class NapCatClient private constructor() : Closeable {
checkRequest(request)
val deferred = CompletableDeferred<T>()
queueMutex.withLock {
requestQueue.add(QueueItem(request, deferred, retries, priority, expectsEvent = true))
requestQueue.add(NapCatQueueItem(request, deferred, retries, priority, expectsEvent = true))
}
return deferred.await()
}
@ -125,7 +124,7 @@ class NapCatClient private constructor() : Closeable {
if (urgentQueue.size >= 10) {
throw IllegalStateException("紧急任务队列已满 (最多 10 个)")
}
urgentQueue.addLast(QueueItem(request, deferred, retries, priority = Int.MIN_VALUE, expectsEvent = true))
urgentQueue.addLast(NapCatQueueItem(request, deferred, retries, priority = Int.MIN_VALUE, expectsEvent = true))
}
return deferred.await()
}
@ -139,7 +138,7 @@ class NapCatClient private constructor() : Closeable {
}
private suspend fun processRequest(item: QueueItem) {
private suspend fun processRequest(item: NapCatQueueItem) {
semaphore.withPermit {
val (request, deferred, retries, _, expectsEvent) = item
var attempt = 0

View File

@ -3,12 +3,12 @@ package top.r3944realms.ltdmanager.napcat
import kotlinx.coroutines.CompletableDeferred
import top.r3944realms.ltdmanager.napcat.request.NapCatRequest
data class QueueItem(
data class NapCatQueueItem(
val request: NapCatRequest,
val deferred: CompletableDeferred<*>,
var retries: Int,
val priority: Int,
val expectsEvent: Boolean // true 表示返回 NapCatEvent, false 表示 Unit
) : Comparable<QueueItem> {
override fun compareTo(other: QueueItem): Int = priority.compareTo(other.priority)
) : Comparable<NapCatQueueItem> {
override fun compareTo(other: NapCatQueueItem): Int = priority.compareTo(other.priority)
}

View File

@ -5,7 +5,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
@Serializable
data class FailedRequestEvent(
data class FailedNapCatRequestEvent(
val status: Status = Status.Failed,
val retcode: Int,
val data: JsonElement?= null,

View File

@ -1,7 +1,10 @@
package top.r3944realms.ltdmanager.napcat.event
import io.ktor.http.*
import kotlinx.serialization.*
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import top.r3944realms.ltdmanager.napcat.event.account.AbstractAccountEvent
import top.r3944realms.ltdmanager.napcat.event.file.AbstractFileEvent
@ -41,8 +44,8 @@ abstract class NapCatEvent(
}
}
private fun failedDecode(jsonString: String): FailedRequestEvent {
return FailedRequestEvent.json.decodeFromString(jsonString)
private fun failedDecode(jsonString: String): FailedNapCatRequestEvent {
return FailedNapCatRequestEvent.json.decodeFromString(jsonString)
}
fun decodeEvent(jsonString: String, type: String): NapCatEvent {
return try {

View File

@ -3,8 +3,6 @@ package top.r3944realms.ltdmanager.napcat.event.group
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import top.r3944realms.ltdmanager.napcat.request.group.GetGroupIgnoredNotifiesRequest
import top.r3944realms.ltdmanager.napcat.request.group.GetGroupSystemMsgRequest
/**
* GetGroupIgnoredNotifies事件

View File

@ -4,7 +4,6 @@ package top.r3944realms.ltdmanager.napcat.event.message.group
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.JsonElement
import top.r3944realms.ltdmanager.napcat.event.NapCatEvent
import top.r3944realms.ltdmanager.napcat.event.group.AbstractGroupEvent
/**

View File

@ -4,7 +4,6 @@ package top.r3944realms.ltdmanager.napcat.event.message.group
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import top.r3944realms.ltdmanager.napcat.event.NapCatEvent
import top.r3944realms.ltdmanager.napcat.event.group.AbstractGroupEvent
/**

View File

@ -1,42 +0,0 @@
package top.r3944realms.ltdmanager
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import top.r3944realms.ltdmanager.module.GroupRequestHandlerModule
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.util.concurrent.atomic.AtomicBoolean
fun main() = runBlocking {
// 标记程序是否运行
val isRunning = AtomicBoolean(true)
// 创建模块实例
val groupModule = GroupRequestHandlerModule(
client = GlobalManager.napCatClient,
targetGroupId = 538751386
)
// 注册模块到全局模块管理器
GlobalManager.moduleManager.registerModule(groupModule)
// 加载模块
GlobalManager.moduleManager.loadModule(groupModule.name)
// 捕获 JVM 关闭信号,优雅退出
Runtime.getRuntime().addShutdownHook(Thread {
runBlocking {
LoggerUtil.logger.info("\n收到退出信号,正在停止所有模块...")
GlobalManager.moduleManager.stopAllModules() // 批量 stop
LoggerUtil.logger.info("模块卸载完成,程序退出。")
GlobalManager.shutdown()
}
isRunning.set(false)
})
// 持续挂起,保持主线程运行
while (isRunning.get()) {
delay(1000L)
}
}

View File

@ -28,37 +28,10 @@ object ConfigInitializer {
if (resourceStream != null) {
Files.copy(resourceStream, filePath, StandardCopyOption.REPLACE_EXISTING)
LoggerUtil.logger.info("已生成默认配置文件: $filePath")
} else {
// 资源文件不存在,可写入内置默认 YAML
val defaultYaml = """
database:
url: "jdbc:mysql://localhost:3306/quizdb?useSSL=false&serverTimezone=UTC"
user: "root"
encrypted-password: "123123aa"
crypto:
secret-key: "ltd25r3944realms"
mode:
bot-api-type: HTTP
environment: DEVELOPMENT
http:
url: "https://127.0.0.1:3001"
encrypted-token: "123123bb"
websocket:
url: "wss://127.0.0.1:3002"
encrypted-token: "123123cc"
tools:
rcon:
mc-rcon-tool-path: "/path/to/rcon"
mc-rcon-tool-config-path: "/path/to/rcon_config"
server-url: "your.minecraft.server"
rcon-password: "123123dd"
""".trimIndent()
LoggerUtil.logger.info("第一次启动,请修改配置后再启动")
exitProcess(-1);
} else throw Error("Jar内部资源文件缺失")
Files.writeString(filePath, defaultYaml)
LoggerUtil.logger.info("已生成默认配置文件(使用内置内容): $filePath")
}
LoggerUtil.logger.info("第一次启动,请修改配置后再启动")
exitProcess(-1);
} else {
LoggerUtil.logger.info("配置文件已存在: $filePath")
}

View File

@ -0,0 +1,57 @@
package top.r3944realms.ltdmanager.utils
import java.io.InputStreamReader
import java.nio.charset.Charset
import java.sql.Timestamp
import java.text.SimpleDateFormat
object HtmlTemplateUtil {
/**
* 从指定 HTML 文件读取内容并替换占位符
* @param resourcePath HTML 文件路径
* @param variables 占位符变量 mapOf("名字" to "小明", "时间" to "2025-08-28")
* @param charset 文件编码默认 UTF-8
*/
fun renderTemplateFromClasspath(
resourcePath: String,
variables: Map<String, String>,
charset: Charset = Charsets.UTF_8
): String {
val inputStream = object {}.javaClass.classLoader.getResourceAsStream(resourcePath)
?: throw IllegalArgumentException("模板文件未找到: $resourcePath")
val template = InputStreamReader(inputStream, charset).use { it.readText() }
var result = template
variables.forEach { (key, value) ->
result = result.replace("{$key}", value)
}
return result
}
/**
* 生成激活码邮件 HTML
*/
fun tokenMailHtmlTemplate(
playerName: String,
token: String,
expireTime: Timestamp? = null,
validDay: Int? = null,
timeYear: Int
): String {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
return renderTemplateFromClasspath(
resourcePath = "mail-body.html",
variables = mapOf(
"player_name" to playerName,
"activation_code" to token,
"expire_time" to (expireTime?.let { sdf.format(it) } ?: "永久有效"),
"valid_days" to (validDay?.toString() ?: "INF"),
"time_year" to timeYear.toString()
)
)
}
}

View File

@ -1,11 +1,258 @@
package top.r3944realms.ltdmanager.utils
import org.slf4j.LoggerFactory
import org.apache.logging.log4j.LogManager
import org.apache.logging.log4j.Logger
import org.apache.logging.log4j.core.LoggerContext
import org.apache.logging.log4j.core.config.Configurator
import java.util.concurrent.atomic.AtomicBoolean
class LoggerUtil {
companion object {
val logger by lazy {
LoggerFactory.getLogger("LTDManagerBot")
object LoggerUtil {
private val isShuttingDown = AtomicBoolean(false)
private val shutdownHooks = mutableListOf<() -> Unit>()
val logger: Logger by lazy {
LogManager.getLogger("LTDManagerBot")
}
init {
// 注册关闭钩子
Runtime.getRuntime().addShutdownHook(Thread {
shutdownGracefully()
})
}
/**
* 注册自定义关闭钩子
*/
fun addShutdownHook(hook: () -> Unit) {
shutdownHooks.add(hook)
}
/**
* 优雅关闭日志系统
*/
fun shutdownGracefully() {
if (isShuttingDown.getAndSet(true)) {
return // 避免重复关闭
}
try {
// 输出关闭开始信息
emergencyInfo("🚀 开始优雅关闭日志系统...")
// 先执行自定义关闭钩子(业务资源关闭)
runCustomShutdownHooks()
// 刷新所有日志输出(确保日志文件保存)
flushAllLogs()
// 给日志一些时间写入磁盘
Thread.sleep(200)
// 关闭 Log4j2 上下文
shutdownLog4j2()
// 最终确认
println("🎉 日志系统关闭完成")
} catch (e: Exception) {
System.err.println("❌ 关闭过程中发生错误: ${e.message}")
e.printStackTrace()
}
}
/**
* 执行自定义关闭钩子
*/
private fun runCustomShutdownHooks() {
if (shutdownHooks.isNotEmpty()) {
emergencyInfo("执行 ${shutdownHooks.size} 个自定义关闭钩子")
shutdownHooks.forEachIndexed { index, hook ->
try {
emergencyInfo("执行关闭钩子 ${index + 1}")
hook()
} catch (e: Exception) {
System.err.println("❌ 关闭钩子 ${index + 1} 执行失败: ${e.message}")
e.printStackTrace()
}
}
}
}
/**
* 刷新所有日志输出 - 针对 RollingFile Appender 优化
*/
fun flushAllLogs() {
try {
emergencyInfo("正在刷新日志输出...")
val context = LogManager.getContext(false)
if (context is LoggerContext) {
// 获取所有 logger 配置
val loggers = context.configuration.loggers
loggers.forEach { (loggerName, loggerConfig) ->
loggerConfig.appenders.forEach { (appenderName, appender) ->
try {
// 特别处理 RollingFileAppender
if (appenderName.contains("File", ignoreCase = true)) {
emergencyInfo("刷新文件 Appender: $appenderName")
// 停止并重新启动以确保数据刷新
appender.stop()
// 短暂延迟确保文件操作完成
Thread.sleep(50)
appender.start()
}
} catch (e: Exception) {
System.err.println("❌ 刷新 Appender $appenderName 失败: ${e.message}")
}
}
}
}
// 额外等待确保所有日志写入完成
Thread.sleep(150)
emergencyInfo("日志刷新完成")
} catch (e: Exception) {
System.err.println("❌ 刷新日志失败: ${e.message}")
}
}
/**
* 关闭 Log4j2 上下文 - 安全版本
*/
private fun shutdownLog4j2() {
try {
emergencyInfo("正在关闭 Log4j2 上下文...")
val context = LogManager.getContext(false)
if (context is LoggerContext) {
// 先停止所有 appender
context.configuration.loggers.forEach { (_, loggerConfig) ->
loggerConfig.appenders.values.forEach { appender ->
try {
appender.stop()
} catch (e: Exception) {
// 忽略停止错误
}
}
}
// 等待一段时间确保文件操作完成
Thread.sleep(100)
// 关闭上下文
context.stop()
// 使用 Configurator 进行完全关闭
Configurator.shutdown(context)
}
emergencyInfo("Log4j2 上下文关闭完成")
} catch (e: Exception) {
System.err.println("❌ 关闭 Log4j2 上下文失败: ${e.message}")
}
}
/**
* 同步信息输出同时输出到控制台和日志
*/
fun syncInfo(message: String) {
println("[INFO] $message")
if (!isShuttingDown.get()) {
logger.info(message)
}
}
/**
* 同步调试输出
*/
fun syncDebug(message: String) {
if (!isShuttingDown.get()) {
logger.debug(message)
}
}
/**
* 同步错误输出
*/
fun syncError(message: String, exception: Exception? = null) {
System.err.println("[ERROR] $message")
exception?.let { System.err.println("[ERROR] Exception: ${it.message}") }
if (!isShuttingDown.get()) {
if (exception != null) {
logger.error(message, exception)
} else {
logger.error(message)
}
}
}
/**
* 同步警告输出
*/
fun syncWarn(message: String, exception: Exception? = null) {
println("[WARN] $message")
exception?.let { println("[WARN] Exception: ${it.message}") }
if (!isShuttingDown.get()) {
if (exception != null) {
logger.warn(message, exception)
} else {
logger.warn(message)
}
}
}
/**
* 紧急输出始终输出到控制台尝试记录日志
*/
fun emergencyInfo(message: String) {
val formattedMessage = "[EMERGENCY] $message"
println(formattedMessage)
// 即使正在关闭也尝试记录到日志文件
if (!isShuttingDown.get()) {
try {
logger.info(formattedMessage)
} catch (e: Exception) {
// 如果日志系统已经关闭,忽略错误
}
}
}
/**
* 检查日志系统是否正在关闭
*/
fun isLoggingShutdown(): Boolean = isShuttingDown.get()
/**
* 强制刷新当前日志
*/
fun flushCurrentLogs() {
if (!isShuttingDown.get()) {
try {
logger.info("手动刷新日志...")
// Log4j2 通常会自动刷新,但可以强制调用
val context = LogManager.getContext(false)
if (context is LoggerContext) {
context.configuration.loggers.forEach { (_, loggerConfig) ->
loggerConfig.appenders.values.forEach { appender ->
try {
appender.stop()
Thread.sleep(10)
appender.start()
} catch (e: Exception) {
// 忽略错误
}
}
}
}
} catch (e: Exception) {
System.err.println("手动刷新日志失败: ${e.message}")
}
}
}
}

View File

@ -1,3 +1,4 @@
#修改后再重启注释将会消失
database:
# 数据库地址
url: "jdbc:mysql://localhost:3306/quizdb?useSSL=false&serverTimezone=UTC"
@ -25,4 +26,20 @@ tools:
mc-rcon-tool-config-path: "/path/to/rcon_config"
server-url: "your.minecraft.server"
# 格式为 ENC(XXX),若不是则会在加载完成配置后自动加密
rcon-password: "123123dd"
rcon-password: "123123dd"
mail:
protocol: "SMTP"
host: "smtp.example.com"
port: 587
mail-address: "your_email@example.com"
# 格式为 ENC(XXX),若不是则会在加载完成配置后自动加密
encrypted-password: "your_password"
auth: true
tls: true
blessing-skin-server:
url: "https://your.blessing.server"
invitation-api:
path: "/api/invitation-codes/generate"
# 格式为 ENC(XXX),若不是则会在加载完成配置后自动加密
encrypted-token: "your-secret-token"

View File

@ -0,0 +1,39 @@
DELIMITER //
CREATE DATABASE IF NOT EXISTS ltd_manager_bot //
USE ltd_manager_bot //
CREATE TABLE IF NOT EXISTS invitation_code_ascription(
id int PRIMARY KEY REFERENCES minecraft_manager_ltd.players(id),
token_id int unsigned NULL REFERENCES blessingskin.invitation_codes(id) )
//;
-- 也许token_id应该改名为code_id
DELIMITER ;
DELIMITER //
DROP VIEW IF EXISTS qualified_user_info //
CREATE VIEW qualified_user_info AS
SELECT
p.id AS player_id,
p.player_name AS player_name,
p.qq AS qq,
ic.code AS token,
ic.expires_at AS expires_at,
CASE
WHEN ic.is_expired = 0 THEN 1 -- 未过期 → 有效
WHEN ic.used_by != 0 THEN 1 -- 已使用 → 有效
ELSE 0 -- 过期且未使用 → 无效
END AS effective,
IF(ic.used_by != 0, 1, 0) AS is_used -- 是否使用
FROM (minecraft_manager_ltd.players p LEFT JOIN ltd_manager_bot.invitation_code_ascription ica ON p.id = ica.id) LEFT JOIN blessingskin.invitation_codes ic ON ica.token_id = ic.id
WHERE p.status = 1; //
DELIMITER ;
SELECT p1.id, invitation_codes.id
FROM minecraft_manager_ltd.players p1 LEFT JOIN blessingskin.players p2 ON LOWER(p1.player_name) COLLATE utf8mb4_unicode_ci = LOWER(p2.name) COLLATE utf8mb4_unicode_ci JOIN blessingskin.invitation_codes ON p2.uid = invitation_codes.used_by
WHERE used_by != 0; --
-- uid 拿到token 去查询 ID

View File

@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="zh-CN">
<!--suppress HtmlRequiredTitleElement -->
<head>
<meta charset="utf-8">
<style>
body {
font-family: 'Microsoft YaHei', 'Segoe UI', Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f7fa;
padding: 0;
margin: 0;
}
.email-container {
max-width: 600px;
margin: 20px auto;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
overflow: hidden;
}
/*noinspection CssReplaceWithShorthandSafely*/
.header {
background-color: #0066cc; /* Outlook 桌面版会用这个 */
background: linear-gradient(135deg, #0066cc, #004499);/* 现代客户端 */
color: white;
padding: 25px;
text-align: center;
}
.content {
padding: 25px;
}
.code-box {
background: #f8f9fa;
border-left: 4px solid #0066cc;
padding: 15px;
margin: 20px 0;
font-family: 'Consolas', monospace;
font-size: 18px;
color: #d32f2f;
}
.footer {
background: #f0f2f5;
padding: 15px;
text-align: center;
font-size: 12px;
color: #666;
}
.warning {
background: #fff8e1;
border-left: 4px solid #ffc107;
padding: 12px;
margin: 20px 0;
}
.button {
display: inline-block;
padding: 10px 20px;
background: #0066cc;
color: white !important;
text-decoration: none;
border-radius: 4px;
margin-top: 15px;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<h1 style="margin:0;font-weight:500;">LTD 专属服务器激活码</h1>
</div>
<div class="content">
<p>尊敬的 <strong>{player_name}</strong>,您好:</p>
<p>感谢您选择LTD专属服务器您的激活凭证如下</p>
<div class="code-box">
<div style="font-size:14px;color:#666;margin-bottom:5px;">激活码</div>
<div style="font-size:22px;font-weight:bold;">{activation_code}</div>
</div>
<div class="warning">
<span style="color:#d32f2f;font-weight:bold;">⚠️ 重要提示:</span>
本激活码与您的账号绑定,请勿泄露给他人。<br/>
失效日期:<strong>{expire_time}</strong>
</div>
<div class="warning">
<span style="color:#0066cc;font-weight:bold;">⏳ 有效期说明:</span>
本激活码自发放之日起 <strong>{valid_days} 天</strong> 内有效,过期将自动失效。
</div>
<p>请点击下方按钮前往注册:</p>
<a href="https://skins.r3944realms.top/auth/register" class="button">皮肤站注册</a>
<p style="margin-top:25px;">如有任何问题,欢迎随时联系我们的
<a href="mailto:f256198830@hotmail.com" style="color:#0066cc;">技术支持</a>
</p>
</div>
<div class="footer">
<p>© {time_year} LTD 服务 | <a href="https://r3944realms.top" style="color:#0066cc;text-decoration:none;">官方网站</a></p>
<p style="color:#999;font-size:11px;">此为群发邮件,请勿直接回复</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,48 @@
package top.r394realms.ltdmanagertest.mail
import top.r3944realms.ltdmanager.GlobalManager
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import top.r3944realms.ltdmanager.core.mail.mail
import top.r3944realms.ltdmanager.module.MailModule
import top.r3944realms.ltdmanager.utils.HtmlTemplateUtil
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
fun main() = GlobalManager.runBlockingMain {
val mailConfig = YamlConfigLoader.loadMailConfig()
val mailModule = mailConfig.port?.let { portIt ->
mailConfig.mailAddress?.let { mailAddressIt ->
MailModule(
host = mailConfig.host.toString(),
authToken = mailConfig.decryptedPassword.toString(),
port = portIt,
senderEmailAddress = mailAddressIt,
)
}
}
if (mailModule == null) throw IllegalStateException("Lost Required Argument")
GlobalManager.moduleManager.registerModule(mailModule)
GlobalManager.moduleManager.loadModule(mailModule.name)
val template = object {}.javaClass.classLoader
.getResource("mail-body.html")?: throw IllegalArgumentException("模板文件未找到")
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(
"player_name" to "小明",
"activation_code" to "ABC123",
"expire_time" to expireTime,
"valid_days" to "${expireHours/24}",
"time_year" to "2025"
))
val mail = mail {
from = "闲趣时坞"
to += "f256198830@hotmail.com"
subject = "=-="
body = bodyC
isHtml = true
cc += "f256198830@outlook.com"
}
mailModule.enqueue(mail)
}

View File

@ -0,0 +1,20 @@
package top.r394realms.ltdmanagertest
import top.r3944realms.ltdmanager.GlobalManager
import top.r3944realms.ltdmanager.module.GroupRequestHandlerModule
fun main() = GlobalManager.runBlockingMain {
// 创建模块实例
val groupModule = GroupRequestHandlerModule(
client = GlobalManager.napCatClient,
targetGroupId = 538751386
)
// 注册模块到全局模块管理器
GlobalManager.moduleManager.registerModule(groupModule)
// 加载模块
GlobalManager.moduleManager.loadModule(groupModule.name)
}