Compare commits

...

10 Commits

133 changed files with 7543 additions and 775 deletions

View File

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true"> <component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="@47.116.125.76" uuid="5b1b9d12-d8be-43ba-a647-9d6e467bf201"> <data-source source="LOCAL" name="@110.42.70.155" uuid="5b1b9d12-d8be-43ba-a647-9d6e467bf201">
<driver-ref>mysql.8</driver-ref> <driver-ref>mysql.8</driver-ref>
<synchronize>true</synchronize> <synchronize>true</synchronize>
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver> <jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mysql://47.116.125.76:3308</jdbc-url> <jdbc-url>jdbc:mysql://110.42.70.155:3306</jdbc-url>
<jdbc-additional-properties> <jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" /> <property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" /> <property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />

View File

@ -0,0 +1,12 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="IncorrectHttpHeaderInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="customHeaders">
<set>
<option value="X-API-Key" />
</set>
</option>
</inspection_tool>
</profile>
</component>

View File

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ASMIdeaPluginConfiguration"> <component name="ASMIdeaPluginConfiguration">
<asm skipDebug="false" skipFrames="false" skipCode="false" expandFrames="false" /> <asm skipDebug="false" skipFrames="false" skipCode="false" expandFrames="false" />

124
.idea/uiDesigner.xml Normal file
View File

@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Palette2">
<group name="Swing">
<item class="com.intellij.uiDesigner.HSpacer" tooltip-text="Horizontal Spacer" icon="/com/intellij/uiDesigner/icons/hspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="1" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="com.intellij.uiDesigner.VSpacer" tooltip-text="Vertical Spacer" icon="/com/intellij/uiDesigner/icons/vspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="1" anchor="0" fill="2" />
</item>
<item class="javax.swing.JPanel" icon="/com/intellij/uiDesigner/icons/panel.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3" />
</item>
<item class="javax.swing.JScrollPane" icon="/com/intellij/uiDesigner/icons/scrollPane.svg" removable="false" auto-create-binding="false" can-attach-label="true">
<default-constraints vsize-policy="7" hsize-policy="7" anchor="0" fill="3" />
</item>
<item class="javax.swing.JButton" icon="/com/intellij/uiDesigner/icons/button.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="0" fill="1" />
<initial-values>
<property name="text" value="Button" />
</initial-values>
</item>
<item class="javax.swing.JRadioButton" icon="/com/intellij/uiDesigner/icons/radioButton.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="RadioButton" />
</initial-values>
</item>
<item class="javax.swing.JCheckBox" icon="/com/intellij/uiDesigner/icons/checkBox.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="CheckBox" />
</initial-values>
</item>
<item class="javax.swing.JLabel" icon="/com/intellij/uiDesigner/icons/label.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="8" fill="0" />
<initial-values>
<property name="text" value="Label" />
</initial-values>
</item>
<item class="javax.swing.JTextField" icon="/com/intellij/uiDesigner/icons/textField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JPasswordField" icon="/com/intellij/uiDesigner/icons/passwordField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JFormattedTextField" icon="/com/intellij/uiDesigner/icons/formattedTextField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JTextArea" icon="/com/intellij/uiDesigner/icons/textArea.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTextPane" icon="/com/intellij/uiDesigner/icons/textPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JEditorPane" icon="/com/intellij/uiDesigner/icons/editorPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JComboBox" icon="/com/intellij/uiDesigner/icons/comboBox.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="2" anchor="8" fill="1" />
</item>
<item class="javax.swing.JTable" icon="/com/intellij/uiDesigner/icons/table.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JList" icon="/com/intellij/uiDesigner/icons/list.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="2" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTree" icon="/com/intellij/uiDesigner/icons/tree.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTabbedPane" icon="/com/intellij/uiDesigner/icons/tabbedPane.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSplitPane" icon="/com/intellij/uiDesigner/icons/splitPane.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSpinner" icon="/com/intellij/uiDesigner/icons/spinner.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSlider" icon="/com/intellij/uiDesigner/icons/slider.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSeparator" icon="/com/intellij/uiDesigner/icons/separator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3" />
</item>
<item class="javax.swing.JProgressBar" icon="/com/intellij/uiDesigner/icons/progressbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="javax.swing.JToolBar" icon="/com/intellij/uiDesigner/icons/toolbar.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1">
<preferred-size width="-1" height="20" />
</default-constraints>
</item>
<item class="javax.swing.JToolBar$Separator" icon="/com/intellij/uiDesigner/icons/toolbarSeparator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="0" fill="1" />
</item>
<item class="javax.swing.JScrollBar" icon="/com/intellij/uiDesigner/icons/scrollbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="0" anchor="0" fill="2" />
</item>
</group>
</component>
</project>

View File

@ -1,5 +1,7 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
fun k(v: String) = project.property(v) as String
plugins { plugins {
kotlin("jvm") version "1.9.23" kotlin("jvm") version "1.9.23"
kotlin("plugin.serialization") version "1.9.23" // 添加序列化插件 kotlin("plugin.serialization") version "1.9.23" // 添加序列化插件
@ -7,8 +9,8 @@ plugins {
id("com.github.johnrengelman.shadow") version "8.0.0" // fat jar id("com.github.johnrengelman.shadow") version "8.0.0" // fat jar
} }
group = project.property("project_group") as String group = k("project_group")
version = project.property("project_version") as String version = k("project_version")
repositories { repositories {
@ -22,6 +24,17 @@ repositories {
maven { maven {
url = uri("https://maven.aliyun.com/repository/gradle-plugin") url = uri("https://maven.aliyun.com/repository/gradle-plugin")
} }
maven {
url = uri("https://libraries.minecraft.net/")
}
// 第三方 repo比如 MohistMC 或 GlareMasters Pub
maven {
url = uri("https://repo.glaremasters.me/repository/public/")
}
maven {
name = "LTD Maven"
url = uri("https://nexus.bot.leisuretimedock.top/repository/maven-public/")
}
} }
//TODO: 0872d1c0-829c-e1d7-6782-89e45c8a6b76 //TODO: 0872d1c0-829c-e1d7-6782-89e45c8a6b76
dependencies { dependencies {
@ -35,6 +48,10 @@ repositories {
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.3") // 推荐使用kotlinx.serialization替代Gson implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.3") // 推荐使用kotlinx.serialization替代Gson
implementation("io.ktor:ktor-client-content-negotiation:2.3.12") implementation("io.ktor:ktor-client-content-negotiation:2.3.12")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// 如果需要日志拦截器(推荐用于调试)
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// 数据库相关 // 数据库相关
implementation("org.jetbrains.exposed:exposed-core:0.41.1") implementation("org.jetbrains.exposed:exposed-core:0.41.1")
implementation("org.jetbrains.exposed:exposed-jdbc:0.41.1") implementation("org.jetbrains.exposed:exposed-jdbc:0.41.1")
@ -58,6 +75,19 @@ repositories {
// 协程 // 协程
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.apache.commons:commons-lang3:3.17.0")
implementation("com.google.guava:guava:33.3.0-jre")
//DG_Lab 依赖库导入
implementation("io.netty:netty-all:4.1.109.Final")
implementation("com.google.code.gson:gson:2.10.1")
implementation("top.r3944realms.dg_lab:Common:${k("dg_lab_version")}")
//生成 二维码
implementation("com.google.zxing:core:[3.5.3,)")
//命令解析
implementation("com.mojang:brigadier:1.2.9")
// 测试 // 测试
testImplementation(kotlin("test")) testImplementation(kotlin("test"))

View File

@ -3,4 +3,5 @@ org.gradle.downloadSources=false
org.gradle.parallel=true org.gradle.parallel=true
org.gradle.degree_of_parallelism=16 org.gradle.degree_of_parallelism=16
project_group=top.r3944realms.ltdmanager project_group=top.r3944realms.ltdmanager
project_version=1.3-SNAPSHOT project_version=1.16-SNAPSHOT
dg_lab_version=4.4.14.18

Binary file not shown.

View File

@ -2,7 +2,9 @@ package top.r3944realms.ltdmanager
import kotlinx.coroutines.* import kotlinx.coroutines.*
import top.r3944realms.ltdmanager.blessingskin.BlessingSkinClient import top.r3944realms.ltdmanager.blessingskin.BlessingSkinClient
import top.r3944realms.ltdmanager.chevereto.CheveretoClient
import top.r3944realms.ltdmanager.core.mysql.MysqlHikariConnectPool import top.r3944realms.ltdmanager.core.mysql.MysqlHikariConnectPool
import top.r3944realms.ltdmanager.mcms.MCSMClient
import top.r3944realms.ltdmanager.mcserver.McSrvStatusClient import top.r3944realms.ltdmanager.mcserver.McSrvStatusClient
import top.r3944realms.ltdmanager.module.ModuleManager import top.r3944realms.ltdmanager.module.ModuleManager
import top.r3944realms.ltdmanager.napcat.NapCatClient import top.r3944realms.ltdmanager.napcat.NapCatClient
@ -19,6 +21,10 @@ object GlobalManager {
MysqlHikariConnectPool() MysqlHikariConnectPool()
} }
fun initApplication() {
moduleManager.loadConfig()
}
// NapCat 客户端 // NapCat 客户端
val napCatClient: NapCatClient by lazy { val napCatClient: NapCatClient by lazy {
NapCatClient.create() NapCatClient.create()
@ -29,6 +35,12 @@ object GlobalManager {
val blessingSkinClient: BlessingSkinClient by lazy { val blessingSkinClient: BlessingSkinClient by lazy {
BlessingSkinClient.create() BlessingSkinClient.create()
} }
val cheveretoClient: CheveretoClient by lazy {
CheveretoClient.create()
}
val mcsmClient: MCSMClient by lazy {
MCSMClient.create()
}
val moduleManager: ModuleManager by lazy { ModuleManager() } val moduleManager: ModuleManager by lazy { ModuleManager() }
@ -67,7 +79,9 @@ object GlobalManager {
"NapCatClient" to { napCatClient.close() }, "NapCatClient" to { napCatClient.close() },
"McSrvStatusClient" to { mcSrvStatusClient.close() }, "McSrvStatusClient" to { mcSrvStatusClient.close() },
"BlessingSkinClient" to { blessingSkinClient.close() }, "BlessingSkinClient" to { blessingSkinClient.close() },
"Hikari 数据源" to { dataSource.close() } "Hikari 数据源" to { dataSource.close() },
"CheveretoClient" to { cheveretoClient.close() },
"McsmClient" to { mcsmClient.close() },
) )
resources.forEach { (name, closer) -> resources.forEach { (name, closer) ->
@ -94,4 +108,5 @@ object GlobalManager {
isRunning.set(false) isRunning.set(false)
} }
} }

View File

@ -1,27 +1,24 @@
package top.r3944realms.ltdmanager.blessingskin package top.r3944realms.ltdmanager.blessingskin
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.* import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.* import io.ktor.client.plugins.*
import io.ktor.client.request.* import kotlinx.coroutines.CompletableDeferred
import io.ktor.http.* import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore 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.request.BlessingSkinRequest
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult import top.r3944realms.ltdmanager.core.client.IClient
import top.r3944realms.ltdmanager.core.client.response.IFailedResponse
import top.r3944realms.ltdmanager.core.client.response.ResponseResult
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader 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.* import java.util.*
class BlessingSkinClient private constructor() : AutoCloseable { class BlessingSkinClient private constructor() : IClient<BlessingSkinRequest, BlessingSkinQueueItem, BlessingSkinResponse, FailedBlessingSkinResponse> {
private val client = HttpClient(CIO) { private val client = HttpClient(CIO) {
expectSuccess = false expectSuccess = false
@ -40,170 +37,40 @@ class BlessingSkinClient private constructor() : AutoCloseable {
// 限流控制 // 限流控制
private val semaphore = Semaphore(5) private val semaphore = Semaphore(5)
private val requestMutex = Mutex() private val requestMutex = Mutex()
private val requestQueue = PriorityQueue<BlessingSkinQueueItem<BlessingSkinResponse, FailedBlessingSkinResponse>>(compareBy { it.priority }) private val requestQueue = PriorityQueue<BlessingSkinQueueItem>(compareBy { it.priority })
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init { init {
startQueueProcessor() init()
} }
/** override fun getBaseUrl(): String = blessingSkinServerConfig.url!!
* 提交请求
*/ override fun getType(): String = "BlessingSkinClient"
suspend fun <T : BlessingSkinResponse, F : FailedBlessingSkinResponse> submitRequest(
request: BlessingSkinRequest<T, F>, override fun getClient(): HttpClient = client
priority: Int = 5,
maxRetries: Int = 3 override fun getSemaphore(): Semaphore = semaphore
): ResponseResult<T, F> {
val deferred = CompletableDeferred<ResponseResult<T, F>>() override fun getRequestMutex(): Mutex = requestMutex
requestMutex.withLock {
requestQueue.add(BlessingSkinQueueItem(request, deferred, priority, maxRetries, true)) override fun getResponseQueue(): PriorityQueue<BlessingSkinQueueItem> = requestQueue
}
return deferred.await() override fun getScope(): CoroutineScope = scope
override fun createFailureResponse(exception: Exception?): IFailedResponse {
return FailedBlessingSkinResponse.Default(exception?.stackTraceToString()?:"ERROR")
} }
/** override fun addToQueue(
* 启动队列处理器 request: BlessingSkinRequest,
*/ deferredC: CompletableDeferred<ResponseResult<BlessingSkinResponse, FailedBlessingSkinResponse>>,
private fun startQueueProcessor() { priority: Int,
scope.launch { maxRetries: Int
while (isActive) { ): BlessingSkinQueueItem {
val item = requestMutex.withLock { val element = BlessingSkinQueueItem(request, deferredC, priority, maxRetries, false)
requestQueue.poll() requestQueue.add(element)
} return element
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 { companion object {

View File

@ -4,13 +4,14 @@ import kotlinx.coroutines.CompletableDeferred
import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
import top.r3944realms.ltdmanager.core.client.QueueItem
data class BlessingSkinQueueItem<out T:BlessingSkinResponse,out F:FailedBlessingSkinResponse>( data class BlessingSkinQueueItem (
val request: BlessingSkinRequest<T,F>, val request0: BlessingSkinRequest,
val deferred: CompletableDeferred<*>, val deferred0: CompletableDeferred<*>,
var retries: Int, val priority0: Int,
val priority: Int, var retries0: Int,
val expectsResponse: Boolean // true 表示返回 BlessingSkinResponse, false 表示 Unit val expectsResponse0: Boolean
) : Comparable<BlessingSkinQueueItem<@UnsafeVariance T, @UnsafeVariance F>> { ) : QueueItem<BlessingSkinRequest, BlessingSkinResponse, FailedBlessingSkinResponse> (
override fun compareTo(other: BlessingSkinQueueItem<@UnsafeVariance T, @UnsafeVariance F>): Int = priority.compareTo(other.priority) request0, deferred0, retries0, priority0, expectsResponse0
} )

View File

@ -0,0 +1,13 @@
package top.r3944realms.ltdmanager.blessingskin.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class InvitationCode(
val code: String,
@SerialName("generated_at")
val generatedAt: String,
@SerialName("expires_at")
val expiresAt: String
)

View File

@ -1,79 +1,13 @@
package top.r3944realms.ltdmanager.blessingskin.request package top.r3944realms.ltdmanager.blessingskin.request
import io.ktor.http.*
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult import top.r3944realms.ltdmanager.core.client.request.IRequest
@Serializable @Serializable
abstract class BlessingSkinRequest<out T : BlessingSkinResponse, out F : FailedBlessingSkinResponse>( abstract class BlessingSkinRequest(
@Transient @Transient
open val createTime: Long = System.currentTimeMillis() override val createTime: Long = System.currentTimeMillis()
) { ): IRequest<BlessingSkinResponse, FailedBlessingSkinResponse>
/**
* 转换为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

@ -6,8 +6,8 @@ import kotlinx.serialization.Transient
import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse 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.blessingskin.response.invitecode.InvitationCodeGenerationResponse
import top.r3944realms.ltdmanager.core.client.response.ResponseResult
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import java.util.* import java.util.*
@ -17,9 +17,7 @@ class GenerateInvitationCodeRequest(
val token: String? = null, val token: String? = null,
@Transient @Transient
val amount: Int? = 1, val amount: Int? = 1,
@Transient ) : BlessingSkinRequest() {
override val createTime: Long = System.currentTimeMillis()
) : BlessingSkinRequest<InvitationCodeGenerationResponse, FailedBlessingSkinResponse.Default>() {
override fun toJSON(): String { override fun toJSON(): String {
// 对于GET请求参数在URL中body可以为空 // 对于GET请求参数在URL中body可以为空
@ -66,7 +64,7 @@ class GenerateInvitationCodeRequest(
} catch (e: Exception) { } catch (e: Exception) {
ResponseResult.Failure( ResponseResult.Failure(
FailedBlessingSkinResponse.Default( FailedBlessingSkinResponse.Default(
failedResult = "解析响应失败: ${e.message}" failedMessage = "解析响应失败: ${e.message}"
) )
) )
} }

View File

@ -7,14 +7,15 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.polymorphic
import top.r3944realms.ltdmanager.blessingskin.response.invitecode.InvitationCodeGenerationResponse import top.r3944realms.ltdmanager.blessingskin.response.invitecode.InvitationCodeGenerationResponse
import top.r3944realms.ltdmanager.core.client.response.IResponse
@Serializable @Serializable
abstract class BlessingSkinResponse ( abstract class BlessingSkinResponse (
@Transient @Transient
open val httpStatusCode: HttpStatusCode = HttpStatusCode.OK, override val httpStatusCode: HttpStatusCode = HttpStatusCode.OK,
@Transient @Transient
open val createTime: Long = System.currentTimeMillis() override val createTime: Long = System.currentTimeMillis()
) { ) : IResponse {
companion object { companion object {
// 通用的反序列化方法 // 通用的反序列化方法
inline fun <reified T : BlessingSkinResponse> decode(jsonString: String): T { inline fun <reified T : BlessingSkinResponse> decode(jsonString: String): T {

View File

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

View File

@ -1,7 +1,7 @@
package top.r3944realms.ltdmanager.blessingskin.response.invitecode package top.r3944realms.ltdmanager.blessingskin.response.invitecode
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import top.r3944realms.ltdmanager.blessingskin.data.InvitationCode
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
@Serializable @Serializable
data class InvitationCodeGenerationResponse( data class InvitationCodeGenerationResponse(
@ -10,12 +10,4 @@ data class InvitationCodeGenerationResponse(
val data: List<InvitationCode>? = null val data: List<InvitationCode>? = null
) : BlessingSkinResponse() { ) : 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,281 @@
package top.r3944realms.ltdmanager.chevereto
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.withPermit
import top.r3944realms.ltdmanager.chevereto.data.CheveretoSource
import top.r3944realms.ltdmanager.chevereto.request.CheveretoRequest
import top.r3944realms.ltdmanager.chevereto.request.v1.CheveretoUploadRequest
import top.r3944realms.ltdmanager.chevereto.response.CheveretoResponse
import top.r3944realms.ltdmanager.chevereto.response.FailedCheveretoResponse
import top.r3944realms.ltdmanager.chevereto.response.v1.CheveretoUploadResponse
import top.r3944realms.ltdmanager.core.client.IClient
import top.r3944realms.ltdmanager.core.client.response.IFailedResponse
import top.r3944realms.ltdmanager.core.client.response.IResponse
import top.r3944realms.ltdmanager.core.client.response.ResponseResult
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import top.r3944realms.ltdmanager.utils.Environment
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.io.ByteArrayInputStream
import java.io.File
import java.util.*
class CheveretoClient private constructor() :
IClient<CheveretoRequest, CheveretoQueueItem, CheveretoResponse, FailedCheveretoResponse> {
private val client = HttpClient(CIO) {
expectSuccess = false
// 安装 HttpTimeout 插件
install(HttpTimeout) {
// 默认超时配置,会被具体请求的配置覆盖
requestTimeoutMillis = 30000
connectTimeoutMillis = 10000
socketTimeoutMillis = 15000
}
}
private val imgTuConfig = YamlConfigLoader.loadTuImgConfig()
private val baseUrl = imgTuConfig.url!!.removeSuffix("/")
private val apiKey = imgTuConfig.decryptedPassword!!
private val semaphore = Semaphore(3)
private val queue = PriorityQueue<CheveretoQueueItem>()
private val queueMutex = Mutex()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
init()
}
override fun getType(): String = "CheveretoClient"
override fun getClient(): HttpClient = client
override fun getSemaphore(): Semaphore = semaphore
override fun getRequestMutex(): Mutex = queueMutex
override fun getResponseQueue(): PriorityQueue<CheveretoQueueItem> = queue
override fun getScope(): CoroutineScope = scope
override fun getBaseUrl(): String = baseUrl
override fun createFailureResponse(exception: Exception?): FailedCheveretoResponse =
FailedCheveretoResponse.Default(
httpStatusCode = HttpStatusCode.InternalServerError,
failedMessage = exception?.message ?: "Unknown error"
)
override fun addToQueue(
request: CheveretoRequest,
deferredC: CompletableDeferred<ResponseResult<CheveretoResponse, FailedCheveretoResponse>>,
priority: Int,
maxRetries: Int
): CheveretoQueueItem {
val item = CheveretoQueueItem(request, deferredC, maxRetries, priority, true)
queue.add(item)
return item
}
override suspend fun processQueueItem(item: CheveretoQueueItem) {
getSemaphore().withPermit {
val request = item.request
val deferred = item.deferred
val maxRetries = item.retries
var attempt = 0
var lastError: Exception?
while (attempt < maxRetries) {
try {
val fullUrl = buildFullUrlWithQueryParams(request)
if (!Environment.isProduction()) {
LoggerUtil.logger.debug("发送请求到: $fullUrl")
LoggerUtil.logger.debug("请求方法: {}", request.method())
}
val response = getClient().request(fullUrl) {
method = request.method()
// 设置请求头
headers {
request.headers().invoke(this)
header("X-API-Key", apiKey)
}
// 对于非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<IResponse, IFailedResponse>>).complete(result)
return
} catch (e: Exception) {
lastError = e
attempt++
if (!request.shouldRetryOnFailure() || attempt >= maxRetries) {
break
}
LoggerUtil.logger.warn("${getType()} 请求失败 (尝试 $attempt/$maxRetries): ${e.message}")
delay((attempt * 1000L)) // 指数退避
}
// 所有重试都失败或不应重试
val errorResponse = createFailureResponse(lastError)
@Suppress("UNCHECKED_CAST")
(deferred as CompletableDeferred<ResponseResult<IResponse, IFailedResponse>>).complete(
ResponseResult.Failure(errorResponse)
)
}
}
}
/**
* 上传 File
*/
suspend fun uploadFile(
file: File,
title: String? = null,
description: String? = null,
tags: String? = null,
albumId: String? = null,
categoryId: String? = null,
width: Int? = null,
expiration: String? = null,
nsfw: Int? = null,
format: String = "json",
useFileDate: Int? = null,
priority: Int = 5,
maxRetries: Int = 3
): CheveretoResponse {
upload(CheveretoUploadRequest(
source = CheveretoSource.ByteArraySource(file.readBytes(), file.name),
format = format,
title = title,
description = description,
tags = tags,
albumId = albumId,
categoryId = categoryId,
width = width,
expiration = expiration,
nsfw = nsfw,
useFileDate = useFileDate
), priority, maxRetries).getRetResponse()
throw Exception("Never Reach")
}
/**
* 上传 ByteArrayInputStream
*/
suspend fun uploadStream(
inputStream: ByteArrayInputStream,
fileName: String,
title: String? = null,
description: String? = null,
tags: String? = null,
albumId: String? = null,
categoryId: String? = null,
width: Int? = null,
expiration: String? = null,
nsfw: Int? = null,
format: String = "json",
useFileDate: Int? = null,
priority: Int = 5,
maxRetries: Int = 3
): CheveretoResponse {
upload(CheveretoUploadRequest(
source = CheveretoSource.ByteArraySource(inputStream.readBytes(), fileName),
format = format,
title = title,
description = description,
tags = tags,
albumId = albumId,
categoryId = categoryId,
width = width,
expiration = expiration,
nsfw = nsfw,
useFileDate = useFileDate
), priority, maxRetries).getRetResponse()
throw Exception("Never Reach")
}
/**
* 上传网络图片 URL
*/
suspend fun uploadUrl(
url: String,
title: String? = null,
description: String? = null,
tags: String? = null,
albumId: String? = null,
categoryId: String? = null,
width: Int? = null,
expiration: String? = null,
nsfw: Int? = null,
format: String = "json",
useFileDate: Int? = null,
priority: Int = 5,
maxRetries: Int = 3
): CheveretoResponse {
upload(CheveretoUploadRequest(
source = CheveretoSource.UrlSource(url),
format = format,
title = title,
description = description,
tags = tags,
albumId = albumId,
categoryId = categoryId,
width = width,
expiration = expiration,
nsfw = nsfw,
useFileDate = useFileDate
), priority, maxRetries).getRetResponse()
throw Exception("Never Reach")
}
suspend fun upload(
request: CheveretoUploadRequest, priority: Int, maxRetries: Int
): ResponseResult<CheveretoUploadResponse, FailedCheveretoResponse> {
return try {
@Suppress("UNCHECKED_CAST")
submitRequest(request, priority, maxRetries) as ResponseResult<CheveretoUploadResponse, FailedCheveretoResponse>
} catch (e: Exception) {
ResponseResult.Failure(
FailedCheveretoResponse.Default(
httpStatusCode = HttpStatusCode.InternalServerError,
failedMessage = "Byte array upload failed: ${e.message}"
)
)
}
}
companion object {
fun create(): CheveretoClient = CheveretoClient()
}
}

View File

@ -0,0 +1,17 @@
package top.r3944realms.ltdmanager.chevereto
import kotlinx.coroutines.CompletableDeferred
import top.r3944realms.ltdmanager.chevereto.request.CheveretoRequest
import top.r3944realms.ltdmanager.chevereto.response.CheveretoResponse
import top.r3944realms.ltdmanager.chevereto.response.FailedCheveretoResponse
import top.r3944realms.ltdmanager.core.client.QueueItem
data class CheveretoQueueItem(
val request0: CheveretoRequest,
val deferred0: CompletableDeferred<*>,
val priority0: Int,
var retries0: Int,
val expectsResponse0: Boolean
) : QueueItem<CheveretoRequest, CheveretoResponse, FailedCheveretoResponse>(
request0, deferred0, retries0, priority0, expectsResponse0
)

View File

@ -0,0 +1,108 @@
package top.r3944realms.ltdmanager.chevereto.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class CheveretoImage(
val name: String,
val extension: String,
val size: Long,
val width: Int,
val height: Int,
val date: String,
@SerialName("date_gmt")
val dateGmt: String,
val title: String,
val tags: List<String>? = emptyList(),
val description: String? = null,
val nsfw: Int,
@SerialName("storage_mode")
val storageMode: String,
val md5: String,
@SerialName("source_md5")
val sourceMd5: String? = null,
@SerialName("original_filename")
val originalFilename: String,
@SerialName("original_exifdata")
val originalExifdata: String? = null,
val views: Int,
@SerialName("category_id")
val categoryId: String? = null,
val chain: Int,
@SerialName("thumb_size")
val thumbSize: Int,
@SerialName("medium_size")
val mediumSize: Int,
@SerialName("frame_size")
val frameSize: Int? = null,
@SerialName("expiration_date_gmt")
val expirationDateGmt: String? = null,
val likes: Int,
@SerialName("is_animated")
val isAnimated: Int,
@SerialName("is_approved")
val isApproved: Int,
@SerialName("is_360")
val is360: Int,
val duration: Int? = null,
val type: String? = null,
@SerialName("tags_string")
val tagsString: String? = null,
val file: File? = null,
@SerialName("id_encoded")
val idEncoded: String,
val filename: String,
val mime: String,
val url: String,
val ratio: Double? = null,
@SerialName("size_formatted")
val sizeFormatted: String,
val frame: ImageThumb? = null,
val image: ImageFile,
val thumb: ImageThumb,
@SerialName("url_frame")
val urlFrame: String? = null,
val medium: Medium? = null,
@SerialName("duration_time")
val durationTime: String? = null,
@SerialName("url_viewer")
val urlViewer: String,
@SerialName("path_viewer")
val pathViewer: String? = null,
@SerialName("url_short")
val urlShort: String,
@SerialName("display_url")
val displayUrl: String,
@SerialName("display_width")
val displayWidth: Int,
@SerialName("display_height")
val displayHeight: Int,
@SerialName("views_label")
val viewsLabel: String,
@SerialName("likes_label")
val likesLabel: String,
@SerialName("how_long_ago")
val howLongAgo: String,
@SerialName("date_fixed_peer")
val dateFixedPeer: String,
@SerialName("title_truncated")
val titleTruncated: String,
@SerialName("title_truncated_html")
val titleTruncatedHtml: String,
@SerialName("is_use_loader")
val isUseLoader: Boolean,
@SerialName("display_title")
val displayTitle: String? = null,
@SerialName("delete_url")
val deleteUrl: String
)

View File

@ -0,0 +1,11 @@
package top.r3944realms.ltdmanager.chevereto.data
import kotlinx.serialization.Serializable
@Serializable
sealed class CheveretoSource {
@Serializable
data class ByteArraySource(val bytes: ByteArray, val fileName: String) : CheveretoSource()
@Serializable
data class UrlSource(val url: String) : CheveretoSource()
}

View File

@ -0,0 +1,13 @@
package top.r3944realms.ltdmanager.chevereto.data
import kotlinx.serialization.Serializable
@Serializable
data class File(
val resource: Resource
) {
@Serializable
data class Resource(
val type: String
)
}

View File

@ -0,0 +1,13 @@
package top.r3944realms.ltdmanager.chevereto.data
import kotlinx.serialization.Serializable
@Serializable
data class ImageFile(
val filename: String,
val name: String,
val mime: String,
val extension: String,
val url: String,
val size: Long
)

View File

@ -0,0 +1,13 @@
package top.r3944realms.ltdmanager.chevereto.data
import kotlinx.serialization.Serializable
@Serializable
data class ImageThumb(
val filename: String,
val name: String,
val mime: String,
val extension: String,
val url: String,
val size: Int
)

View File

@ -0,0 +1,12 @@
package top.r3944realms.ltdmanager.chevereto.data
import kotlinx.serialization.Serializable
@Serializable
data class Medium(
val filename: String? = null,
val name: String? = null,
val mime: String? = null,
val extension: String? = null,
val url: String? = null
)

View File

@ -0,0 +1,9 @@
package top.r3944realms.ltdmanager.chevereto.data
import kotlinx.serialization.Serializable
@Serializable
data class SuccessInfo(
val message : String? = null,
val code: Int? = 200,
)

View File

@ -0,0 +1,13 @@
package top.r3944realms.ltdmanager.chevereto.request
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import top.r3944realms.ltdmanager.chevereto.response.CheveretoResponse
import top.r3944realms.ltdmanager.chevereto.response.FailedCheveretoResponse
import top.r3944realms.ltdmanager.core.client.request.IRequest
@Serializable
abstract class CheveretoRequest(
@Transient
override val createTime: Long = System.currentTimeMillis()
) : IRequest<CheveretoResponse, FailedCheveretoResponse>

View File

@ -0,0 +1,89 @@
package top.r3944realms.ltdmanager.chevereto.request.v1
import io.ktor.http.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import top.r3944realms.ltdmanager.chevereto.data.CheveretoSource
import top.r3944realms.ltdmanager.chevereto.request.CheveretoRequest
import top.r3944realms.ltdmanager.chevereto.response.FailedCheveretoResponse
import top.r3944realms.ltdmanager.chevereto.response.v1.CheveretoUploadResponse
import top.r3944realms.ltdmanager.core.client.response.ResponseResult
@Serializable
data class CheveretoUploadRequest(
private val source: CheveretoSource,
private val title: String? = null,
private val description: String? = null,
private val tags: String? = null,
@SerialName("album_id")
private val albumId: String? = null,
@SerialName("category_id")
private val categoryId: String? = null,
private val width: Int? = null,
private val expiration: String? = null,
private val nsfw: Int? = null,
private val format: String = "json",
@SerialName("use_file_date")
private val useFileDate: Int? = null
) : CheveretoRequest() {
override fun path(): String = "api/1/upload"
override fun method(): HttpMethod = HttpMethod.Post
override fun headers(): HeadersBuilder.() -> Unit = {
append(HttpHeaders.Accept, "application/json")
// 对于文件上传Content-Type 由 Ktor 自动设置
}
override fun bodyParameters(): Map<String, Any> {
val params = mutableMapOf<String, Any>()
title?.let { params["title"] = it }
description?.let { params["description"] = it }
tags?.let { params["tags"] = it }
albumId?.let { params["album_id"] = it }
categoryId?.let { params["category_id"] = it }
width?.let { params["width"] = it }
expiration?.let { params["expiration"] = it }
nsfw?.let { params["nsfw"] = it }
params["format"] = format
useFileDate?.let { params["use_file_date"] = it }
return params
}
override fun toJSON(): String = Json.encodeToString(this)
override fun getResponse(
responseJson: String,
httpStatusCode: HttpStatusCode
): ResponseResult<CheveretoUploadResponse, FailedCheveretoResponse> {
return try {
if (httpStatusCode.isSuccess()) {
val successResponse = Json.decodeFromString<CheveretoUploadResponse>(responseJson)
ResponseResult.Success(successResponse)
} else {
ResponseResult.Failure(
FailedCheveretoResponse.Default(
httpStatusCode = HttpStatusCode.InternalServerError,
failedMessage = responseJson.takeIf { it.isNotBlank() }?:"ERROR"
)
)
}
} catch (e: Exception) {
ResponseResult.Failure(
FailedCheveretoResponse.Default(
httpStatusCode = HttpStatusCode.InternalServerError,
failedMessage = "Failed to parse response: ${e.message}. Raw response: $responseJson"
)
)
}
}
override fun expectedResponseType(): String = "CheveretoUploadResponse"
override fun expectedFailureType(): String = "FailedCheveretoResponse"
override fun shouldRetryOnFailure(): Boolean = true
}

View File

@ -0,0 +1,37 @@
package top.r3944realms.ltdmanager.chevereto.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.chevereto.response.v1.CheveretoUploadResponse
import top.r3944realms.ltdmanager.core.client.response.IResponse
@Serializable
abstract class CheveretoResponse (
@Transient
override val httpStatusCode: HttpStatusCode = HttpStatusCode.OK,
@Transient
override val createTime: Long = System.currentTimeMillis()
) : IResponse {
companion object {
// 通用的反序列化方法
inline fun <reified T : CheveretoResponse> decode(jsonString: String): T {
return json.decodeFromString(jsonString)
}
val json: Json by lazy {
Json {
ignoreUnknownKeys = true
serializersModule = SerializersModule {
polymorphic(CheveretoResponse::class) {
subclass(FailedCheveretoResponse.Default::class, FailedCheveretoResponse.Default.serializer())
subclass(CheveretoUploadResponse::class, CheveretoUploadResponse.serializer())
}
}
}
}
}
}

View File

@ -0,0 +1,12 @@
package top.r3944realms.ltdmanager.chevereto.response
import io.ktor.http.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import top.r3944realms.ltdmanager.core.client.response.IFailedResponse
@Serializable
abstract class FailedCheveretoResponse: CheveretoResponse(), IFailedResponse {
@Serializable
class Default(@Transient override val httpStatusCode: HttpStatusCode = HttpStatusCode.OK, @Transient override val failedMessage: String = "未知错误") : FailedCheveretoResponse()
}

View File

@ -0,0 +1,17 @@
package top.r3944realms.ltdmanager.chevereto.response.v1
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import top.r3944realms.ltdmanager.chevereto.data.CheveretoImage
import top.r3944realms.ltdmanager.chevereto.data.SuccessInfo
import top.r3944realms.ltdmanager.chevereto.response.CheveretoResponse
@Serializable
data class CheveretoUploadResponse(
@SerialName("status_code")
val statusCode: Int,
val success: SuccessInfo,
val image: CheveretoImage,
@SerialName("status_txt")
val statusTxt: String
) : CheveretoResponse()

View File

@ -0,0 +1,170 @@
package top.r3944realms.ltdmanager.core.client
import io.ktor.client.*
import io.ktor.client.call.*
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.core.client.request.IRequest
import top.r3944realms.ltdmanager.core.client.response.IFailedResponse
import top.r3944realms.ltdmanager.core.client.response.IResponse
import top.r3944realms.ltdmanager.core.client.response.ResponseResult
import top.r3944realms.ltdmanager.utils.Environment
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.net.URLEncoder
import java.util.*
interface IClient<R: IRequest<T, F>, Q: QueueItem<R, T, F>, T: IResponse, F: IFailedResponse> : AutoCloseable {
fun getType(): String
fun getClient(): HttpClient
fun getSemaphore(): Semaphore
fun getRequestMutex(): Mutex
fun getResponseQueue(): PriorityQueue<Q>
fun getScope(): CoroutineScope
fun getBaseUrl(): String = "http://localhost:1234"
fun createFailureResponse(exception: Exception? ): IFailedResponse
fun init() {
startQueueProcessor()
}
fun startQueueProcessor() {
getScope().launch {
while (isActive) {
val item = getRequestMutex().withLock {
getResponseQueue().poll()
}
if (item == null) {
delay(50)
continue
}
processQueueItem(item)
}
}
}
fun addToQueue(request: R,
deferredC: CompletableDeferred<ResponseResult<T, F>>,
priority: Int = 5,
maxRetries: Int = 3): Q
/**
* 提交请求
*/
suspend fun submitRequest(
request: R,
priority: Int = 5,
maxRetries: Int = 3
): ResponseResult<T, F> {
val deferred = CompletableDeferred<ResponseResult<T, F>>()
getRequestMutex().withLock {
addToQueue(request, deferred, priority, maxRetries)
}
return deferred.await()
}
suspend fun processQueueItem(item: Q) {
getSemaphore().withPermit {
val request = item.request
val deferred = item.deferred
val maxRetries = item.retries
var attempt = 0
var lastError: Exception?
while (attempt < maxRetries) {
try {
val fullUrl = buildFullUrlWithQueryParams(request)
if (!Environment.isProduction()) {
LoggerUtil.logger.debug("发送请求到: $fullUrl")
LoggerUtil.logger.debug("请求方法: {}", request.method())
}
val response = getClient().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<IResponse, IFailedResponse>>).complete(result)
return
} catch (e: Exception) {
lastError = e
attempt++
if (!request.shouldRetryOnFailure() || attempt >= maxRetries) {
break
}
LoggerUtil.logger.warn("${getType()} 请求失败 (尝试 $attempt/$maxRetries): ${e.message}")
delay((attempt * 1000L)) // 指数退避
}
// 所有重试都失败或不应重试
val errorResponse = createFailureResponse(lastError)
@Suppress("UNCHECKED_CAST")
(deferred as CompletableDeferred<ResponseResult<IResponse, IFailedResponse>>).complete(
ResponseResult.Failure(errorResponse)
)
}
}
}
/**
* 构建完整的URL包含查询参数
*/
fun buildFullUrlWithQueryParams(request: R): String {
val baseUrl = getBaseUrl().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响应
*/
fun isHtmlResponse(text: String): Boolean {
return text.contains("<!DOCTYPE html>", ignoreCase = true) ||
text.contains("<html>", ignoreCase = true) ||
text.contains("Redirecting", ignoreCase = true)
}
override fun close() {
getScope().cancel()
runBlocking {
getClient().close()
}
}
}

View File

@ -0,0 +1,26 @@
package top.r3944realms.ltdmanager.core.client
import kotlinx.coroutines.CompletableDeferred
import top.r3944realms.ltdmanager.core.client.request.IRequest
import top.r3944realms.ltdmanager.core.client.response.IFailedResponse
import top.r3944realms.ltdmanager.core.client.response.IResponse
open class QueueItem<R: IRequest<T, F>, T:IResponse, F:IFailedResponse>(
val request: R,
val deferred: CompletableDeferred<*>,
val retries: Int,
val priority: Int,
val expectsResponse: Boolean
) : Comparable<QueueItem<R, T, F>> {
// fun getRequest(): R = request
// fun getDeferred(): CompletableDeferred<*> = deferred
// fun getRetries(): Int = retries
// fun getPriority(): Int = priority
/**
* @return true 表示返回 BlessingSkinResponse, false 表示 Unit
*/
fun expectsResponse(): Boolean = expectsResponse
override fun compareTo(other: QueueItem<R, @UnsafeVariance T, @UnsafeVariance F>): Int = priority.compareTo(other.priority)
}

View File

@ -0,0 +1,76 @@
package top.r3944realms.ltdmanager.core.client.request
import io.ktor.http.*
import top.r3944realms.ltdmanager.core.client.response.IFailedResponse
import top.r3944realms.ltdmanager.core.client.response.IResponse
import top.r3944realms.ltdmanager.core.client.response.ResponseResult
interface IRequest<out T: IResponse, out F: IFailedResponse> {
// 只使用属性
val createTime: Long
/**
* 转换为JSON字符串
*/
fun toJSON(): String
/**
* 获取API路径不包含基础URL
* 例如: "invitation-codes/generate"
*/
fun path(): String
/**
* 获取HTTP方法默认为GET因为大多数API使用GET+查询参数
*/
fun method(): HttpMethod = HttpMethod.Get
/**
* 自定义请求头
*/
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")
*/
fun queryParameters(): Map<String, String> = emptyMap()
/**
* 获取请求体参数用于POST请求的JSON body
* 例如: mapOf("token" to "abc123", "amount" to 1)
*/
fun bodyParameters(): Map<String, Any> = emptyMap()
/**
* 获取请求体内容类型默认为Application.Json
*/
fun contentType(): ContentType = ContentType.Application.Json
/**
* 解析响应JSON字符串
* @param responseJson 响应JSON字符串
* @param httpStatusCode HTTP状态码
*/
fun getResponse(responseJson: String, httpStatusCode: HttpStatusCode): ResponseResult<T, F>
/**
* 获取预期的成功响应类型名称用于日志和调试
*/
fun expectedResponseType(): String
/**
* 获取预期的失败响应类型名称用于日志和调试
*/
fun expectedFailureType(): String
/**
* 是否需要在失败时重试默认重试
*/
fun shouldRetryOnFailure(): Boolean = true
}

View File

@ -0,0 +1,7 @@
package top.r3944realms.ltdmanager.core.client.response
interface IFailedResponse : IResponse {
val failedMessage: String
val thrownException: Exception
get() = Exception(failedMessage)
}

View File

@ -0,0 +1,8 @@
package top.r3944realms.ltdmanager.core.client.response
import io.ktor.http.*
interface IResponse {
val httpStatusCode: HttpStatusCode
val createTime: Long
}

View File

@ -0,0 +1,47 @@
package top.r3944realms.ltdmanager.core.client.response
sealed class ResponseResult<out T: IResponse, out F: IFailedResponse> {
data class Success<T : IResponse>(val response: T) : ResponseResult<T, Nothing>()
data class Failure<F : IFailedResponse>(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
}
fun getRetResponse(): T {
if (this is Success) {
return response
}
else if (this is Failure) {
@Suppress("UNCHECKED_CAST")
return failure as T
}
throw Exception("Never Reach")
}
}

View File

@ -38,7 +38,7 @@ data class BlessingSkinServerConfig(
try { try {
encryptedToken = "ENC(${CryptoUtil.encrypt(encryptedToken!!)})" encryptedToken = "ENC(${CryptoUtil.encrypt(encryptedToken!!)})"
YamlUpdater.updateYaml( YamlUpdater.updateYaml(
YamlConfigLoader.configFilePath.toString(), YamlConfigLoader.appConfigFilePath.toString(),
"blessing-skin-server.invitation-api.encrypted-token", "blessing-skin-server.invitation-api.encrypted-token",
encryptedToken!! encryptedToken!!
) )

View File

@ -37,7 +37,7 @@ data class DatabaseConfig(
try { try {
encryptedPassword = "ENC(${CryptoUtil.encrypt(encryptedPassword!!)})" encryptedPassword = "ENC(${CryptoUtil.encrypt(encryptedPassword!!)})"
YamlUpdater.updateYaml( YamlUpdater.updateYaml(
YamlConfigLoader.configFilePath.toString(), YamlConfigLoader.appConfigFilePath.toString(),
"database.encrypted-password", "database.encrypted-password",
this.encryptedPassword!! this.encryptedPassword!!
) )

View File

@ -0,0 +1,130 @@
package top.r3944realms.ltdmanager.core.config
import top.r3944realms.ltdmanager.utils.CryptoUtil
import top.r3944realms.ltdmanager.utils.YamlUpdater
data class DgLabConfig(
var wsServer: WsServerConfig = WsServerConfig(),
var dgLabClient: DgLabClientConfig = DgLabClientConfig(),
var pulseData: PulseDataConfig = PulseDataConfig(),
var commandText: CommandTextConfig = CommandTextConfig(),
var replyText: ReplyTextConfig = ReplyTextConfig(),
var debug: DebugConfig = DebugConfig()
) {
data class WsServerConfig(
var localServerUrl: String = "0.0.0.0",
var localServerPort: Int = 4567,
var localServerPublishUrl: String = "ws://127.0.0.1:4567",
var localServerSecure: Boolean = false,
var localServerSslCert: String = "",
var localServerSslKey: String = "",
var encryptedLocalServerSslPassword: String? = null
) {
val decryptedLocalServerSslPassword: String?
get() {
if (encryptedLocalServerSslPassword == null) return null
if (!isEncrypted()) return encryptedLocalServerSslPassword
return try {
val cipherText = encryptedLocalServerSslPassword!!.substring(4, encryptedLocalServerSslPassword!!.length - 1)
CryptoUtil.decrypt(cipherText)
} catch (e: Exception) {
throw IllegalStateException("localServerSslPassword 解密失败", e)
}
}
fun encryptPassword() {
if (encryptedLocalServerSslPassword == null || isEncrypted()) return
try {
encryptedLocalServerSslPassword = "ENC(${CryptoUtil.encrypt(encryptedLocalServerSslPassword!!)})"
YamlUpdater.updateYaml(
YamlConfigLoader.appConfigFilePath.toString(),
"dg-lab.ws-server.encrypted-local-server-ssl-password",
encryptedLocalServerSslPassword!!
)
} catch (e: Exception) {
throw IllegalStateException("SSL 密码加密失败", e)
}
}
private fun isEncrypted(): Boolean {
return encryptedLocalServerSslPassword != null &&
encryptedLocalServerSslPassword!!.startsWith("ENC(") &&
encryptedLocalServerSslPassword!!.endsWith(")")
}
//TODO: 添加有效性检测
fun validate() {
require(localServerUrl.isNotBlank()) { "localServerUrl 未配置" }
require(localServerPort > 0) { "localServerPort 必须大于 0" }
require(localServerPublishUrl.isNotBlank()) { "localServerPublishUrl 未配置" }
if (localServerSecure) {
require(localServerSslCert.isNotBlank()) { "启用 SSL 时必须配置 localServerSslCert" }
require(localServerSslKey.isNotBlank()) { "启用 SSL 时必须配置 localServerSslKey" }
}
}
}
data class DgLabClientConfig(
var bindTimeout: Double = 90.0,
var registerTimeout: Double = 30.0
)
data class PulseDataConfig(
var customPulseData: String = "data/dg-lab-play/customPulseData.json",
var durationPerPost: Double = 8.0,
var postInterval: Double = 1.0,
var sleepAfterClear: Double = 0.5
) {
fun validate(maxLength: Double) {
require(durationPerPost <= maxLength * 0.1) { "PulseDataConfig.durationPerPost 超出最大时长" }
}
}
data class CommandTextConfig(
var appendPulse: String = "增加波形",
var currentPulse: String = "当前波形",
var currentStrength: String = "当前强度",
var decreaseStrength: String = "减小强度",
var dgLabDeviceJoin: String = "绑定郊狼",
var exitGame: String = "退出游戏",
var increaseStrength: String = "加大强度",
var randomPulse: String = "随机波形",
var randomStrength: String = "随机强度",
var resetPulse: String = "重置波形",
var showPlayers: String = "当前玩家",
var showPulses: String = "可用波形",
var usage: String = "郊狼玩法"
)
data class ReplyTextConfig(
var bindTimeout: String = "绑定超时",
var currentPlayers: String = "当前玩家:",
var currentPulse: String = "当前波形循环为:【{}】",
var currentStrength: String = "A通道{0}/{1} B通道{2}/{3}",
var failedToCreateClient: String = "创建 DG-Lab 控制终端失败",
var failedToFetchStrengthInfo: String = "获取通道强度状态失败",
var failedToFetchStrengthLimit: String = "获取通道强度上限失败,控制失败",
var gameExited: String = "已退出游戏",
var invalidPulseParam: String = "波形参数错误,控制失败",
var invalidStrengthParam: String = "强度参数错误,控制失败",
var invalidTarget: String = "目标玩家不存在或郊狼 App 未绑定",
var noAvailablePulse: String = "无可用波形",
var noPlayer: String = "当前没有已连接的玩家,你可以绑定试试~",
var notBindYet: String = "你目前没有绑定 DG-Lab App",
var pleaseAtTarget: String = "使用命令的同时请 @ 想要控制的玩家",
var pleaseScanQrcode: String = "请用 DG-Lab App 扫描二维码以连接",
var pleaseSetPulseFirst: String = "请先设置郊狼波形:{}",
var pulsesEmpty: String = "当前波形循环为空",
var successfullyBind: String = "绑定成功,可以开始色色了!",
var successfullyDecreased: String = "郊狼强度减小了 {}%",
var successfullyIncreased: String = "郊狼强度加强了 {}%",
var successfullySetPulse: String = "郊狼波形成功设置为【{}】!",
var successfullySetToStrength: String = "郊狼强度成功设置为 {}%"
)
data class DebugConfig(
var enableDebug: Boolean = false,
var ideHost: String = "127.0.0.1",
var idePort: Int = 5678
)
}

View File

@ -36,7 +36,7 @@ data class HttpConfig(
try { try {
encryptedToken = "ENC(${CryptoUtil.encrypt(encryptedToken!!)})" encryptedToken = "ENC(${CryptoUtil.encrypt(encryptedToken!!)})"
YamlUpdater.updateYaml( YamlUpdater.updateYaml(
YamlConfigLoader.configFilePath.toString(), YamlConfigLoader.appConfigFilePath.toString(),
"http.encrypted-token", "http.encrypted-token",
this.encryptedToken!! this.encryptedToken!!
) )

View File

@ -0,0 +1,59 @@
package top.r3944realms.ltdmanager.core.config
import top.r3944realms.ltdmanager.utils.CryptoUtil
import top.r3944realms.ltdmanager.utils.YamlUpdater
data class ImgTuConfig(
var url: String? = null,
var encryptedPassword: String? = null
) {
/**
* 获取解密后的Password如果未加密返回原值
*/
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("Password解密失败", e)
}
}
/**
* 加密密码如果未加密并返回是否成功加密
*/
fun encryptPassword() {
if (encryptedPassword == null || isEncrypted()) {
return
}
try {
encryptedPassword = "ENC(${CryptoUtil.encrypt(encryptedPassword!!)})"
YamlUpdater.updateYaml(
YamlConfigLoader.appConfigFilePath.toString(),
"img-tu.encrypted-password",
this.encryptedPassword!!
)
} catch (e: Exception) {
throw IllegalStateException("密码加密失败", e)
}
}
/**
* 检查Password是否已加密
*/
private fun isEncrypted(): Boolean {
return encryptedPassword != null &&
encryptedPassword!!.startsWith("ENC(") &&
encryptedPassword!!.endsWith(")")
}
override fun toString(): String {
return "ImgTuConfig(url=$url, Password=***)"
}
}

View File

@ -34,7 +34,7 @@ data class MailConfig(
try { try {
encryptedPassword = "ENC(${CryptoUtil.encrypt(encryptedPassword!!)})" encryptedPassword = "ENC(${CryptoUtil.encrypt(encryptedPassword!!)})"
YamlUpdater.updateYaml( YamlUpdater.updateYaml(
YamlConfigLoader.configFilePath.toString(), YamlConfigLoader.appConfigFilePath.toString(),
"mail.encrypted-password", "mail.encrypted-password",
this.encryptedPassword!! this.encryptedPassword!!
) )

View File

@ -0,0 +1,54 @@
package top.r3944realms.ltdmanager.core.config
import top.r3944realms.ltdmanager.utils.CryptoUtil
import top.r3944realms.ltdmanager.utils.YamlUpdater
data class McsmConfig(
var url: String ?= null,
var encryptedApiKey: String ?= null,
var instanceID: String ?= null,
) {
val decryptedApiKey: String?
get() {
if (encryptedApiKey == null) return null
if (!isEncrypted()) return encryptedApiKey
try {
val cipherText = encryptedApiKey!!.substring(4, encryptedApiKey!!.length - 1)
return CryptoUtil.decrypt(cipherText)
} catch (e: Exception) {
throw IllegalStateException("API解密失败", e)
}
}
/**
* 加密密码如果未加密并写回配置文件
*/
fun encryptApi() {
if (encryptedApiKey == null || isEncrypted()) {
return
}
try {
encryptedApiKey = "ENC(${CryptoUtil.encrypt(encryptedApiKey!!)})"
YamlUpdater.updateYaml(
YamlConfigLoader.appConfigFilePath.toString(),
"mcsm.encrypted-api-key",
this.encryptedApiKey!!
)
} catch (e: Exception) {
throw IllegalStateException("API加密失败", e)
}
}
/**
* 检查密码是否已加密
*/
private fun isEncrypted(): Boolean {
return encryptedApiKey != null &&
encryptedApiKey!!.startsWith("ENC(") &&
encryptedApiKey!!.endsWith(")")
}
override fun toString(): String {
return "McsmConfig(url=$url, api-key=***)"
}
}

View File

@ -0,0 +1,245 @@
package top.r3944realms.ltdmanager.core.config
import top.r3944realms.ltdmanager.module.Modules
import top.r3944realms.ltdmanager.module.exception.ConfigError
data class ModuleConfig(
var modules: List<Module>? = emptyList()
) {
data class Module(
var name: String = "default",
var type: ModuleType = ModuleType.HELP_MODULE,
var enabled: Boolean = true,
var dependencies: List<Dependency>? = emptyList(),
var config: Map<String, Any> = emptyMap()
) {
data class Dependency(
var name: String = "unknown",
var type: ModuleType = ModuleType.UNKNOWN_MODULE,
) {
fun getDepName() :String = "${type.modName}-#$name"
}
fun findDependency(type: ModuleType): Dependency? {
return dependencies?.find { it.type == type }
}
inline fun <reified T> typedList(paramName: String): List<T> {
val list = anyList(paramName)
return list.map { element ->
when (T::class) {
String::class -> element.toString() as T
Int::class -> convertToInt(element, "$paramName.list.element") as T
Long::class -> convertToLong(element, "$paramName.list.element") as T
Boolean::class -> convertToBoolean(element, "$paramName.list.element") as T
else -> {
if (element is T) element
else throw ConfigError(
ConfigError.Type.NOT_EXPECTED_VALUE,
name,
"$paramName.list",
T::class.simpleName ?: T::class.java.simpleName,
element::class.simpleName ?: element::class.java.simpleName
)
}
}
}
}
// 特定类型的 List 方法
fun <T> list(paramName: String): List<T> = get<List<T>>(paramName)
// 特定类型的 Map 方法
fun <K, V> map(paramName: String): Map<K, V> = get<Map<K, V>>(paramName)
// 泛型 List 获取(返回 List<Any>
fun anyList(paramName: String): List<Any> = get<List<Any>>(paramName)
// 泛型 Map 获取(返回 Map<String, Any>
fun anyMap(paramName: String): Map<String, Any> = get<Map<String, Any>>(paramName)
// String List 的便捷方法
fun stringList(paramName: String): List<String> {
val list = anyList(paramName)
return list.map { it.toString() }
}
// Int List 的便捷方法
fun intList(paramName: String): List<Int> {
val list = anyList(paramName)
return list.map { value ->
when (value) {
is Int -> value
is Number -> value.toInt()
is String -> value.toIntOrNull()
?: throw ConfigError(
ConfigError.Type.NOT_EXPECTED_VALUE,
name,
paramName,
"List<Int>",
"元素类型: ${value::class.simpleName}"
)
else -> throw ConfigError(
ConfigError.Type.NOT_EXPECTED_VALUE,
name,
paramName,
"List<Int>",
"元素类型: ${value::class.simpleName}"
)
}
}
}
// 获取特定类型的 Map
inline fun <reified V> typedMap(paramName: String): Map<String, V> {
val map = anyMap(paramName)
return map.mapValues { (key, value) ->
when (V::class) {
String::class -> value.toString() as V
Int::class -> convertToInt(value, "$paramName.$key") as V
Long::class -> convertToLong(value, "$paramName.$key") as V
Boolean::class -> convertToBoolean(value, "$paramName.$key") as V
else -> {
if (value is V) value
else throw ConfigError(
ConfigError.Type.NOT_EXPECTED_VALUE,
name,
"$paramName.$key",
V::class.simpleName ?: V::class.java.simpleName,
value::class.simpleName ?: value::class.java.simpleName
)
}
}
}
}
enum class ModuleType(val modName: String) {
GROUP_MESSAGE_POLLING_MODULE(Modules.GROUP_MESSAGE_POLLING),
GROUP_REQUEST_HANDLER_MODULE(Modules.GROUP_REQUEST_HANDLER),
MAIL_MODULE(Modules.MAIL),
BAN_MODULE(Modules.BAN),
DG_LAB_MODULE(Modules.DG_LAB),
INVITE_MODULE(Modules.INVITATION_CODE),
MC_SERVER_STATUS_MODULE(Modules.MC_SERVER_STATUS),
MOD_GROUP_HANDLER_MODULE(Modules.MOD_GROUP_HANDLER),
RCON_PLAYER_LIST_MODULE(Modules.RCON_PLAYER_LIST),
STATE_MODULE(Modules.STATE),
HELP_MODULE(Modules.HELP),
UNKNOWN_MODULE("UnknownModule");
}
// 基础获取方法
fun value(paramName: String): Any =
config[paramName] ?: throw ConfigError(
ConfigError.Type.MISSING_PARAMETER,
name,
paramName
)
// 泛型获取方法
private inline fun <reified T> get(paramName: String): T {
val value = value(paramName)
return when (T::class) {
Long::class -> convertToLong(value, paramName) as T
Int::class -> convertToInt(value, paramName) as T
String::class -> value.toString() as T
Boolean::class -> convertToBoolean(value, paramName) as T
Double::class -> convertToDouble(value, paramName) as T
Float::class -> convertToFloat(value, paramName) as T
else -> {
if (value is T) value
else throw typeMismatchError<T>(value, paramName)
}
}
}
// 特定类型方法(向后兼容)
fun long(paramName: String): Long = get<Long>(paramName)
fun int(paramName: String): Int = get<Int>(paramName)
fun string(paramName: String): String = get<String>(paramName)
fun boolean(paramName: String): Boolean = get<Boolean>(paramName)
fun double(paramName: String): Double = get<Double>(paramName)
fun float(paramName: String): Float = get<Float>(paramName)
// 可选值方法
inline fun <reified T> getOrNull(paramName: String): T? =
config[paramName] as? T ?: run {
val value = config[paramName]
if (value == null) null
else if (value is T) value
else null
}
inline fun <reified T> getOrDefault(paramName: String, defaultValue: T): T =
getOrNull<T>(paramName) ?: defaultValue
// 类型转换辅助方法
fun convertToLong(value: Any, paramName: String): Long = when (value) {
is Long -> value
is Number -> value.toLong()
is String -> try {
value.toLong()
} catch (e: NumberFormatException) {
throw typeMismatchError<Long>(value, paramName)
}
else -> throw typeMismatchError<Long>(value, paramName)
}
fun convertToInt(value: Any, paramName: String): Int = when (value) {
is Int -> value
is Number -> value.toInt()
is String -> try {
value.toInt()
} catch (e: NumberFormatException) {
throw typeMismatchError<Int>(value, paramName)
}
else -> throw typeMismatchError<Int>(value, paramName)
}
fun convertToBoolean(value: Any, paramName: String): Boolean = when (value) {
is Boolean -> value
is String -> when (value.lowercase()) {
"true", "yes", "1" -> true
"false", "no", "0" -> false
else -> throw typeMismatchError<Boolean>(value, paramName)
}
is Number -> value.toInt() != 0
else -> throw typeMismatchError<Boolean>(value, paramName)
}
private fun convertToDouble(value: Any, paramName: String): Double = when (value) {
is Double -> value
is Number -> value.toDouble()
is String -> try {
value.toDouble()
} catch (e: NumberFormatException) {
throw typeMismatchError<Double>(value, paramName)
}
else -> throw typeMismatchError<Double>(value, paramName)
}
private fun convertToFloat(value: Any, paramName: String): Float = when (value) {
is Float -> value
is Number -> value.toFloat()
is String -> try {
value.toFloat()
} catch (e: NumberFormatException) {
throw typeMismatchError<Float>(value, paramName)
}
else -> throw typeMismatchError<Float>(value, paramName)
}
// 错误处理辅助方法
private inline fun <reified T> typeMismatchError(
actualValue: Any,
paramName: String
): Nothing {
throw ConfigError(
ConfigError.Type.NOT_EXPECTED_VALUE,
paramName,
T::class.simpleName ?: T::class.java.simpleName,
actualValue::class.simpleName ?: actualValue::class.java.simpleName
)
}
}
}

View File

@ -38,7 +38,7 @@ data class ToolConfig(
try { try {
rconPassword = "ENC(${CryptoUtil.encrypt(rconPassword!!)})" rconPassword = "ENC(${CryptoUtil.encrypt(rconPassword!!)})"
YamlUpdater.updateYaml( YamlUpdater.updateYaml(
YamlConfigLoader.configFilePath.toString(), YamlConfigLoader.appConfigFilePath.toString(),
"tools.rcon.rcon-password", "tools.rcon.rcon-password",
rconPassword!! rconPassword!!
) )

View File

@ -36,7 +36,7 @@ data class WebsocketConfig(
try { try {
encryptedToken = "ENC(${CryptoUtil.encrypt(encryptedToken!!)})" encryptedToken = "ENC(${CryptoUtil.encrypt(encryptedToken!!)})"
YamlUpdater.updateYaml( YamlUpdater.updateYaml(
YamlConfigLoader.configFilePath.toString(), YamlConfigLoader.appConfigFilePath.toString(),
"websocket.encrypted-token", "websocket.encrypted-token",
this.encryptedToken!! this.encryptedToken!!
) )

View File

@ -8,42 +8,49 @@ import org.yaml.snakeyaml.introspector.PropertyUtils
import top.r3944realms.ltdmanager.utils.ConfigInitializer import top.r3944realms.ltdmanager.utils.ConfigInitializer
import top.r3944realms.ltdmanager.utils.NamingConventionUtil import top.r3944realms.ltdmanager.utils.NamingConventionUtil
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
object YamlConfigLoader { object YamlConfigLoader {
val configFilePath = Paths.get("config/application.yaml") // 配置文件路径 val appConfigFilePath: Path = Paths.get("config/application.yaml") // 配置文件路径
private val _config by lazy { loadConfig() } // 延迟初始化 val moduleConfigFilePath: Path = Paths.get("config/module.yaml") // 配置文件路径
val config: ConfigWrapper get() = _config private val _app_config by lazy { loadAppConfigWrapper() } // 延迟初始化
val appConfig: AppConfigWrapper get() = _app_config
private val _module_config by lazy { loadModuleConfigWrapper() } // 延迟初始化
val moduleConfig: ModuleConfigWrapper get() = _module_config
init { init {
// 第一次启动确保配置文件存在 // 第一次启动确保配置文件存在
ConfigInitializer.initConfig("module.yaml", "config", false)
ConfigInitializer.initConfig("application.yaml", "config") ConfigInitializer.initConfig("application.yaml", "config")
// 初始化后加密(确保只执行一次) // 初始化后加密(确保只执行一次)
runCatching { runCatching {
ensureConfigEncrypted(_config) ensureConfigEncrypted(_app_config)
}.onFailure { e -> }.onFailure { e ->
println("初始化加密失败: ${e.message}") println("初始化加密失败: ${e.message}")
e.printStackTrace() e.printStackTrace()
} }
} }
private fun ensureConfigEncrypted(config: ConfigWrapper?) { private fun ensureConfigEncrypted(config: AppConfigWrapper?) {
config?.database?.encryptPassword() config?.database?.encryptPassword()
config?.websocket?.encryptToken() config?.websocket?.encryptToken()
config?.http?.encryptToken() config?.http?.encryptToken()
config?.mcsm?.encryptApi()
config?.mail?.encryptPassword() config?.mail?.encryptPassword()
config?.tools?.rcon?.encryptPassword() config?.tools?.rcon?.encryptPassword()
config?.blessingSkinServer?.invitationApi?.encryptToken() config?.blessingSkinServer?.invitationApi?.encryptToken()
config?.dgLab?.wsServer?.encryptPassword()
config?.imgTu?.encryptPassword()
} }
private fun loadConfig(): ConfigWrapper { private fun loadAppConfigWrapper(): AppConfigWrapper {
if (!Files.exists(configFilePath)) { if (!Files.exists(appConfigFilePath)) {
throw RuntimeException("配置文件未找到: $configFilePath") throw RuntimeException("应用配置文件未找到: $appConfigFilePath")
} }
try { try {
val yamlContent = Files.readString(configFilePath) val yamlContent = Files.readString(appConfigFilePath)
return Yaml(getConstructor()).load(yamlContent) return Yaml(getConstructor(AppConfigWrapper::class.java)).load(yamlContent)
?: throw RuntimeException("YAML解析返回null") ?: throw RuntimeException("YAML解析返回null")
} catch (e: Exception) { } catch (e: Exception) {
@ -51,7 +58,23 @@ object YamlConfigLoader {
} }
} }
private fun getConstructor(): Constructor { private fun loadModuleConfigWrapper(): ModuleConfigWrapper {
if (!Files.exists(moduleConfigFilePath)) {
throw RuntimeException("模块配置文件未找到: $moduleConfigFilePath")
}
try {
val yamlContent = Files.readString(moduleConfigFilePath)
return Yaml(getConstructor(ModuleConfigWrapper::class.java)).load(yamlContent)
?: throw RuntimeException("YAML解析返回null")
} catch (e: Exception) {
throw RuntimeException("YAML解析失败: ${e.message}", e)
}
}
private fun getConstructor(clazz: Class<*>): Constructor {
val propertyUtils = object : PropertyUtils() { val propertyUtils = object : PropertyUtils() {
override fun getProperty(type: Class<*>, name: String): Property { override fun getProperty(type: Class<*>, name: String): Property {
val processedName = if (name.contains("-")) { val processedName = if (name.contains("-")) {
@ -63,20 +86,24 @@ object YamlConfigLoader {
} }
} }
return Constructor(ConfigWrapper::class.java, LoaderOptions()).apply { return Constructor(clazz, LoaderOptions()).apply {
setPropertyUtils(propertyUtils) setPropertyUtils(propertyUtils)
} }
} }
fun loadDatabaseConfig(): DatabaseConfig = config.database fun loadDatabaseConfig(): DatabaseConfig = appConfig.database
fun loadCryptoConfig(): CryptoConfig = config.crypto fun loadCryptoConfig(): CryptoConfig = appConfig.crypto
fun loadWebsocketConfig(): WebsocketConfig = config.websocket fun loadMcsmConfig(): McsmConfig = appConfig.mcsm
fun loadHttpConfig(): HttpConfig = config.http fun loadWebsocketConfig(): WebsocketConfig = appConfig.websocket
fun loadModeConfig(): ModeConfig = config.mode fun loadHttpConfig(): HttpConfig = appConfig.http
fun loadToolConfig(): ToolConfig = config.tools fun loadModeConfig(): ModeConfig = appConfig.mode
fun loadMailConfig(): MailConfig = config.mail fun loadToolConfig(): ToolConfig = appConfig.tools
fun loadBlessingSkinServerConfig(): BlessingSkinServerConfig = config.blessingSkinServer fun loadMailConfig(): MailConfig = appConfig.mail
data class ConfigWrapper( fun loadBlessingSkinServerConfig(): BlessingSkinServerConfig = appConfig.blessingSkinServer
fun loadDgLabConfig(): DgLabConfig = appConfig.dgLab
fun loadTuImgConfig(): ImgTuConfig = appConfig.imgTu
fun loadModuleConfig(): ModuleConfig = moduleConfig.module
data class AppConfigWrapper(
var database: DatabaseConfig = DatabaseConfig(), var database: DatabaseConfig = DatabaseConfig(),
var crypto: CryptoConfig = CryptoConfig(), var crypto: CryptoConfig = CryptoConfig(),
var mode: ModeConfig = ModeConfig(), var mode: ModeConfig = ModeConfig(),
@ -84,7 +111,13 @@ object YamlConfigLoader {
var http: HttpConfig = HttpConfig(), var http: HttpConfig = HttpConfig(),
var tools: ToolConfig = ToolConfig(), var tools: ToolConfig = ToolConfig(),
var mail: MailConfig = MailConfig(), var mail: MailConfig = MailConfig(),
var mcsm: McsmConfig = McsmConfig(),
var blessingSkinServer: BlessingSkinServerConfig = BlessingSkinServerConfig(), var blessingSkinServer: BlessingSkinServerConfig = BlessingSkinServerConfig(),
var dgLab: DgLabConfig = DgLabConfig(),
var imgTu: ImgTuConfig = ImgTuConfig(),
)
data class ModuleConfigWrapper(
var module: ModuleConfig = ModuleConfig(),
) )
} }

View File

@ -0,0 +1,151 @@
package top.r3944realms.ltdmanager.dglab
import com.r3944realms.dg_lab.api.manager.Status
import com.r3944realms.dg_lab.api.operation.ClientOperation
import com.r3944realms.dg_lab.api.operation.ServerOperation
import com.r3944realms.dg_lab.api.websocket.message.role.WebSocketClientRole
import com.r3944realms.dg_lab.api.websocket.message.role.WebSocketServerRole
import com.r3944realms.dg_lab.manager.DGPBClientManager
import com.r3944realms.dg_lab.manager.DGPBServerManager
import com.r3944realms.dg_lab.websocket.PowerBoxWSClient
import com.r3944realms.dg_lab.websocket.PowerBoxWSServer
import com.r3944realms.dg_lab.websocket.sharedData.ClientPowerBoxSharedData
import com.r3944realms.dg_lab.websocket.sharedData.ServerPowerBoxSharedData
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import top.r3944realms.ltdmanager.dglab.manager.ClientManager
import top.r3944realms.ltdmanager.dglab.manager.ServerManager
import top.r3944realms.ltdmanager.dglab.model.game.Player
import top.r3944realms.ltdmanager.dglab.model.game.PlayerManager
import kotlin.io.path.Path
/**
* DG_Lab管理器
*/
class DgLab {
// 可空,延迟初始化
internal var serverManager: ServerManager? = null
get() = field
internal var clientManager: ClientManager? = null
get() = field
private var playerManager: PlayerManager? = null
companion object {
const val SERVER_ROLE_NAME = "Se-IC"
}
fun isSeverOnline(): Boolean = serverManager?.let { it.status == Status.RUNNING } ?: false
fun isClientOnline(id: String): Boolean = clientManager?.getClient(id)?.let { it.status == Status.RUNNING } ?: false
fun getPlayerManager(): PlayerManager = playerManager!!
fun close() {
serverManager?.stop()
clientManager?.stopAll()
}
fun initOrLoadPlayerManager(idNameMap: Map<Long, String>) {
playerManager = PlayerManager(1)
val idList = idNameMap.map { id -> id.key }
val existingIds = playerManager?.allPlayers()?.map { it.id }?.toSet() ?: emptySet()
val targetIds = idList.toSet()
// 要删除的
val toRemove = existingIds - targetIds
// 要新增的
val toAdd = targetIds - existingIds
// 删除
toRemove.forEach { id ->
playerManager?.removePlayer(id)
}
// 新增
toAdd.forEach { id ->
playerManager?.addPlayer(Player(id, idNameMap[id] as String,false))
}
}
fun createServerManager(operation: ServerOperation): DGPBServerManager {
val loadDgLabConfig = YamlConfigLoader.loadDgLabConfig()
val boxWSServer = PowerBoxWSServer.Builder.getBuilder()
.port(loadDgLabConfig.wsServer.localServerPort)
.role(WebSocketServerRole(SERVER_ROLE_NAME))
.operation(operation)
.sharedData(ServerPowerBoxSharedData())
.build()
if (loadDgLabConfig.wsServer.localServerSecure) {
boxWSServer.enableSSL(Path(loadDgLabConfig.wsServer.localServerSslCert).toFile(), Path(loadDgLabConfig.wsServer.localServerSslKey).toFile(), loadDgLabConfig.wsServer.decryptedLocalServerSslPassword)
}
val dgpbServerManager = DGPBServerManager(boxWSServer)
return dgpbServerManager
}
/**
* 初始化 服务器管理类
*/
fun initServerManager(server: DGPBServerManager) {
serverManager = ServerManager(server)
}
/**
* 初始化 客户端管理类
*/
fun initClientManager() {
clientManager = ClientManager()
}
/**
* 添加 客户端管理类
*/
fun addClient(key: String, client: DGPBClientManager) {
clientManager?.addClient(key, client)
}
/**
* 移除 客户端管理类
*/
fun removeClient(key: String) {
clientManager?.removeClient(key)
}
/**
* 获取 服务器管理类
*/
@Throws(IllegalStateException::class)
fun getServer(): DGPBServerManager {
return serverManager?.getInstance() ?: throw IllegalStateException("Server is not initialized")
}
/**
* 获取 客户端管理类
*/
fun getClient(key: String): DGPBClientManager? {
return clientManager?.getClient(key)
}
/**
* 获取 & 创建 客户端管理类
*/
fun getClientOrCreate(key: String, operation: ClientOperation): DGPBClientManager {
val client = getClient(key)
if (client == null) {
val loadDgLabConfig = YamlConfigLoader.loadDgLabConfig()
val boxWSClient = PowerBoxWSClient.Builder.getBuilder()
.address(loadDgLabConfig.wsServer.localServerUrl)
.port(loadDgLabConfig.wsServer.localServerPort)
.role(WebSocketClientRole("QQ-$key"))
.operation(operation)
.sharedData(ClientPowerBoxSharedData())
.useRoleMsgMode(true)
.build()
if (loadDgLabConfig.wsServer.localServerSecure) {
boxWSClient.enableSSL()
}
val clientManager = DGPBClientManager(
boxWSClient
)
this.clientManager?.addClient(key, clientManager)
return clientManager
}
return client
}
}

View File

@ -0,0 +1,51 @@
package top.r3944realms.ltdmanager.dglab.manager
import com.r3944realms.dg_lab.manager.DGPBClientManager
class ClientManager(
private val clients: MutableMap<String, DGPBClientManager> = mutableMapOf(),
) : IManager<MutableMap<String, DGPBClientManager>> {
/**
* 添加单例客户端管理示例
* @param key 唯一标识客户端管理的 key比如 ID name
*/
fun addClient(key: String, client: DGPBClientManager) {
clients[key] = client
}
/**
* 移除单例客户端管理实例
*/
fun removeClient(key: String) {
clients.remove(key)?.stop()
}
/**
* 根据 key 获取客户端
*/
fun getClient(key: String): DGPBClientManager? {
return clients[key]
}
/**
* 启动所有客户端
*/
override fun startAll() {
clients.values.forEach { it.start() }
}
/**
* 停止所有客户端
*/
override fun stopAll() {
clients.values.forEach { it.stop() }
}
/**
* 获取内部 Map 实例
*/
override fun getInstance(): MutableMap<String, DGPBClientManager> {
return clients
}
}

View File

@ -0,0 +1,7 @@
package top.r3944realms.ltdmanager.dglab.manager
interface IManager<T> {
fun startAll()
fun stopAll()
fun getInstance(): T?
}

View File

@ -0,0 +1,43 @@
package top.r3944realms.ltdmanager.dglab.manager
import com.r3944realms.dg_lab.api.manager.IDGLabManager
import com.r3944realms.dg_lab.api.manager.Status
import com.r3944realms.dg_lab.api.websocket.sharedData.ISharedData
import com.r3944realms.dg_lab.manager.DGPBServerManager
class ServerManager(
private val server: DGPBServerManager
) : IManager<DGPBServerManager>, IDGLabManager {
override fun startAll() {
start()
}
override fun stopAll() {
stop()
}
override fun start() {
server.start()
}
override fun stop() {
server.stop()
}
override fun getSharedData(): ISharedData {
return server.sharedData
}
override fun getStatus(): Status {
return server.status
}
override fun setStatus(p0: Status?) {
server.status = p0
}
override fun getInstance(): DGPBServerManager {
return server
}
}

View File

@ -0,0 +1,228 @@
package top.r3944realms.ltdmanager.dglab.model.game
import com.r3944realms.dg_lab.api.operation.ClientOperation
import com.r3944realms.dg_lab.api.websocket.message.data.PowerBoxData
import com.r3944realms.dg_lab.api.websocket.message.data.type.PowerBoxDataType
import com.r3944realms.dg_lab.manager.DGPBClientManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import top.r3944realms.ltdmanager.GlobalManager
import top.r3944realms.ltdmanager.chevereto.response.FailedCheveretoResponse
import top.r3944realms.ltdmanager.chevereto.response.v1.CheveretoUploadResponse
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
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.request.other.SendGroupMsgRequest
import top.r3944realms.ltdmanager.napcat.request.other.SendPrivateMsgRequest
import top.r3944realms.ltdmanager.utils.LoggerUtil
import top.r3944realms.ltdmanager.utils.QRCodeUtil
import java.io.ByteArrayInputStream
class GameClientOperation(
val napCatClient: NapCatClient,
val groupId: Long,
val playerManager: PlayerManager,
private val playerId: Long
) : ClientOperation {
private val scope = CoroutineScope(Dispatchers.IO)
private var qrcode:ByteArrayInputStream? = null;
var clientSelf: DGPBClientManager? = null
private var hasBinding = false
private var bindingTimeoutJob: kotlinx.coroutines.Job? = null // 保存倒计时任务
override fun ClientStartingHandler() {
LoggerUtil.logger.debug("Player $playerId is starting the client...")
scope.launch {
napCatClient.sendUnit(SendPrivateMsgRequest(listOf(MessageElement.text("DG_LAB客户端启动中...")), ID.long(playerId)))
}
}
override fun ClientStartedHandler() {
LoggerUtil.logger.debug("Player $playerId client started successfully.")
scope.launch {
napCatClient.sendUnit(SendPrivateMsgRequest(listOf(MessageElement.text("DG_LAB客户端启动完成!")), ID.long(playerId)))
}
playerManager.getPlayer(playerId)?.active = true
}
override fun ClientStartingErrorHandler(errMsg: String) {
LoggerUtil.logger.debug("Player $playerId failed to start client! Reason: $errMsg")
scope.launch {
napCatClient.sendUnit(SendPrivateMsgRequest(listOf(MessageElement.text("DG_LAB客户端启动中遇到错误$errMsg!")), ID.long(playerId)))
}
playerManager.getPlayer(playerId)?.active = false
}
override fun ClientStoppingHandler() {
LoggerUtil.logger.debug("Player $playerId is stopping the client...")
scope.launch {
napCatClient.sendUnit(SendPrivateMsgRequest(listOf(MessageElement.text("DG_LAB客户端关闭中...")), ID.long(playerId)))
}
playerManager.getPlayer(playerId)?.active = false
}
override fun ClientStoppingErrorHandler(errMsg: String) {
LoggerUtil.logger.debug("Player $playerId encountered an error while stopping. Reason: $errMsg")
scope.launch {
napCatClient.sendUnit(SendPrivateMsgRequest(listOf(MessageElement.text("DG_LAB客户端关闭中遇到错误$errMsg!")), ID.long(playerId)))
}
playerManager.getPlayer(playerId)?.active = false
}
override fun ClientStoppedHandler() {
LoggerUtil.logger.debug("Player $playerId client stopped.")
scope.launch {
napCatClient.sendUnit(SendPrivateMsgRequest(listOf(MessageElement.text("DG_LAB客户端成功关闭!")), ID.long(playerId)))
}
bindingTimeoutJob?.cancel()
playerManager.getPlayer(playerId)?.active = false
}
override fun QrCodeUrlHandler(p0: String?) {
LoggerUtil.logger.debug("Player $playerId QR code received: $p0")
if (p0.isNullOrBlank()) {
LoggerUtil.logger.warn("二维码 URL 为空,无法生成")
return
}
// 处理 URL将 IP 和端口替换为配置文件中的服务器 URL
val processedUrl = processQrCodeUrl(p0)
// 生成二维码文件
qrcode = QRCodeUtil.generateQRCode(processedUrl, 300, 300)
}
/**
* 处理二维码 URL将整个连接地址替换为配置文件中的服务器 URL
*/
private fun processQrCodeUrl(originalUrl: String): String {
return try {
val configUrl = YamlConfigLoader.loadDgLabConfig().wsServer.localServerPublishUrl
// 使用正则表达式匹配整个 ws:// 或 wss:// 开头的 URL
val pattern = Regex("""wss?://[^:/]+(?::\d+)?(/.*)?""")
pattern.replace(originalUrl) { matchResult ->
// 保留原始 URL 中的路径部分(如果有的话)
val path = matchResult.groupValues[1]
"$configUrl$path"
}
} catch (e: Exception) {
LoggerUtil.logger.error("处理二维码 URL 时出错: ${e.message}", e)
originalUrl // 如果处理失败,返回原 URL
}
}
override fun ShowQrCodeHandler() {
LoggerUtil.logger.debug("Display QRCode to $playerId.")
if (qrcode == null) {
LoggerUtil.logger.warn("没有可用的二维码路径")
return
}
scope.launch {
// 上传二维码图片
val response = GlobalManager.cheveretoClient.uploadStream(
qrcode!!,
"$playerId-Qrcode-${System.currentTimeMillis()}.png",
"Qrcode-$playerId-${System.currentTimeMillis()}",
"5min后将会自动删除",
albumId = "BFx",
expiration = "PT5M"
)
if (response is CheveretoUploadResponse){
napCatClient.sendUnit(
SendPrivateMsgRequest(
listOf(
MessageElement.text("请在60s内绑定APP否则将自动断开连接"),
MessageElement.image(response.image.url, "二维码")
),
ID.long(playerId)
)
)
} else if (response is FailedCheveretoResponse.Default){
napCatClient.sendUnit(
SendPrivateMsgRequest(
listOf(
MessageElement.text("无法上传图片,请联系管理员:${response.httpStatusCode} , ${response.failedMessage}"),
),
ID.long(playerId)
)
)
}
// 启动 60 秒倒计时任务
bindingTimeoutJob = launch {
kotlinx.coroutines.delay(60_000)
val player = playerManager.getPlayer(playerId)
if (player != null && !hasBinding) {
LoggerUtil.logger.warn("Player $playerId 在 60 秒内未绑定,正在停止客户端")
napCatClient.sendUnit(
SendPrivateMsgRequest(
listOf(
MessageElement.text("请在60s内未绑定APP准备停止客户端"),
),
ID.long(playerId)
)
)
try {
clientSelf?.stop()
} catch (e: Exception) {
LoggerUtil.logger.error("停止客户端失败: ", e)
} finally {
player.active = false
}
}
}
}
}
override fun ConnectSuccessfulNoticeHandler() {
LoggerUtil.logger.debug("Player $playerId connected successfully.")
bindingTimeoutJob?.cancel()
bindingTimeoutJob = null
val player = playerManager.getPlayer(playerId)
player?.active = true
scope.launch {
napCatClient.sendUnit(SendPrivateMsgRequest(listOf(MessageElement.text("恭喜,绑定成功")), ID.long(playerId)))
napCatClient.sendUnit(SendGroupMsgRequest(listOf(MessageElement.text("$playerId 加入战局")), ID.long(groupId)))
}
}
override fun DisconnectHandler(p0: PowerBoxData?) {
LoggerUtil.logger.debug("Player {} disconnected: {}", playerId, p0)
scope.launch {
napCatClient.sendUnit(SendPrivateMsgRequest(listOf(MessageElement.text("连接断开, $p0")), ID.long(playerId)))
napCatClient.sendUnit(SendGroupMsgRequest(listOf(MessageElement.text("$playerId 离开战局")), ID.long(groupId)))
}
}
override fun ErrorHandler(p0: PowerBoxData?) {
LoggerUtil.logger.debug("Player {} error occurred: {}", playerId, p0)
scope.launch {
if(p0 != null && p0.message.isNotEmpty())
napCatClient.sendUnit(SendPrivateMsgRequest(listOf(MessageElement.text("遇到错误, $p0")), ID.long(playerId)))
}
}
override fun HeartBeatHandler(p0: PowerBoxData?) {
// LoggerUtil.logger.debug("Heartbeat from player {}: {}", playerId, p0)
// scope.launch {
// napCatClient.sendUnit(SendPrivateMsgRequest(listOf(MessageElement.text("连接断开, $p0")), ID.long(playerId)))
// }
}
override fun OtherMessageHandler(p0: PowerBoxData?) {
// LoggerUtil.logger.debug("Other message for player {}: {}", playerId, p0)
// scope.launch {
// napCatClient.sendUnit(SendPrivateMsgRequest(listOf(MessageElement.text("连接断开, $p0")), ID.long(playerId)))
// }
when (p0?.commandType) {
PowerBoxDataType.STRENGTH -> TODO()
PowerBoxDataType.PULSE -> TODO()
PowerBoxDataType.CLEAR -> TODO()
PowerBoxDataType.FEEDBACK -> TODO()
else -> return
}
}
}

View File

@ -0,0 +1,84 @@
package top.r3944realms.ltdmanager.dglab.model.game
import com.r3944realms.dg_lab.api.websocket.message.PowerBoxMessage
import com.r3944realms.dg_lab.api.websocket.message.role.PlaceholderRole
import com.r3944realms.dg_lab.api.websocket.message.role.WebSocketServerRole
import com.r3944realms.dg_lab.websocket.handler.server.DefaultServerOperation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import top.r3944realms.ltdmanager.dglab.DgLab
import top.r3944realms.ltdmanager.dglab.manager.ServerManager
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.request.other.SendGroupMsgRequest
class GameServerOperation(private val msgClient: NapCatClient, val groupId: Long) : DefaultServerOperation() {
private val scope = CoroutineScope(Dispatchers.IO)
var serverManager: ServerManager? = null
override fun ServerStartingHandler() {
scope.launch {
msgClient.sendUnit(
SendGroupMsgRequest(listOf(MessageElement.text("服务器启动中...")), ID.long(groupId))
)
}
}
override fun ServerStartedHandler() {
scope.launch {
msgClient.sendUnit(
SendGroupMsgRequest(listOf(MessageElement.text("服务器已启动")), ID.long(groupId))
)
}
}
override fun ServerStoppingHandler() {
scope.launch {
msgClient.sendUnit(
SendGroupMsgRequest(listOf(MessageElement.text("服务器关闭中...")), ID.long(groupId))
)
}
}
override fun ServerStoppedHandler() {
scope.launch {
msgClient.sendUnit(
SendGroupMsgRequest(listOf(MessageElement.text("服务器已关闭")), ID.long(groupId))
)
}
}
override fun ServerStoppingErrorHandler(errMsg: String) {
scope.launch {
msgClient.sendUnit(
SendGroupMsgRequest(listOf(MessageElement.text("服务器关闭过程中遇到错误: $errMsg")), ID.long(groupId))
)
}
}
override fun ServerStartingErrorHandler(errMsg: String?) {
scope.launch {
msgClient.sendUnit(
SendGroupMsgRequest(listOf(MessageElement.text("服务器开启过程中遇到错误: $errMsg")), ID.long(groupId))
)
}
}
override fun ClientSessionBuildInHandler(clientId: String?) {
scope.launch{
delay(1000)
serverManager?.getInstance()?.send(
clientId,
PowerBoxMessage.createPowerBoxMessage(
"bind",
clientId,
"",
"",
WebSocketServerRole(DgLab.SERVER_ROLE_NAME),
PlaceholderRole("Temp-$clientId")
)
)
}
}
}

View File

@ -0,0 +1,13 @@
package top.r3944realms.ltdmanager.dglab.model.game
import kotlinx.serialization.Serializable
/**
* 玩家类
*/
@Serializable
data class Player(
val id: Long,
var name: String,
var active: Boolean,
)

View File

@ -0,0 +1,73 @@
package top.r3944realms.ltdmanager.dglab.model.game
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import top.r3944realms.ltdmanager.module.PersistentState
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.io.File
import java.util.concurrent.ConcurrentHashMap
class PlayerManager(id: Long): PersistentState<PlayerManager.PlayerState> {
@Contextual
private val map = ConcurrentHashMap<Long, Player>()
@Transient
private val stateFile: File = getStateFileInternal("dglab_player_data.json", "dglab$id")
@Transient
private val stateBackupFile: File = getStateFileInternal("dglab_player_data.json.bak","dglab$id")
override fun getStateFileInternal(): File = stateFile
private var playerState = loadState()
@Serializable
data class PlayerState(
val map: Map<Long, Player> = emptyMap()
)
override fun getState(): PlayerState = playerState
/** 添加或更新玩家 */
fun addPlayer(player: Player) {
map[player.id] = player
}
/** 根据 ID 获取玩家 */
fun getPlayer(id: Long): Player? = map[id]
/** 删除玩家 */
fun removePlayer(id: Long): Player? = map.remove(id)
/** 判断是否存在玩家 */
fun contains(id: Long): Boolean = map.containsKey(id)
/** 获取所有玩家 */
fun allPlayers(): List<Player> = map.values.toList()
/** 获取所有在线玩家的数量 */
fun getOnlinePlayerSize(): Int = map.values.filter { it.active }.size
override fun saveState(state: PlayerState) {
try {
if (stateFile.exists()) stateFile.copyTo(stateBackupFile, overwrite = true)
stateFile.writeText(Json.encodeToString(state))
} catch (e: Exception) {
LoggerUtil.logger.error("[dglab] 保存玩家数据&状态失败", e)
}
}
override fun loadState(): PlayerState {
return try {
val fileToRead = when {
stateFile.exists() -> stateFile
stateBackupFile.exists() -> stateBackupFile
else -> null
} ?: return PlayerState()
Json.decodeFromString<PlayerState>(fileToRead.readText())
} catch (e: Exception) {
LoggerUtil.logger.warn("[dglab] 读取玩家数据&状态失败", e)
PlayerState()
}
}
}

View File

@ -0,0 +1,132 @@
package top.r3944realms.ltdmanager.dglab.model.pulseware
import com.r3944realms.dg_lab.api.message.data.PulseWave
import com.r3944realms.dg_lab.api.message.data.PulseWaveList
object CustomPulseDataConverter {
/**
* 将频率转换为 Dg-Lab 格式
*
* @param frequency 频率值
* @return Dg-Lab 格式的数字
*/
private fun convertFrequency(frequency: Int): Int {
return when {
frequency <= 10 -> 10
frequency <= 100 -> frequency
frequency <= 600 -> (frequency - 100) / 5 + 100
frequency <= 1000 -> (frequency - 600) / 10 + 200
else -> 10
}
}
/**
* 将频率数组转换为 Dg-Lab 格式
*
* @param frequencies 频率数组
* @return 转换后的频率数组
*/
private fun convertFrequencies(frequencies: IntArray): IntArray {
return IntArray(4) { index ->
if (index < frequencies.size) {
convertFrequency(frequencies[index])
} else {
10 // 默认值
}
}
}
/**
* 将自定义波形数据转换为 PulseWaveList
*
* @param customPulseData Map<String, List<Array<IntArray>>>
* 每个 int[][] 包含两个长度为 4 int 数组第一个是 frequencies第二个是 strengths
* @return Map<String, PulseWaveList>
*/
fun convert(customPulseData: Map<String, List<Array<IntArray>>>): Map<String, PulseWaveList> {
val pulseWaveLists: MutableMap<String, PulseWaveList> = HashMap()
for ((name, operations) in customPulseData) {
val waveList = PulseWaveList()
waveList.name = name
for (op in operations) {
val freqs = op[0]
val strengths = op[1]
// 确保每个数组长度为4
require(!(freqs.size != 4 || strengths.size != 4)) { "每个波形段必须包含 4 个频率和 4 个强度值" }
// 转换频率为 Dg-Lab 格式
val convertedFreqs = convertFrequencies(freqs)
val wave = PulseWave.fromArrays(convertedFreqs, strengths)
waveList.add(wave)
}
pulseWaveLists[name] = waveList
}
return pulseWaveLists
}
/**
* 转换单个 PulseWave 的频率
*/
private fun convertPulseWaveFrequencies(pulseWave: PulseWave): PulseWave {
val freqs = intArrayOf(
convertFrequency(pulseWave.f1()),
convertFrequency(pulseWave.f2()),
convertFrequency(pulseWave.f3()),
convertFrequency(pulseWave.f4())
)
val strengths = intArrayOf(
pulseWave.s1(),
pulseWave.s2(),
pulseWave.s3(),
pulseWave.s4()
)
return PulseWave.fromArrays(freqs, strengths)
}
/**
* 转换整个 PulseWaveList 的频率
*/
fun convertPulseWaveListFrequencies(pulseWaveList: PulseWaveList): PulseWaveList {
val convertedList = PulseWaveList()
convertedList.name = pulseWaveList.name
for (i in 0 until pulseWaveList.list.size) {
val convertedWave = convertPulseWaveFrequencies(pulseWaveList.list[i])
convertedList.add(convertedWave)
}
return convertedList
}
fun PulseWave.toSerializable(): PulseWaveSerializable =
PulseWaveSerializable(
convertFrequency(f1()),
convertFrequency(f2()),
convertFrequency(f3()),
convertFrequency(f4()),
s1(), s2(), s3(), s4()
)
private fun PulseWaveSerializable.toPulseWave(): PulseWave =
PulseWave.fromArrays(
intArrayOf(convertFrequency(f1), convertFrequency(f2), convertFrequency(f3), convertFrequency(f4)),
intArrayOf(s1, s2, s3, s4)
)
fun PulseWaveList.toSerializable(): PulseWaveListSerializable =
PulseWaveListSerializable(name, list.map { it.toSerializable() }.toMutableList())
fun PulseWaveListSerializable.toPulseWaveList(): PulseWaveList {
val listObj = PulseWaveList()
listObj.setName(name)
list.forEach { listObj.add(it.toPulseWave()) }
return listObj
}
}

View File

@ -0,0 +1,345 @@
package top.r3944realms.ltdmanager.dglab.model.pulseware
import com.r3944realms.dg_lab.api.message.data.PulseWave
import com.r3944realms.dg_lab.api.message.data.PulseWaveList
object DefaultPulseData {
/**
* 将频率转换为 Dg-Lab 格式
*
* @param frequency 频率值
* @return Dg-Lab 格式的数字
*/
private fun convertFrequency(frequency: Int): Int {
return when {
frequency <= 10 -> 10
frequency <= 100 -> frequency
frequency <= 600 -> (frequency - 100) / 5 + 100
frequency <= 1000 -> (frequency - 600) / 10 + 200
else -> 10
}
}
/**
* 转换频率数组为 Dg-Lab 格式
*/
private fun convertFrequencies(frequencies: IntArray): IntArray {
return IntArray(frequencies.size) { index ->
convertFrequency(frequencies[index])
}
}
/**
* 创建经过频率转换的波形段
*/
private fun createWaveSegment(frequencies: IntArray, strengths: IntArray): PulseWave {
val convertedFreqs = convertFrequencies(frequencies)
return PulseWave.fromArrays(convertedFreqs, strengths)
}
fun allPulseWaveLists(): Map<String, PulseWaveList> {
return mapOf(
"呼吸" to Breath,
"潮汐" to Tide,
"连击" to Combo,
"快速按捏" to FastPinch,
"按捏渐强" to PinchGradual,
"心跳节奏" to Heartbeat,
"压缩" to Compress,
"节奏步伐" to RhythmStep,
"颗粒摩擦" to GranularFriction,
"渐变弹跳" to GradualBounce,
"波浪涟漪" to WaveRipple,
"雨水冲刷" to RainWash,
"变速敲击" to SpeedHit,
"信号灯" to SignalLight,
"挑逗1" to Tease1,
"挑逗2" to Tease2
)
}
val Breath: PulseWaveList by lazy {
val list = PulseWaveList()
list.name = "呼吸"
// 每段频率和强度
val segments = listOf(
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 5, 10, 20)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(20, 25, 30, 40)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(40, 45, 50, 60)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(60, 65, 70, 80)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(0, 0, 0, 0), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(0, 0, 0, 0), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(0, 0, 0, 0), intArrayOf(0, 0, 0, 0))
)
// 转成 PulseWave 并加入列表
for (seg in segments) {
list.add(createWaveSegment(seg[0], seg[1]))
}
list
}
val Tide: PulseWaveList by lazy {
val list = PulseWaveList()
list.name = "潮汐"
val segments = listOf(
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 4, 8, 17)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(17, 21, 25, 33)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(50, 50, 50, 50)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(50, 54, 58, 67)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(67, 71, 75, 83)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 98, 96, 92)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(92, 90, 88, 84)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(84, 82, 80, 76)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(68, 68, 68, 68))
)
segments.forEach { list.add(createWaveSegment(it[0], it[1])) }
list
}
val Combo: PulseWaveList by lazy {
val list = PulseWaveList()
list.name = "连击"
val segments = listOf(
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 92, 84, 67)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(67, 58, 50, 33)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 1)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(2, 2, 2, 2))
)
segments.forEach { list.add(createWaveSegment(it[0], it[1])) }
list
}
val FastPinch: PulseWaveList by lazy {
val list = PulseWaveList()
list.name = "快速按捏"
val segments = listOf(
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(0, 0, 0, 0), intArrayOf(0, 0, 0, 0))
)
segments.forEach { list.add(createWaveSegment(it[0], it[1])) }
list
}
val PinchGradual: PulseWaveList by lazy {
val list = PulseWaveList()
list.name = "按捏渐强"
val segments = listOf(
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(29, 29, 29, 29)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(52, 52, 52, 52)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(2, 2, 2, 2)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(73, 73, 73, 73)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(87, 87, 87, 87)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0))
)
segments.forEach { list.add(createWaveSegment(it[0], it[1])) }
list
}
val Heartbeat: PulseWaveList by lazy {
val list = PulseWaveList()
list.name = "心跳节奏"
val segments = listOf(
arrayOf(intArrayOf(110, 110, 110, 110), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(110, 110, 110, 110), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(75, 75, 75, 75)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(75, 77, 79, 83)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(83, 85, 88, 92)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0))
)
segments.forEach { list.add(createWaveSegment(it[0], it[1])) }
list
}
val Compress: PulseWaveList by lazy {
val list = PulseWaveList()
list.name = "压缩"
val segments = listOf(
arrayOf(intArrayOf(25, 25, 24, 24), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(24, 23, 23, 23), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(22, 22, 22, 21), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(21, 21, 20, 20), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(20, 19, 19, 19), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(18, 18, 18, 17), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(17, 16, 16, 16), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(15, 15, 15, 14), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(14, 14, 13, 13), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(13, 12, 12, 12), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(11, 11, 11, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100))
)
segments.forEach { list.add(createWaveSegment(it[0], it[1])) }
list
}
val RhythmStep: PulseWaveList by lazy {
val list = PulseWaveList()
list.name = "节奏步伐"
val segments = listOf(
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 5, 10, 20)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(20, 25, 30, 40)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(40, 45, 50, 60)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(60, 65, 70, 80)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 6, 12, 25)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(25, 31, 38, 50)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(50, 56, 62, 75)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 8, 16, 33)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(33, 42, 50, 67)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 12, 25, 50)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100))
)
segments.forEach { list.add(createWaveSegment(it[0], it[1])) }
list
}
val GranularFriction: PulseWaveList by lazy {
val list = PulseWaveList()
list.name = "颗粒摩擦"
val segments = listOf(
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0))
)
segments.forEach { list.add(createWaveSegment(it[0], it[1])) }
list
}
val GradualBounce: PulseWaveList by lazy {
val list = PulseWaveList()
list.name = "渐变弹跳"
val segments = listOf(
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(1, 1, 1, 1)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(1, 9, 18, 34)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(34, 42, 50, 67)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(0, 0, 0, 0), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(0, 0, 0, 0), intArrayOf(0, 0, 0, 0))
)
segments.forEach { list.add(createWaveSegment(it[0], it[1])) }
list
}
val WaveRipple: PulseWaveList by lazy {
val list = PulseWaveList()
list.name = "波浪涟漪"
val segments = listOf(
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(1, 1, 1, 1)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(1, 3, 7, 13)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(13, 25, 40, 60)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(60, 75, 90, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(50, 50, 50, 50)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0))
)
segments.forEach { list.add(createWaveSegment(it[0], it[1])) }
list
}
val RainWash: PulseWaveList by lazy {
val list = PulseWaveList()
list.name = "雨水冲刷"
val segments = listOf(
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 5, 15, 30)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(40, 50, 60, 70)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(80, 90, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0))
)
segments.forEach { list.add(createWaveSegment(it[0], it[1])) }
list
}
val SpeedHit: PulseWaveList by lazy {
val list = PulseWaveList()
list.name = "变速敲击"
val segments = listOf(
arrayOf(intArrayOf(15, 15, 15, 15), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(20, 20, 20, 20), intArrayOf(50, 50, 50, 50)),
arrayOf(intArrayOf(25, 25, 25, 25), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(20, 20, 20, 20), intArrayOf(50, 50, 50, 50)),
arrayOf(intArrayOf(15, 15, 15, 15), intArrayOf(0, 0, 0, 0))
)
segments.forEach { list.add(createWaveSegment(it[0], it[1])) }
list
}
val SignalLight: PulseWaveList by lazy {
val list = PulseWaveList()
list.name = "信号灯"
val segments = listOf(
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 0, 0, 0)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 100, 100, 100))
)
segments.forEach { list.add(createWaveSegment(it[0], it[1])) }
list
}
val Tease1: PulseWaveList by lazy {
val list = PulseWaveList()
list.name = "挑逗1"
val segments = listOf(
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 30, 60, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 70, 40, 0))
)
segments.forEach { list.add(createWaveSegment(it[0], it[1])) }
list
}
val Tease2: PulseWaveList by lazy {
val list = PulseWaveList()
list.name = "挑逗2"
val segments = listOf(
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(0, 50, 100, 100)),
arrayOf(intArrayOf(10, 10, 10, 10), intArrayOf(100, 50, 0, 0))
)
segments.forEach { list.add(createWaveSegment(it[0], it[1])) }
list
}
}

View File

@ -0,0 +1,4 @@
package top.r3944realms.ltdmanager.dglab.model.pulseware
class PulseWaveClassTransform {
}

View File

@ -0,0 +1,29 @@
package top.r3944realms.ltdmanager.dglab.model.pulseware
import com.r3944realms.dg_lab.api.message.data.PulseWaveList
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import top.r3944realms.ltdmanager.dglab.model.pulseware.CustomPulseDataConverter.toPulseWaveList
import top.r3944realms.ltdmanager.dglab.model.pulseware.CustomPulseDataConverter.toSerializable
import java.io.File
object PulseWaveJsonIO {
private val json = Json {
prettyPrint = true
encodeDefaults = true
}
fun saveToFile(map: Map<String, PulseWaveList>, file: File) {
val serializableMap = map.mapValues { it.value.toSerializable() }
file.writeText(json.encodeToString(serializableMap))
}
fun loadFromFile(file: File): Map<String, PulseWaveList> {
if (!file.exists()) return emptyMap()
val type = MapSerializer(String.serializer(), PulseWaveListSerializable.serializer())
val data: Map<String, PulseWaveListSerializable> = json.decodeFromString(type, file.readText())
return data.mapValues { it.value.toPulseWaveList() }
}
}

View File

@ -0,0 +1,9 @@
package top.r3944realms.ltdmanager.dglab.model.pulseware
import kotlinx.serialization.Serializable
@Serializable
data class PulseWaveListSerializable(
var name: String = "",
val list: MutableList<PulseWaveSerializable> = mutableListOf()
)

View File

@ -0,0 +1,9 @@
package top.r3944realms.ltdmanager.dglab.model.pulseware
import kotlinx.serialization.Serializable
@Serializable
data class PulseWaveSerializable(
val f1: Int, val f2: Int, val f3: Int, val f4: Int,
val s1: Int, val s2: Int, val s3: Int, val s4: Int
)

View File

@ -1,86 +1,165 @@
package top.r3944realms.ltdmanager package top.r3944realms.ltdmanager
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import top.r3944realms.ltdmanager.module.McServerStatusModule
import top.r3944realms.ltdmanager.module.* import top.r3944realms.ltdmanager.module.*
// DSL
fun main() = GlobalManager.runBlockingMain { fun main() = GlobalManager.runBlockingMain {
val groupId:Long = 538751386 GlobalManager.initApplication()
val selfQQId = 3327379836 // val commonGroupId:Long = 538751386
val selfNickName = "闲趣老土豆" // val whitelistGroupId:Long = 920719236
// 创建模块实例 // val selfQQId = 3327379836
val groupModule = GroupRequestHandlerModule( // val selfNickName = "闲趣老土豆"
client = GlobalManager.napCatClient, // // 创建模块实例
targetGroupId = groupId // val groupModule = GroupRequestHandlerModule(
) // moduleName = "WhiteListGroup",
val groupMsgPollingModule = GroupMessagePollingModule( // client = GlobalManager.napCatClient,
targetGroupId = groupId, // targetGroupId = whitelistGroupId
pollIntervalMillis = 5_000L, // )
msgHistoryCheck = 15 // val commonGroupMsgPollingModule = GroupMessagePollingModule(
) // moduleName = "CommonGroupMsgPolling",
val toolConfig = YamlConfigLoader.loadToolConfig() // targetGroupId = commonGroupId,
val rconModule = RconPlayerListModule( // pollIntervalMillis = 5_000L,
groupMessagePollingModule = groupMsgPollingModule, // msgHistoryCheck = 15
rconTimeOut = 2_000L, // )
cooldownMillis = 10_000L, // val whiteListGroupMsgPollingModule = GroupMessagePollingModule(
selfId = selfQQId, // moduleName = "WhiteListGroup",
selfNickName = selfNickName, // targetGroupId = whitelistGroupId,
rconPath = toolConfig.rcon.mcRconToolPath.toString(), // pollIntervalMillis = 5_000L,
rconConfigPath = toolConfig.rcon.mcRconToolConfigPath.toString(), // msgHistoryCheck = 15
keywords = setOf( // )
//形容 // val commonHelpModule = HelpModule(
"土豆", "马铃薯", "Potato", "potato", "POTATO", // moduleName = "CommonGroup",
"Potatoes", "potatoes", "POTATOES", "🥔", // keywords = listOf("help", "帮助"),
//正经 // groupMessagePollingModule = commonGroupMsgPollingModule,
"列表","服务器状态", "TPS", "tps", "list", "List" // selfId = selfQQId,
) // selfNickName = selfNickName,
) // )
val mailConfig = YamlConfigLoader.loadMailConfig() // val whitelistHelpModule = HelpModule(
val mailModule = MailModule( // moduleName = "WhiteListGroup",
host = mailConfig.host.toString(), // keywords = listOf("help", "帮助"),
authToken = mailConfig.decryptedPassword.toString(), // groupMessagePollingModule = whiteListGroupMsgPollingModule,
port = mailConfig.port!!, // selfId = selfQQId,
senderEmailAddress = mailConfig.mailAddress!!, // selfNickName = selfNickName,
) // )
val blessingSkinConfig = YamlConfigLoader.loadBlessingSkinServerConfig() // val toolConfig = YamlConfigLoader.loadToolConfig()
val invitationCodesModule = InvitationCodesModule( // val corconModule = RconPlayerListModule(
groupMessagePollingModule = groupMsgPollingModule, // moduleName = "CommonGroup",
mailModule = mailModule, // groupMessagePollingModule = commonGroupMsgPollingModule,
apiToken = blessingSkinConfig.invitationApi?.decryptedToken!!, // rconTimeOut = 2_000L,
selfId = selfQQId, // cooldownMillis = 10_000L,
keywords = setOf( // selfId = selfQQId,
"申请皮肤站注册邀请码", // selfNickName = selfNickName,
"申请土豆服务器注册邀请码", // rconPath = toolConfig.rcon.mcRconToolPath.toString(),
"申请LTD邀请码", // rconConfigPath = toolConfig.rcon.mcRconToolConfigPath.toString(),
"Apply for an invitation code" // keywords = setOf(
) // //形容
) // "土豆", "马铃薯", "Potato", "potato", "POTATO",
val mcServerStatusModule = McServerStatusModule( // "Potatoes", "potatoes", "POTATOES", "🥔",
groupMessagePollingModule = groupMsgPollingModule, // //正经
selfId = selfQQId, // "列表","服务器状态", "TPS", "tps", "list", "List"
cooldownSeconds = 20, // )
selfNickName = selfNickName, // )
commands = listOf("/m", "/mcs", "seek", "s"), // val rconModule = RconPlayerListModule(
presetServer = mapOf( // moduleName = "WhiteListGroup",
setOf("先行土豆", "先行", "pre", "Pre", "BF", "bf", "p", "P") to "n2.akiracloud.net:10599", // groupMessagePollingModule = whiteListGroupMsgPollingModule,
setOf("土豆", "老土豆", "七周目", "7" ,"ZZ", "zz", "Zz", "seven") to "main.mmccdd.top:11106", // rconTimeOut = 2_000L,
) // cooldownMillis = 10_000L,
) // selfId = selfQQId,
// selfNickName = selfNickName,
// 注册模块到全局模块管理器 // rconPath = toolConfig.rcon.mcRconToolPath.toString(),
GlobalManager.moduleManager.registerModule(groupModule) // rconConfigPath = toolConfig.rcon.mcRconToolConfigPath.toString(),
GlobalManager.moduleManager.registerModule(groupMsgPollingModule) // keywords = setOf(
GlobalManager.moduleManager.registerModule(mcServerStatusModule) // //形容
GlobalManager.moduleManager.registerModule(rconModule) // "土豆", "马铃薯", "Potato", "potato", "POTATO",
GlobalManager.moduleManager.registerModule(mailModule) // "Potatoes", "potatoes", "POTATOES", "🥔",
GlobalManager.moduleManager.registerModule(invitationCodesModule) // //正经
// "列表","服务器状态", "TPS", "tps", "list", "List"
// 加载模块 // )
GlobalManager.moduleManager.loadModule(groupModule.name) // )
GlobalManager.moduleManager.loadModule(groupMsgPollingModule.name) //// val mailConfig = YamlConfigLoader.loadMailConfig()
GlobalManager.moduleManager.loadModule(mcServerStatusModule.name) //// val mailModule = MailModule(
GlobalManager.moduleManager.loadModule(rconModule.name) //// moduleName = "WhiteListGroup",
GlobalManager.moduleManager.loadModule(mailModule.name) //// host = mailConfig.host.toString(),
GlobalManager.moduleManager.loadModule(invitationCodesModule.name) //// authToken = mailConfig.decryptedPassword.toString(),
//// port = mailConfig.port!!,
//// senderEmailAddress = mailConfig.mailAddress!!,
//// )
//// val blessingSkinConfig = YamlConfigLoader.loadBlessingSkinServerConfig()
//// val invitationCodesModule = InvitationCodesModule(
//// moduleName = "WhiteListGroup",
//// groupMessagePollingModule = commonGroupMsgPollingModule,
//// mailModule = mailModule,
//// apiToken = blessingSkinConfig.invitationApi?.decryptedToken!!,
//// selfId = selfQQId,
//// keywords = setOf(
//// "申请皮肤站注册邀请码",
//// "申请土豆服务器注册邀请码",
//// "申请LTD邀请码",
//// "Apply for an invitation code"
//// )
//// )
//// val commonMcServerStatusModule = McServerStatusModule(
//// moduleName = "CommonGroup",
//// groupMessagePollingModule = commonGroupMsgPollingModule,
//// selfId = selfQQId,
//// cooldownMillis = 20_000L,
//// selfNickName = selfNickName,
//// commands = listOf("/m", "/mcs", "seek", "s", "test"),
//// presetServer = mapOf(
//// setOf("老土豆", "七周目", "7" ,"ZZ", "zz", "Zz", "seven") to "main.mmccdd.top:11106",
//// setOf("土豆", "八周目", "8" ,"39", "eight") to "ac.r3944realms.top"
//// )
//// )
//// val whitelistMcServerStatusModule = McServerStatusModule(
//// moduleName = "WhiteListGroup",
//// groupMessagePollingModule = whiteListGroupMsgPollingModule,
//// selfId = selfQQId,
//// cooldownMillis = 20_000L,
//// selfNickName = selfNickName,
//// commands = listOf("/m", "/mcs", "seek", "s", "test"),
//// presetServer = mapOf(
//// setOf("老土豆", "七周目", "7" ,"ZZ", "zz", "Zz", "seven") to "main.mmccdd.top:11106",
//// setOf("土豆", "八周目", "8" ,"39", "eight") to "ac.r3944realms.top"
//// )
//// )
// val dgLabModule = DGLabModule(
// moduleName = "DG",
// groupMessagePollingModule = commonGroupMsgPollingModule,
// selfId = selfQQId,
// adminIds = listOf(2561098830L),
// commandHead = listOf("dglab")
// )
//
// // 注册模块到全局模块管理器
// GlobalManager.moduleManager.register(groupModule)
// GlobalManager.moduleManager.register(commonGroupMsgPollingModule)
// GlobalManager.moduleManager.register(whiteListGroupMsgPollingModule)
//// GlobalManager.moduleManager.registerModule(commonMcServerStatusModule)
// GlobalManager.moduleManager.register(rconModule)
// GlobalManager.moduleManager.register(corconModule)
//// GlobalManager.moduleManager.registerModule(whitelistMcServerStatusModule)
//// GlobalManager.moduleManager.registerModule(mailModule)
//// GlobalManager.moduleManager.registerModule(invitationCodesModule)
// GlobalManager.moduleManager.register(whitelistHelpModule)
// GlobalManager.moduleManager.register(commonHelpModule)
// GlobalManager.moduleManager.register(dgLabModule)
//// GlobalManager.moduleManager.registerModule(banModule)
//// GlobalManager.moduleManager.registerModule(modGroupHandlerModule)
//
// // 加载模块
// GlobalManager.moduleManager.load(groupModule.name)
// GlobalManager.moduleManager.load(commonGroupMsgPollingModule.name)
// GlobalManager.moduleManager.load(whiteListGroupMsgPollingModule.name)
//// GlobalManager.moduleManager.loadModule(commonMcServerStatusModule.name)
// GlobalManager.moduleManager.load(corconModule.name)
// GlobalManager.moduleManager.load(rconModule.name)
//// GlobalManager.moduleManager.loadModule(mailModule.name)
//// GlobalManager.moduleManager.loadModule(invitationCodesModule.name)
// GlobalManager.moduleManager.load(commonHelpModule.name)
//// GlobalManager.moduleManager.loadModule(whitelistMcServerStatusModule.name)
// GlobalManager.moduleManager.load(whitelistHelpModule.name)
// GlobalManager.moduleManager.load(dgLabModule.name)
//// GlobalManager.moduleManager.loadModule(banModule.name)
//// GlobalManager.moduleManager.loadModule(modGroupHandlerModule.name)
} }

View File

@ -0,0 +1,211 @@
package top.r3944realms.ltdmanager.mcms
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.core.config.YamlConfigLoader
import top.r3944realms.ltdmanager.mcms.request.MCSMRequest
import top.r3944realms.ltdmanager.mcms.response.FailedMCSMResponse
import top.r3944realms.ltdmanager.mcms.response.MCSMResponse
import top.r3944realms.ltdmanager.mcms.response.ResponseResult
import top.r3944realms.ltdmanager.utils.Environment
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.net.URLEncoder
import java.util.*
class MCSMClient 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<MCSMSkinQueueItem<MCSMResponse, FailedMCSMResponse>>(compareBy { it.priority })
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
startQueueProcessor()
}
/**
* 提交请求
*/
suspend fun <T : MCSMResponse, F : FailedMCSMResponse> submitRequest(
request: MCSMRequest<T, F>,
priority: Int = 5,
maxRetries: Int = 3
): ResponseResult<T, F> {
val deferred = CompletableDeferred<ResponseResult<T, F>>()
requestMutex.withLock {
requestQueue.add(MCSMSkinQueueItem(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: MCSMSkinQueueItem<MCSMResponse, FailedMCSMResponse>) {
semaphore.withPermit {
val (request, deferred, _, maxRetries, _) = 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<MCSMResponse, FailedMCSMResponse>>).complete(result)
return
} catch (e: Exception) {
lastError = e
attempt++
if (!request.shouldRetryOnFailure() || attempt >= maxRetries) {
break
}
LoggerUtil.logger.warn("MCSM请求失败 (尝试 $attempt/$maxRetries): ${e.message}")
delay((attempt * 1000L)) // 指数退避
}
}
// 所有重试都失败或不应重试
val errorResponse = createFailureResponse(lastError)
@Suppress("UNCHECKED_CAST")
(deferred as CompletableDeferred<ResponseResult<MCSMResponse, FailedMCSMResponse>>).complete(
ResponseResult.Failure(errorResponse)
)
}
}
/**
* 构建完整的URL包含查询参数
*/
private fun buildFullUrlWithQueryParams(request: MCSMRequest<*, *>): 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?
): FailedMCSMResponse {
return FailedMCSMResponse.ExceptionFailedMCSMResponse(
result = exception?.message ?: "未知错误",
)
}
override fun close() {
scope.cancel()
runBlocking {
client.close()
}
}
companion object {
fun create(): MCSMClient = MCSMClient()
}
}

View File

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

View File

@ -0,0 +1,80 @@
package top.r3944realms.ltdmanager.mcms.request
import io.ktor.http.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import top.r3944realms.ltdmanager.mcms.response.FailedMCSMResponse
import top.r3944realms.ltdmanager.mcms.response.MCSMResponse
import top.r3944realms.ltdmanager.mcms.response.ResponseResult
@Serializable
abstract class MCSMRequest<out T : MCSMResponse, out F : FailedMCSMResponse>(
@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,50 @@
package top.r3944realms.ltdmanager.mcms.request.instance
import io.ktor.http.*
import kotlinx.serialization.Serializable
import top.r3944realms.ltdmanager.mcms.request.MCSMRequest
import top.r3944realms.ltdmanager.mcms.response.FailedMCSMResponse
import top.r3944realms.ltdmanager.mcms.response.ResponseResult
import top.r3944realms.ltdmanager.mcms.response.instance.GetInstanceListResponse
@Serializable
class GetInstanceListRequest(
private val daemonId: String,
private val page: Int,
private val pageSize: Int,
private val status: String,
private val instanceName: String? = null
) : MCSMRequest<GetInstanceListResponse, FailedMCSMResponse>() {
override fun toJSON(): String = "{}" // GET 无请求体
override fun path(): String = "api/service/remote_service_instances"
override fun queryParameters(): Map<String, String> =
buildMap {
put("daemonId", daemonId)
put("page", page.toString())
put("page_size", pageSize.toString())
put("status", status)
instanceName?.let { put("instance_name", it) }
}
override fun getResponse(
responseJson: String,
httpStatusCode: HttpStatusCode
): ResponseResult<GetInstanceListResponse, FailedMCSMResponse> {
return if (httpStatusCode.value in 200..299) {
ResponseResult.Success(
kotlinx.serialization.json.Json.decodeFromString<GetInstanceListResponse>(responseJson)
)
} else {
ResponseResult.Failure(
kotlinx.serialization.json.Json.decodeFromString<FailedMCSMResponse>(responseJson)
)
}
}
override fun expectedResponseType(): String = GetInstanceListResponse::class.simpleName!!
override fun expectedFailureType(): String = FailedMCSMResponse::class.simpleName!!
}

View File

@ -0,0 +1,53 @@
package top.r3944realms.ltdmanager.mcms.request.instance
import io.ktor.http.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import top.r3944realms.ltdmanager.mcms.request.MCSMRequest
import top.r3944realms.ltdmanager.mcms.response.FailedMCSMResponse
import top.r3944realms.ltdmanager.mcms.response.ResponseResult
import top.r3944realms.ltdmanager.mcms.response.instance.StartInstanceResponse
/**
* 启动实例请求
* GET /api/protected_instance/open
*/
@Serializable
data class StartInstanceRequest(
val uuid: String,
val daemonId: String
) : MCSMRequest<StartInstanceResponse, FailedMCSMResponse>() {
override fun toJSON(): String =
Json.encodeToString(this)
override fun path(): String =
"protected_instance/open"
override fun queryParameters(): Map<String, String> =
mapOf(
"uuid" to uuid,
"daemonId" to daemonId
)
override fun method(): HttpMethod = HttpMethod.Get
override fun getResponse(
responseJson: String,
httpStatusCode: HttpStatusCode
): ResponseResult<StartInstanceResponse, FailedMCSMResponse> {
return if (httpStatusCode.value == 200) {
val obj = Json.decodeFromString(StartInstanceResponse.serializer(), responseJson)
ResponseResult.Success(obj)
} else {
val fail = Json.decodeFromString(FailedMCSMResponse.serializer(), responseJson)
ResponseResult.Failure(fail)
}
}
override fun expectedResponseType(): String = "StartInstanceResponse"
override fun expectedFailureType(): String = "FailedMCSMResponse"
}

View File

@ -0,0 +1,29 @@
package top.r3944realms.ltdmanager.mcms.response
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.JsonObject
@Serializable
open class FailedMCSMResponse(
@Transient
val status0: Status = Status.Ok,
val data: JsonObject? = null,
@Transient
val time0: Long = -1,
): MCSMResponse(
status0, time0
) {
@Serializable
data class ExceptionFailedMCSMResponse(
@Transient
val status1: Status = Status.Ok,
val data0: String? = null,
@Transient
val time1: Long = -1,
@Transient
val result: String? = null,
): FailedMCSMResponse(
status1, null, time1
)
}

View File

@ -0,0 +1,50 @@
package top.r3944realms.ltdmanager.mcms.response
import io.ktor.http.*
import kotlinx.serialization.SerialName
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.mcms.response.instance.GetInstanceListResponse
import top.r3944realms.ltdmanager.mcms.response.instance.StartInstanceResponse
@Serializable
abstract class MCSMResponse (
open val status: Status,
open val time: Long,
@Transient
open val httpStatusCode: HttpStatusCode = HttpStatusCode.OK,
@Transient
open val createTime: Long = System.currentTimeMillis()
) {
companion object {
// 通用的反序列化方法
inline fun <reified T : MCSMResponse> decode(jsonString: String): T {
return json.decodeFromString(jsonString)
}
val json: Json by lazy {
Json {
ignoreUnknownKeys = true
serializersModule = SerializersModule {
polymorphic(MCSMResponse::class) {
subclass(GetInstanceListResponse::class, GetInstanceListResponse.serializer())
subclass(StartInstanceResponse::class, StartInstanceResponse.serializer())
}
}
}
}
}
@Serializable
enum class Status(val value: String) {
@SerialName("200") Ok("200"),
@SerialName("400") ParamsNotRight("400"),
@SerialName("403") PermissionDenied("403"),
@SerialName("500") InternalServerError("500");
companion object {
fun isOk(value: Status): Boolean = value == Ok
}
}
}

View File

@ -0,0 +1,38 @@
package top.r3944realms.ltdmanager.mcms.response
// 响应结果封装
sealed class ResponseResult<out T : MCSMResponse, out F : FailedMCSMResponse> {
data class Success<T : MCSMResponse>(val response: T) : ResponseResult<T, Nothing>()
data class Failure<F : FailedMCSMResponse>(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,60 @@
package top.r3944realms.ltdmanager.mcms.response.instance
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.JsonObject
import top.r3944realms.ltdmanager.mcms.response.MCSMResponse
@Serializable
data class GetInstanceListResponse(
@Transient
val status0: Status = Status.Ok,
val data: InstanceListData? = null,
@Transient
val time0: Long = -1,
) : MCSMResponse(status0, time0) {
@Serializable
data class InstanceListData(
val maxPage: Int,
val pageSize: Int,
val data: List<InstanceDetail>
)
@Serializable
data class InstanceDetail(
val config: JsonObject? = null, //TODO: 不清楚是干什么的,需验证
val info: InstanceInfo,
val instanceUuid: String,
val processInfo: ProcessInfo,
val space: Long,
val started: Int,
val status: Int
)
@Serializable
data class InstanceInfo(
val currentPlayers: Int,
val fileLock: Int,
val maxPlayers: Int,
val openFrpStatus: Boolean,
val playersChart: List<PlayerChartItem>,
val version: String
)
@Serializable
data class PlayerChartItem(
val time: Long? = null,
val players: Int? = null
)
@Serializable
data class ProcessInfo(
val cpu: Double,
val memory: Long,
val ppid: Long,
val pid: Long,
val ctime: Long,
val elapsed: Long,
val timestamp: Long
)
}

View File

@ -0,0 +1,21 @@
package top.r3944realms.ltdmanager.mcms.response.instance
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import top.r3944realms.ltdmanager.mcms.response.MCSMResponse
@Serializable
data class StartInstanceResponse(
@Transient
val status0: Status = Status.Ok,
val data: StartInstanceData,
@Transient
val time0: Long = -1
) : MCSMResponse(status0, time0){
@Serializable
data class StartInstanceData(
val instanceUuid: String
)
}

View File

@ -0,0 +1,17 @@
package top.r3944realms.ltdmanager.module
class ApplyWhitelistModule(
moduleName: String,
private val groupMessagePollingModule: GroupMessagePollingModule,
private val cooldownMillis: Long = 120_000,
private val keywords: Set<String> = setOf("申请白名单")
):
BaseModule(Modules.APPLY_WHITELIST,moduleName) {
override fun onLoad() {
TODO("Not yet implemented")
}
override suspend fun onUnload() {
TODO("Not yet implemented")
}
}

View File

@ -0,0 +1,344 @@
package top.r3944realms.ltdmanager.module
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import top.r3944realms.ltdmanager.module.common.CommandParser
import top.r3944realms.ltdmanager.module.common.filter.TriggerMessageFilter
import top.r3944realms.ltdmanager.module.common.filter.type.IgnoreSelfFilter
import top.r3944realms.ltdmanager.module.common.filter.type.MultiCommandFilter
import top.r3944realms.ltdmanager.module.common.filter.type.NewMessageFilter
import top.r3944realms.ltdmanager.napcat.data.ID
import top.r3944realms.ltdmanager.napcat.data.MessageElement
import top.r3944realms.ltdmanager.napcat.data.MessageType
import top.r3944realms.ltdmanager.napcat.data.msghistory.MsgHistorySpecificMsg
import top.r3944realms.ltdmanager.napcat.event.group.GetGroupShutListEvent
import top.r3944realms.ltdmanager.napcat.request.group.GetGroupShutListRequest
import top.r3944realms.ltdmanager.napcat.request.group.SetGroupBanRequest
import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.io.File
import kotlin.random.Random
/**
* 指令触发禁言模块
*/
class BanModule(
moduleName: String,
private val groupMessagePollingModule : GroupMessagePollingModule,
private val selfId: Long,
private val adminsId: List<Long> = listOf(),
muteCommandPrefixList: List<String> = listOf("mute"), // 默认命令前缀
unmuteCommandPrefixList: List<String> = listOf("unmute"),
private val minBanMinutes: Int = 1,
private val maxBanMinutes: Int = 15,
private val factorX: Int = 2, // 系数 x禁言倍数
) : BaseModule(Modules.BAN, moduleName), PersistentState<BanModule.BanState> {
private val banCommandParse = CommandParser(muteCommandPrefixList)
private val pardonCommandParse = CommandParser(unmuteCommandPrefixList)
private val multiCommandFilter = MultiCommandFilter(listOf(banCommandParse, pardonCommandParse))
private val stateFile: File = getStateFileInternal("command_ban_state.json", name)
private val stateBackupFile: File = getStateFileInternal("command_ban_state.json.bak", name)
private var banState = loadState()
override fun getState(): BanState = banState
private val triggerFilter by lazy {
TriggerMessageFilter(
listOf(
IgnoreSelfFilter(selfId),
NewMessageFilter { userId ->
banState.getLastTriggerTime(userId) to banState.getLastTriggerRealId(userId)
},
multiCommandFilter
)
)
}
private var scope: CoroutineScope? = null
override fun getStateFileInternal(): File = stateFile
override fun onLoad() {
LoggerUtil.logger.info("[$name] 模块已装载,监听群组: ${groupMessagePollingModule.targetGroupId}")
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope!!.launch {
LoggerUtil.logger.info("[$name] 启动消息监听协程")
groupMessagePollingModule.messagesFlow.collect { messages ->
handleMessages(messages)
}
}
}
override suspend fun onUnload() {
LoggerUtil.logger.info("[$name] 模块卸载,取消协程")
scope?.cancel()
}
private suspend fun handleMessages(messages: List<MsgHistorySpecificMsg>) {
// 先过一遍过滤器,只有符合条件的才进入后续处理
val filtered = triggerFilter.filter(messages)
for (msg in filtered) {
processBanCommand(msg)
processUnBanCommand(msg)
}
}
/**
* SpecificMsg 中的 message 段拼成一条可解析文本
* - text 段直接拼接
* - 如果消息段里包含 @ MessageData 中为 qq 字段则拼成 "@{qq}"方便 parseMentionToUserId 解析
*/
private fun MsgHistorySpecificMsg.plainText(): String {
return this.message.joinToString(" ") { seg ->
// 如果 message element 包含 qq 字段(即@用户),优先使用它
seg.data.qq?.let { "@${it}" } ?: (seg.data.text ?: "")
}.trim()
}
/**
* 从消息段中提取所有被 @ 的用户 ID
*/
private fun MsgHistorySpecificMsg.getMentionedUserIds(): List<ID> {
return this.message
.filter { it.type == MessageType.At && it.data.qq != null }
.mapNotNull { it.data.qq }
.distinctBy {
when (it) {
is ID.StringValue -> it.value
is ID.LongValue -> it.value
}
}
}
private suspend fun processUnBanCommand(msg: MsgHistorySpecificMsg) {
try {
pardonCommandParse.parseCommand(msg.plainText()) ?: return
// 获取所有被 @ 的用户
val mentionedUserIds = msg.getMentionedUserIds().map {
when (it) {
is ID.StringValue -> it.value.toLong()
is ID.LongValue -> it.value
}
} // List<Long>
val send =
napCatClient.send<GetGroupShutListEvent>(GetGroupShutListRequest(ID.long(groupMessagePollingModule.targetGroupId)))
val muteList = send.data.map { it.uin.toLong() }
for (target in mentionedUserIds) {
if(target !in muteList) {
sendGroupMessage("❌ 目标用户未被禁言",
msg.realId
)
} else {
banUser(ID.long(target), groupMessagePollingModule.targetGroupId, 0)
sendGroupMessage(
"✅ 已解禁对方@(${target})",
msg.realId
)
}
}
// 更新状态
banState = banState.updateLastTrigger(msg.userId, msg.realId, msg.time)
saveState(banState)
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 执行解禁言指令失败", e)
sendGroupMessage("❌ 执行解禁言失败,请检查解指令格式或权限", msg.realId)
banState = banState.updateLastTrigger(msg.sender.userId, msg.realId, msg.time)
saveState(banState)
}
}
private suspend fun processBanCommand(msg: MsgHistorySpecificMsg) {
try {
val parsed = banCommandParse.parseCommand(msg.plainText()) ?: return
val (_, argument) = parsed
val parts = argument.split(" ").filter { it.isNotBlank() }
// 解析禁言时间
val durationMinutes = parts.getOrNull(0)?.toIntOrNull()
?: Random.nextInt(minBanMinutes, maxBanMinutes + 1)
val durationSeconds = durationMinutes.coerceIn(minBanMinutes, maxBanMinutes) * 60
// 获取所有被 @ 的用户
val mentionedUserIds = msg.getMentionedUserIds() // List<ID>
val targets = mentionedUserIds.ifEmpty { listOf(ID.long(msg.sender.userId)) }
for (target in targets) {
val targetLongId = when (target) {
is ID.StringValue -> target.value.toLong()
is ID.LongValue -> target.value
}
// 权限检查:非管理员不能禁言多个他人
if (mentionedUserIds.isNotEmpty() && mentionedUserIds.size != 1 && msg.sender.userId !in adminsId) {
sendGroupMessage("❌ 你没有权限使用禁言多用户功能", msg.realId)
continue
}
// 禁言机器人跳过
if (targetLongId == selfId) {
sendGroupMessage("❌ 你没有权限禁言机器人", msg.realId)
continue
}
if (targetLongId in adminsId) {
sendGroupMessage("❌ 不支持禁言管理员", msg.realId)
continue
}
// 单 @ 且非自己,可能触发反禁自己
if (mentionedUserIds.size == 1 && targetLongId != msg.sender.userId && msg.sender.userId !in adminsId) {
val dice = Random.nextInt(1, 7) // 1~6
val chance = when (dice) {
6 -> 100
5 -> 80
4 -> 60
3 -> 50
2 -> 20
1 -> 0
else -> 0
}
val selfDuration = durationSeconds * factorX
if (Random.nextInt(0,100) > chance) {
// 触发反禁自己
banUser(ID.long(msg.sender.userId), groupMessagePollingModule.targetGroupId, selfDuration)
sendGroupMessage(
"⚠️ 骰子点数: $dice, 成功概率: ${chance}% → 失败,你触发了反禁,禁言 ${selfDuration / 60} 分钟",
msg.realId
)
} else {
// 未触发反禁自己,禁言目标
banUser(target, groupMessagePollingModule.targetGroupId, durationSeconds)
sendGroupMessage(
"✅ 骰子点数: $dice, 成功概率: ${chance}% → 成功禁言 <@${targetLongId}>",
msg.realId
)
}
} else {
// 多 @ 或管理员操作,直接禁言目标
banUser(target, groupMessagePollingModule.targetGroupId, durationSeconds)
sendGroupMessage(
if (targetLongId == msg.sender.userId) {
"✅ 你已被禁言 ${durationSeconds/ 60} 分钟"
} else {
"✅ 已禁言 <@${targetLongId}> ${durationSeconds/ 60} 分钟"
},
msg.realId
)
}
}
// 更新状态
banState = banState.updateLastTrigger(msg.userId, msg.realId, msg.time)
saveState(banState)
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 执行禁言指令失败", e)
sendGroupMessage("❌ 执行禁言失败,请检查指令格式或权限", msg.realId)
banState = banState.updateLastTrigger(msg.sender.userId, msg.realId, msg.time)
saveState(banState)
}
}
private suspend fun banUser(userId: ID, groupId: Long, seconds: Int) {
val request = SetGroupBanRequest(
duration = seconds.toDouble(),
groupId = ID.long(groupId),
userId = userId
)
napCatClient.sendUnit(request)
LoggerUtil.logger.info("[$name] 已对用户 $userId 执行 $seconds 秒禁言")
}
private suspend fun sendGroupMessage(text: String, replyTo: Long? = null) {
val request = SendGroupMsgRequest(
MessageElement.reply(ID.long(replyTo ?: 0), text),
ID.long(groupMessagePollingModule.targetGroupId)
)
napCatClient.sendUnit(request)
}
override fun info(): String {
return buildString {
append("[$name] 指令禁言模块:\n")
append(" 管理员用户ID: ${adminsId}\n")
append(" - 用户发送 ${banCommandParse.getCommands().joinToString("、")} 来禁言自己或指定其他用户(需管理员权限)。\n")
append(" - 支持指定禁言分钟数或随机分钟数,范围 $minBanMinutes-$maxBanMinutes 分钟。\n")
append(" - 支持对单个 @ 用户禁言,有概率反禁自己(骰子点数决定概率)。\n")
append(" - 管理员可以禁言其他用户;非管理员尝试多个禁言对象会收到无权限提示。\n")
append(" - 用户发送 ${pardonCommandParse.getCommands().joinToString("、")} 来解禁指定用户。\n")
append(" - 仅支持对单个 @ 用户解禁言。\n")
}
}
override fun help(): String {
return buildString {
appendLine("📖 [$name] 使用帮助:")
appendLine("指令格式:${banCommandParse.getCommands().joinToString("、")} [分钟] [@用户...]")
appendLine("示例:")
appendLine(" - <指令> → 随机禁言自己")
appendLine(" - <指令> 5 → 禁言自己 5 分钟")
appendLine(" - <指令> 4 @User123 → 禁言指定用户 4 分钟(可能失败)")
appendLine(" - <指令> 4 @User123 @User22 → 禁言指定多用户 4 分钟(需在程序管理员列表中)")
appendLine()
appendLine("⚠️ 特殊说明:")
appendLine(" - 如果 @ 单个用户且执行者非需在程序管理员,有 y% 概率触发反禁自己,")
appendLine(" 骰子点数决定概率6 → 100%, 5 → 80%, 4 → 60%, 3 → 50%, 2 → 20%, 1 → 0%")
appendLine(" - 禁言机器人自身不会生效")
appendLine(" - 禁言状态会自动保存以便下次使用")
appendLine()
appendLine("指令格式:${pardonCommandParse.getCommands().joinToString("、")} [@用户]")
appendLine("示例:")
appendLine(" - <指令> @User123 → 解禁指定用户")
}
}
// ---------------- 持久化 ----------------
@Serializable
data class UserBanDetail(
val realId: Long,
val time: Long,
)
@Serializable
data class BanState(
val map: Map<Long, UserBanDetail> = emptyMap()
) {
fun getLastTriggerTime(userId: Long): Long = map[userId]?.time ?: -1
fun getLastTriggerRealId(userId: Long): Long = map[userId]?.realId ?: -1
fun updateLastTrigger(userId: Long, realId: Long, time: Long = -1): BanState {
val old = map[userId]
val newTime = if (time != -1L) time else old?.time ?: -1
val newMap = map.toMutableMap().apply {
put(userId, UserBanDetail(realId, newTime))
}
return copy(map = newMap)
}
}
override fun saveState(state: BanState) {
try {
if (stateFile.exists()) stateFile.copyTo(stateBackupFile, overwrite = true)
stateFile.writeText(Json.encodeToString(state))
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 保存状态失败", e)
}
}
override fun loadState(): BanState {
return try {
val fileToRead = when {
stateFile.exists() -> stateFile
stateBackupFile.exists() -> stateBackupFile
else -> null
} ?: return BanState()
Json.decodeFromString<BanState>(fileToRead.readText())
} catch (e: Exception) {
LoggerUtil.logger.warn("[$name] 读取状态失败", e)
BanState()
}
}
}

View File

@ -1,6 +1,7 @@
package top.r3944realms.ltdmanager.module package top.r3944realms.ltdmanager.module
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import org.intellij.lang.annotations.MagicConstant
import top.r3944realms.ltdmanager.GlobalManager import top.r3944realms.ltdmanager.GlobalManager
import top.r3944realms.ltdmanager.utils.LoggerUtil import top.r3944realms.ltdmanager.utils.LoggerUtil
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@ -9,12 +10,12 @@ import kotlin.coroutines.cancellation.CancellationException
* 模块抽象基类 * 模块抽象基类
* 所有功能模块都继承该类 * 所有功能模块都继承该类
*/ */
abstract class BaseModule { abstract class BaseModule(baseName : String = "BaseModule", idName : String = "") {
/** /**
* 模块名称 * 模块名称
*/ */
abstract val name: String val name: String = "$baseName-#$idName";
/** /**
* 停止信号 * 停止信号
@ -73,21 +74,57 @@ abstract class BaseModule {
} catch (_: CancellationException) {} } catch (_: CancellationException) {}
LoggerUtil.syncInfo("[$name] 模块已安全停止") LoggerUtil.syncInfo("[$name] 模块已安全停止")
} }
/**
* 模块说明 / 帮助信息
* 默认返回空字符串子类可重写提供具体帮助文本
*/
open fun help(): String = ""
/** 模块基础信息,用于 HelpModule 显示 */
open fun info(): String = "模块 $name 未提供详细信息"
/** /**
* 提供访问全局 NapCatClient 的快捷方式 * 提供访问全局 NapCatClient 的快捷方式
*/ */
protected val napCatClient get() = GlobalManager.napCatClient protected val napCatClient get() = GlobalManager.napCatClient
/** /**
* 提供访问全局 blessingSkinClient 的快捷方式 * 提供访问全局 blessingSkinClient 的快捷方式
*/ */
protected val blessingSkinClient get() = GlobalManager.blessingSkinClient protected val blessingSkinClient get() = GlobalManager.blessingSkinClient
/** /**
* 提供访问全局 mcSrvStatusClient 的快捷方式 * 提供访问全局 mcSrvStatusClient 的快捷方式
*/ */
protected val mcSrvStatusClient get() = GlobalManager.mcSrvStatusClient protected val mcSrvStatusClient get() = GlobalManager.mcSrvStatusClient
/**
* 提供访问全局 加载模块 的快捷方式
*/
protected val moduleMap get() = GlobalManager.moduleManager.getModules()
/** /**
* 获取数据库连接 * 获取数据库连接
* 使用 try-with-resources 时会自动关闭 * 使用 try-with-resources 时会自动关闭
*/ */
protected fun getConnection() = GlobalManager.getConnection() protected fun getConnection() = GlobalManager.getConnection()
/**
* 安全获取 NapCatClient避免空指针异常
*/
protected fun getNapCatClientOrNull() = try {
GlobalManager.napCatClient
} catch (e: Exception) {
LoggerUtil.logger.warn("获取NapCatClient失败", e)
null
}
/**
* 安全获取 NapCatClient如果获取失败则抛出详细异常
*/
protected fun getNapCatClientOrThrow(): Any {
val client = try {
GlobalManager.napCatClient
} catch (e: Exception) {
throw IllegalStateException("无法获取NapCatClient请检查GlobalManager初始化状态", e)
}
return client ?: throw IllegalStateException("NapCatClient为null请检查GlobalManager初始化")
}
} }

View File

@ -0,0 +1,489 @@
package top.r3944realms.ltdmanager.module
import com.mojang.brigadier.CommandDispatcher
import com.mojang.brigadier.arguments.IntegerArgumentType
import com.mojang.brigadier.arguments.LongArgumentType
import com.mojang.brigadier.arguments.StringArgumentType
import com.mojang.brigadier.builder.LiteralArgumentBuilder.literal
import com.mojang.brigadier.builder.RequiredArgumentBuilder.argument
import com.mojang.brigadier.exceptions.CommandSyntaxException
import com.r3944realms.dg_lab.api.message.IPowerBoxMsg
import com.r3944realms.dg_lab.api.message.argType.ChangePolicy
import com.r3944realms.dg_lab.api.message.argType.Channel
import com.r3944realms.dg_lab.api.websocket.message.MessageDirection
import com.r3944realms.dg_lab.manager.DGPBClientManager
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import top.r3944realms.ltdmanager.GlobalManager
import top.r3944realms.ltdmanager.dglab.DgLab
import top.r3944realms.ltdmanager.dglab.model.game.GameClientOperation
import top.r3944realms.ltdmanager.dglab.model.game.GameServerOperation
import top.r3944realms.ltdmanager.dglab.model.game.Player
import top.r3944realms.ltdmanager.dglab.model.pulseware.DefaultPulseData
import top.r3944realms.ltdmanager.module.common.filter.TriggerMessageFilter
import top.r3944realms.ltdmanager.module.common.filter.type.IgnoreSelfFilter
import top.r3944realms.ltdmanager.module.common.filter.type.KeywordFilter
import top.r3944realms.ltdmanager.module.common.filter.type.NewMessageFilter
import top.r3944realms.ltdmanager.napcat.NapCatClient
import top.r3944realms.ltdmanager.napcat.data.ID
import top.r3944realms.ltdmanager.napcat.data.MessageElement
import top.r3944realms.ltdmanager.napcat.data.MessageType
import top.r3944realms.ltdmanager.napcat.data.msghistory.MsgHistorySpecificMsg
import top.r3944realms.ltdmanager.napcat.event.group.GetGroupMemberListEvent
import top.r3944realms.ltdmanager.napcat.request.group.GetGroupMemberListRequest
import top.r3944realms.ltdmanager.napcat.request.message.SetMsgEmojiLikeRequest
import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.io.File
import kotlin.math.abs
/**
* 数据 {QQ}
*/
class DGLabModule(
moduleName: String,
private val groupMessagePollingModule : GroupMessagePollingModule,
private val selfId: Long,
val adminIds: List<Long> = listOf(),
val maxClientNumber: Int = 10,
val commandHead: List<String> = listOf("dglab"),
) : BaseModule(Modules.DG_LAB, moduleName), PersistentState<DGLabModule.DgLabState> {
var dgLabManager: DgLab? = null
private var scope: CoroutineScope? = null
private var dglabCommandDispatcher: CommandDispatcher<Player> = CommandDispatcher<Player>().apply {
for (command in commandHead) register(
literal<Player>(command)
.then(literal<Player?>("server").requires { adminIds.contains(it.id) }
.then(literal<Player?>("start").executes { startDgLab() })
.then(literal<Player?>("stop").executes { stopDgLab() })
.then(literal<Player?>("stopAllClient").executes { stopAllDgLabClient() })
)
.then(literal<Player?>("client")
.then(literal<Player?>("start").executes { startClient(it.source.id) })
.then(literal<Player?>("stop").executes { stopClient(it.source.id) })
)
.then(literal<Player?>("strength")
.then(argument<Player?, String>("channel", StringArgumentType.string())
.then(literal<Player?>("add")
.then(argument<Player?, Int>("value", IntegerArgumentType.integer(-200, 200))
.executes { strengthAdd(it.source.id, StringArgumentType.getString(it, "channel"), IntegerArgumentType.getInteger(it, "value")) }
)
)
.then(literal<Player?>("set")
.then(argument<Player?, Int>("value", IntegerArgumentType.integer(0, 200))
.executes { strengthSet(it.source.id, StringArgumentType.getString(it, "channel"), IntegerArgumentType.getInteger(it, "value")) }
)
)
)
.then(argument<Player?, Long>("player", LongArgumentType.longArg())
.then(argument<Player?, String>("channel", StringArgumentType.string())
.then(literal<Player?>("add")
.then(argument<Player?, Int>("value", IntegerArgumentType.integer(-200, 200))
.executes { strengthAdd(LongArgumentType.getLong(it, "player"), StringArgumentType.getString(it, "channel"), IntegerArgumentType.getInteger(it, "value")) }
)
)
.then(literal<Player?>("set")
.then(argument<Player?, Int>("value", IntegerArgumentType.integer(0, 200))
.executes { strengthSet(LongArgumentType.getLong(it, "player"), StringArgumentType.getString(it, "channel"), IntegerArgumentType.getInteger(it, "value")) }
)
)
)
)
)
.then(literal<Player?>("pulse")
.then(argument<Player?, String>("channel", StringArgumentType.string())
.then(literal<Player?>("clear").executes { pulseClear(it.source.id, StringArgumentType.getString(it, "channel")) })
.then(literal<Player?>("set")
.then(argument<Player?, String>("pulseName", StringArgumentType.string())
.then(argument<Player?, Int>("timer", IntegerArgumentType.integer(0, Int.MAX_VALUE))
.executes { pulseSet(it.source.id, StringArgumentType.getString(it, "channel"), StringArgumentType.getString(it, "pulseName"), IntegerArgumentType.getInteger(it, "timer")) }
)
)
)
)
.then(argument<Player?, Long>("player", LongArgumentType.longArg())
.then(argument<Player?, String>("channel", StringArgumentType.string())
.then(literal<Player?>("clear").executes { pulseClear(LongArgumentType.getLong(it, "player"), StringArgumentType.getString(it, "channel")) })
.then(literal<Player?>("set")
.then(argument<Player?, String>("pulseName", StringArgumentType.string())
.then(argument<Player?, Int>("timer", IntegerArgumentType.integer(0, Int.MAX_VALUE))
.executes { pulseSet(LongArgumentType.getLong(it, "player"), StringArgumentType.getString(it, "channel"), StringArgumentType.getString(it, "pulseName"), IntegerArgumentType.getInteger(it, "timer")) }
)
)
)
)
)
)
)
// .then(literal<Player?>("info").executes {}
// .then(argument<Player?, String>("player", StringArgumentType.string()).executes {})
// )
}
private val stateFile: File = getStateFileInternal("dg_lab_state.json", name)
private val stateBackupFile: File = getStateFileInternal("dg_lab_state.json.bak", name)
private var dgLabState = loadState()
override fun getState(): DgLabState = dgLabState
override fun getStateFileInternal(): File = stateFile
private val triggerFilter by lazy {
TriggerMessageFilter(
listOf(
IgnoreSelfFilter(selfId),
NewMessageFilter { userId ->
dgLabState.getLastTriggerTime(userId) to dgLabState.getLastTriggerRealId(userId)
},
KeywordFilter(commandHead.toSet())
)
)
}
override fun onLoad() {
LoggerUtil.logger.info("[$name] 模块已装载,监听群组: ${groupMessagePollingModule.targetGroupId}")
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope!!.launch {
LoggerUtil.logger.info("[$name] 轮询协程启动")
dgLabManager = DgLab()
val gameServerOperation = GameServerOperation(napCatClient, groupMessagePollingModule.targetGroupId)
dgLabManager?.createServerManager(gameServerOperation)?.let { dgLabManager?.initServerManager(it) }
gameServerOperation.serverManager = dgLabManager?.serverManager
init()
groupMessagePollingModule.messagesFlow.collect { messages ->
if (loaded) handleMessages(messages)
}
}
}
override suspend fun onUnload() {
saveState(dgLabState)
dgLabManager?.close()
scope?.cancel()
LoggerUtil.logger.info("[$name] 模块已卸载完成")
}
private suspend fun handleMessages(messages: List<MsgHistorySpecificMsg>) {
if (messages.isEmpty()) return
// 先对所有消息进行 @ 提及处理
val processedMessages = messages.map { msg ->
val processedText = processMessageMentionsToLong(msg)
msg to processedText
}
val triggerMsgs = processedMessages
.filter { (msg, _) -> filterTriggerMessages(listOf(msg)).isNotEmpty() }
.map { (msg, processedText) -> Triple(msg, msg.userId, processedText) }
if (triggerMsgs.isEmpty()) return
var refPlayer: Player? = null
var refMsg: MsgHistorySpecificMsg? = null
try {
triggerMsgs.forEach { (msg, userId, processedText) ->
refMsg = msg
LoggerUtil.logger.info("[$name] 原始消息用户: $userId")
LoggerUtil.logger.info("[$name] 处理后的命令: $processedText")
refPlayer = dgLabManager?.getPlayerManager()?.getPlayer(userId)
dgLabState = dgLabState.updateOrCreate(userId, msg.realId, msg.time)
val execute = dglabCommandDispatcher.execute(processedText, refPlayer)
scope?.launch {
GlobalManager.napCatClient.sendUnit(
SetMsgEmojiLikeRequest(
if (execute == 0) 1.0 else 2.0, ID.long(msg.realId), true
)
)
}
}
} catch (e: CommandSyntaxException) {
val reader = e.input // 用户输入
val cursor = e.cursor
val partialInput = reader.substring(0, cursor)
if (refPlayer != null) {
val node = dglabCommandDispatcher.parse(
partialInput,
dgLabManager?.getPlayerManager()?.getPlayer(refPlayer!!.id)
).context.nodes.lastOrNull()?.node
val usage = if (node != null) {
val values = dglabCommandDispatcher.getSmartUsage(node, refPlayer).values
if(!values.isEmpty()) "目前节点可使用的子命令: $values"
else "目前节点无用法"
} else {
"未找到用法"
}
sendFailedMessage(
napCatClient,
text = "指令解析错误:\n ${e.message}\n\n$usage",
qq = refMsg?.userId,
realId = refMsg?.realId,
time = refMsg?.time
)
}
}
catch (e: Exception) {
sendFailedMessage(napCatClient, text = "系统错误,请联系管理员: ${e.message}")
} finally {
saveState(dgLabState)
}
}
/**
* 处理整个消息中的 @ 提及转换为 Long 类型并清理多余空格
*/
private fun processMessageMentionsToLong(msg: MsgHistorySpecificMsg): String {
val processedText = msg.message.joinToString(" ") { seg ->
when (seg.type) {
MessageType.At -> {
// 处理 @ 提及,转换为 Long
seg.data.qq?.let { qq ->
when (qq) {
is ID.StringValue -> qq.value.toLong().toString()
is ID.LongValue -> qq.value.toString()
}
} ?: seg.data.text ?: ""
}
MessageType.Text -> {
seg.data.text ?: ""
}
else -> ""
}
}.trim()
// 清理多余空格:将多个连续空格替换为单个空格
return processedText.replace(Regex("\\s+"), " ")
}
private suspend fun filterTriggerMessages(
messages: List<MsgHistorySpecificMsg>
): List<MsgHistorySpecificMsg> = triggerFilter.filter(messages)
private suspend fun init() {
val getGroupMemberListEvent = napCatClient.send<GetGroupMemberListEvent>(
GetGroupMemberListRequest(
ID.long(groupMessagePollingModule.targetGroupId),
false
)
)
dgLabManager?.initOrLoadPlayerManager(getGroupMemberListEvent.data.filter { !it.isRobot }
.associate { it.userId to it.nickname })
dgLabManager?.initClientManager()
}
// private fun getHelp(): Int {
// scope?.launch {
// sendMessage()
// }
// return 1
// }
private fun startDgLab(): Int {
dgLabManager?.getServer()?.start()
return 1
}
private fun stopDgLab(): Int {
dgLabManager?.getServer()?.stop()
return 1
}
private fun stopAllDgLabClient(): Int {
dgLabManager?.clientManager?.stopAll()
return 1
}
private fun startClient(qq: Long): Int {
if (dgLabManager?.getPlayerManager()?.getOnlinePlayerSize()!! > maxClientNumber) {
scope!!.launch {
sendFailedMessage(napCatClient, text = "无法启动新的客户端, 因为已到达最大连接数${maxClientNumber}")
}
return -1
}
val operation = GameClientOperation(
napCatClient,
groupMessagePollingModule.targetGroupId,
dgLabManager!!.getPlayerManager(),
qq
)
val dgpbClientManager = dgLabManager?.getClientOrCreate(
qq.toString(),
operation
)
operation.clientSelf = dgpbClientManager
dgpbClientManager?.start()
return 1
}
private fun stopClient(qq: Long): Int {
dgLabManager?.getClient(qq.toString())?.stop()
return 1
}
private fun strengthAdd(qq: Long, channel: String, value: Int): Int {
val client = dgLabManager?.getClient(qq.toString()) ?: return -1
val changePolicy = if(value >= 0) ChangePolicy.INCREASE else ChangePolicy.DECREASE
val strengthValue = abs(value)
when(channel) {
"a" -> sendStrengthChange(client, Channel.A, changePolicy, strengthValue)
"b" -> sendStrengthChange(client, Channel.B, changePolicy, strengthValue)
"ab" -> {
sendStrengthChange(client, Channel.A, changePolicy, strengthValue)
sendStrengthChange(client, Channel.B, changePolicy, strengthValue)
}
}
return 0
}
private fun strengthSet(qq: Long, channel: String, value: Int): Int {
val client = dgLabManager?.getClient(qq.toString()) ?: return -1
when(channel) {
"a" -> sendStrengthChange(client, Channel.A, ChangePolicy.GOTO, value)
"b" -> sendStrengthChange(client, Channel.B, ChangePolicy.GOTO, value)
"ab" -> {
sendStrengthChange(client, Channel.A, ChangePolicy.GOTO, value)
sendStrengthChange(client, Channel.B, ChangePolicy.GOTO, value)
}
}
return 0
}
private fun sendStrengthChange(client: DGPBClientManager, channel: Channel, policy: ChangePolicy, value: Int) {
client.send(IPowerBoxMsg.StrengthChange(channel, policy, value)
.toPowerBoxMessage(client.sharedData.connectionId, client.sharedData.targetWSId, MessageDirection.DirectType.CLIENT_TO_APPLICATION))
}
private fun pulseClear(qq: Long, channel: String): Int {
val client = dgLabManager?.getClient(qq.toString()) ?: return -1
when(channel) {
"a" -> client.send(IPowerBoxMsg.Clear(Channel.A)
.toPowerBoxMessage(client.sharedData.connectionId, client.sharedData.targetWSId, MessageDirection.DirectType.CLIENT_TO_APPLICATION))
"b" -> client.send(IPowerBoxMsg.Clear(Channel.B)
.toPowerBoxMessage(client.sharedData.connectionId, client.sharedData.targetWSId, MessageDirection.DirectType.CLIENT_TO_APPLICATION))
"ab" -> {
client.send(IPowerBoxMsg.Clear(Channel.A)
.toPowerBoxMessage(client.sharedData.connectionId, client.sharedData.targetWSId, MessageDirection.DirectType.CLIENT_TO_APPLICATION))
client.send(IPowerBoxMsg.Clear(Channel.B)
.toPowerBoxMessage(client.sharedData.connectionId, client.sharedData.targetWSId, MessageDirection.DirectType.CLIENT_TO_APPLICATION))
}
}
return 0
}
private fun pulseSet(qq: Long, channel: String, pulseName: String, timer: Int): Int {
val client = dgLabManager?.getClient(qq.toString()) ?: return -1
val pulse = DefaultPulseData.allPulseWaveLists()[pulseName] ?: return -2
when(channel) {
"a" -> client.send(IPowerBoxMsg.Pulse(Channel.A, pulse, timer)
.toPowerBoxMessage(client.sharedData.connectionId, client.sharedData.targetWSId, MessageDirection.DirectType.CLIENT_TO_APPLICATION))
"b" -> client.send(IPowerBoxMsg.Pulse(Channel.B, pulse, timer)
.toPowerBoxMessage(client.sharedData.connectionId, client.sharedData.targetWSId, MessageDirection.DirectType.CLIENT_TO_APPLICATION))
"ab" -> {
client.send(IPowerBoxMsg.Pulse(Channel.A, pulse, timer)
.toPowerBoxMessage(client.sharedData.connectionId, client.sharedData.targetWSId, MessageDirection.DirectType.CLIENT_TO_APPLICATION))
client.send(IPowerBoxMsg.Pulse(Channel.B, pulse, timer)
.toPowerBoxMessage(client.sharedData.connectionId, client.sharedData.targetWSId, MessageDirection.DirectType.CLIENT_TO_APPLICATION))
}
}
return 0
}
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
dgLabState = dgLabState.updateOrCreate(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
dgLabState = dgLabState.updateOrCreate(qq, realId, time)
} else {
val request = SendGroupMsgRequest(
listOf(MessageElement.text(text)),
ID.long(groupMessagePollingModule.targetGroupId)
)
client.sendUnit(request)
LoggerUtil.logger.info("[$name] 已发送 失败消息[无指定对象]")
}
}
// -------- 持久化 -----------
@Serializable
data class DgLabDetail(
val realId : Long,
val time: Long,
)
@Serializable
data class DgLabState(
val map: Map<Long, DgLabDetail> = emptyMap()
) {
fun getLastTriggerTime(userId: Long): Long = map[userId]?.time ?: -1
fun getLastTriggerRealId(userId: Long): Long = map[userId]?.realId ?: -1
/**
* 更新或创建某个用户的触发信息
* - 如果传了 realId则更新 realId
* - 如果传了 time则更新 time
* - 其他字段保持原值
*/
fun updateOrCreate(
userId: Long,
realId: Long? = null,
time: Long? = null
): DgLabState {
val old = map[userId]
val newDetail = DgLabDetail(
realId = realId ?: old?.realId ?: -1,
time = time ?: old?.time ?: -1
)
val newMap = map.toMutableMap().apply { put(userId, newDetail) }
return copy(map = newMap)
}
}
override fun saveState(state: DgLabState) {
try {
if (stateFile.exists()) stateFile.copyTo(stateBackupFile, overwrite = true)
stateFile.writeText(Json.encodeToString(state))
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 保存状态失败", e)
}
}
override fun loadState(): DgLabState {
return try {
val fileToRead = when {
stateFile.exists() -> stateFile
stateBackupFile.exists() -> stateBackupFile
else -> null
} ?: return DgLabState()
Json.decodeFromString<DgLabState>(fileToRead.readText())
} catch (e: Exception) {
LoggerUtil.logger.warn("[$name] 读取状态失败", e)
DgLabState()
}
}
}

View File

@ -5,26 +5,25 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import top.r3944realms.ltdmanager.napcat.data.ID import top.r3944realms.ltdmanager.napcat.data.ID
import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent import top.r3944realms.ltdmanager.napcat.data.msghistory.MsgHistorySpecificMsg
import top.r3944realms.ltdmanager.napcat.event.message.GetGroupMsgHistoryEvent import top.r3944realms.ltdmanager.napcat.event.message.GetGroupMsgHistoryEvent
import top.r3944realms.ltdmanager.napcat.request.message.GetGroupMsgHistoryRequest import top.r3944realms.ltdmanager.napcat.request.message.GetGroupMsgHistoryRequest
import top.r3944realms.ltdmanager.utils.LoggerUtil import top.r3944realms.ltdmanager.utils.LoggerUtil
class GroupMessagePollingModule( class GroupMessagePollingModule(
moduleName: String,
val targetGroupId: Long, val targetGroupId: Long,
private val pollIntervalMillis: Long = 5_000L, private val pollIntervalMillis: Long = 5_000L,
private val msgHistoryCheck: Int = 15 private val msgHistoryCheck: Int = 15,
) : BaseModule() { ) : BaseModule(Modules.GROUP_MESSAGE_POLLING, moduleName) {
override val name: String = "MessagePollingModule"
private var scope: CoroutineScope? = null private var scope: CoroutineScope? = null
// 用 Flow 存消息,其他模块可以订阅 // 用 Flow 存消息,其他模块可以订阅
private val _messagesFlow = MutableSharedFlow<List<GetFriendMsgHistoryEvent.SpecificMsg>>( private val _messagesFlow = MutableSharedFlow<List<MsgHistorySpecificMsg>>(
replay = 1, // 保留最近一份消息 replay = 1, // 保留最近一份消息
extraBufferCapacity = 1 extraBufferCapacity = 1
) )
val messagesFlow: SharedFlow<List<GetFriendMsgHistoryEvent.SpecificMsg>> = _messagesFlow.asSharedFlow() val messagesFlow: SharedFlow<List<MsgHistorySpecificMsg>> = _messagesFlow.asSharedFlow()
override fun onLoad() { override fun onLoad() {
LoggerUtil.logger.info("[$name] 启动消息轮询 (群: $targetGroupId)") LoggerUtil.logger.info("[$name] 启动消息轮询 (群: $targetGroupId)")
@ -32,12 +31,12 @@ class GroupMessagePollingModule(
scope!!.launch { scope!!.launch {
while (isActive && loaded) { while (isActive && loaded) {
try { try {
val event = napCatClient.send( val event = getNapCatClientOrNull()?.send<GetGroupMsgHistoryEvent>(
GetGroupMsgHistoryRequest( GetGroupMsgHistoryRequest(
count = msgHistoryCheck, count = msgHistoryCheck,
groupId = ID.long(targetGroupId) groupId = ID.long(targetGroupId)
) )
) as? GetGroupMsgHistoryEvent )
val messages = event?.data?.messages ?: emptyList() val messages = event?.data?.messages ?: emptyList()
LoggerUtil.logger.debug("[$name] 拉取到 ${messages.size} 条消息") LoggerUtil.logger.debug("[$name] 拉取到 ${messages.size} 条消息")

View File

@ -11,12 +11,11 @@ import top.r3944realms.ltdmanager.napcat.request.group.SetGroupAddRequestRequest
import top.r3944realms.ltdmanager.utils.LoggerUtil import top.r3944realms.ltdmanager.utils.LoggerUtil
class GroupRequestHandlerModule( class GroupRequestHandlerModule(
moduleName: String,
private val client: NapCatClient, private val client: NapCatClient,
private val targetGroupId: Long, private val targetGroupId: Long,
private val pollIntervalMillis: Long = 30_000L, private val pollIntervalMillis: Long = 30_000L,
) : BaseModule() { ) : BaseModule(Modules.GROUP_REQUEST_HANDLER, moduleName) {
override val name: String = "GroupRequestHandlerModule"
private var scope: CoroutineScope? = null private var scope: CoroutineScope? = null
@ -138,7 +137,7 @@ class GroupRequestHandlerModule(
try { try {
getConnection().use { conn -> getConnection().use { conn ->
val stmt = conn.prepareStatement( val stmt = conn.prepareStatement(
"SELECT status FROM minecraft_manager_ltd.players WHERE qq=?" "SELECT status FROM minecraft_manager_ltd_9.players WHERE qq=?"
) )
stmt.setLong(1, actor) stmt.setLong(1, actor)
val rs = stmt.executeQuery() val rs = stmt.executeQuery()
@ -176,4 +175,7 @@ class GroupRequestHandlerModule(
return data.invitedRequest + data.joinRequests return data.invitedRequest + data.joinRequests
} }
} }
override fun info(): String = "模块: $name\n功能: 自动处理群组加群请求\n版本: 1.0"
override fun help(): String = "本模块会轮询群组加群请求并根据数据库白名单自动同意或拒绝"
} }

View File

@ -0,0 +1,245 @@
package top.r3944realms.ltdmanager.module
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import top.r3944realms.ltdmanager.module.common.CommandParser
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownManager
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownScope
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownStateProvider
import top.r3944realms.ltdmanager.module.common.filter.TriggerMessageFilter
import top.r3944realms.ltdmanager.module.common.filter.type.CooldownFilter
import top.r3944realms.ltdmanager.module.common.filter.type.IgnoreSelfFilter
import top.r3944realms.ltdmanager.module.common.filter.type.KeywordFilter
import top.r3944realms.ltdmanager.module.common.filter.type.NewMessageFilter
import top.r3944realms.ltdmanager.napcat.NapCatClient
import top.r3944realms.ltdmanager.napcat.data.ID
import top.r3944realms.ltdmanager.napcat.data.MessageElement
import top.r3944realms.ltdmanager.napcat.data.MessageType
import top.r3944realms.ltdmanager.napcat.data.msghistory.MsgHistorySpecificMsg
import top.r3944realms.ltdmanager.napcat.request.message.SendForwardMsgRequest
import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.io.File
/**
* HelpModule 提供全局模块帮助信息
*/
class HelpModule(
moduleName: String,
private val groupMessagePollingModule: GroupMessagePollingModule,
private val selfId: Long,
private val selfNickName: String,
private val keywords: List<String> = listOf("help", "帮助"),
private val cooldownMillis: Long = 30_000L
) : BaseModule(Modules.HELP, moduleName), PersistentState<HelpModule.HelpState> {
// 命令解析器
private val commandParser = CommandParser(keywords)
private val MsgHistorySpecificMsg.textContent: String
get() = message.joinToString("") { it.data.text ?: "" }
// 持久化文件
private val stateFile: File = getStateFileInternal("help_module_state.json", name)
private val stateBackupFile: File = getStateFileInternal("help_module_state.json.bak", name)
@Serializable
data class HelpState(var lastTriggeredRealId: Long = -1L, var lastTriggerTime: Long = 0L)
private var lastTriggerState: HelpState = loadState()
// 冷却管理器
private val cooldownManager by lazy {
CooldownManager(
cooldownMillis = cooldownMillis,
scope = CooldownScope.Global,
stateProvider = object : CooldownStateProvider<HelpState> {
override fun load() = loadState()
override fun save(state: HelpState) = saveState(state)
},
getLastTrigger = { state, _ -> state.lastTriggerTime to state.lastTriggeredRealId },
updateTrigger = { state, _, realId, time -> state.copy(lastTriggeredRealId = realId, lastTriggerTime = time) },
updateCooldownRealId = { state, _, realId -> state.copy(lastTriggeredRealId = realId) },
groupId = groupMessagePollingModule.targetGroupId
)
}
// 触发过滤器
private val triggerFilter by lazy {
TriggerMessageFilter(
listOf(
IgnoreSelfFilter(selfId),
NewMessageFilter { _ -> lastTriggerState.lastTriggerTime to lastTriggerState.lastTriggeredRealId },
KeywordFilter(keywords.toSet()),
CooldownFilter(cooldownManager) { msg, remain -> sendCooldownMessage(napCatClient, msg.realId, remain) }
)
)
}
private var scope: CoroutineScope? = null
override fun getStateFileInternal(): File = stateFile
override fun getState(): HelpState = lastTriggerState
override fun onLoad() {
LoggerUtil.logger.info("[$name] 模块已加载,监听 help 指令")
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope!!.launch {
groupMessagePollingModule.messagesFlow.collect { messages ->
if (loaded) handleMessages(messages)
}
}
}
override suspend fun onUnload() {
LoggerUtil.logger.info("[$name] 模块卸载,取消协程...")
scope?.cancel()
saveState(lastTriggerState)
LoggerUtil.logger.info("[$name] 模块已卸载完成")
}
private suspend fun handleMessages(messages: List<MsgHistorySpecificMsg>) {
val filtered = triggerFilter.filter(messages)
val triggerMsg = filtered.maxByOrNull { it.time } ?: return
val cmdPair = commandParser.parseCommand(triggerMsg.textContent)
if (cmdPair != null) {
val (_, arg) = cmdPair
if (arg.isNotEmpty()) {
val module = moduleMap[arg]
if (module != null) sendModuleHelp(triggerMsg, arg, module)
else sendText(triggerMsg, "未找到模块: $arg")
} else {
sendAllModulesHelp(triggerMsg)
}
}
}
private suspend fun sendAllModulesHelp(msg: MsgHistorySpecificMsg) {
val messages = moduleMap.map { (name, module) ->
val textBuilder = StringBuilder()
textBuilder.appendLine("===== $name =====")
textBuilder.appendLine(module.info())
val helpText = module.help()
if (helpText.isNotEmpty()) textBuilder.appendLine(helpText)
textBuilder.appendLine().appendLine()
SendForwardMsgRequest.Message(
data = SendForwardMsgRequest.PurpleData(textBuilder.toString()),
type = MessageType.Text
)
}
val topMessage = SendForwardMsgRequest.TopForwardMsg(
data = SendForwardMsgRequest.MessageData(
content = messages,
nickname = selfNickName,
userId = ID.long(selfId)
),
type = MessageType.Node
)
val request = SendForwardMsgRequest(
groupId = ID.long(groupMessagePollingModule.targetGroupId),
messages = listOf(topMessage),
news = listOf(SendForwardMsgRequest.ForwardModelNews("点击查看所有模块信息")),
prompt = "全局模块信息",
source = "📚 HelpModule",
summary = "信息,共 ${messages.size} 个模块"
)
napCatClient.sendUnit(request)
updateTriggerState(msg)
}
private suspend fun sendModuleHelp(msg: MsgHistorySpecificMsg, moduleName: String, module: BaseModule) {
val textBuilder = StringBuilder()
textBuilder.appendLine("===== $moduleName =====")
textBuilder.appendLine(module.info())
val helpText = module.help()
if (helpText.isNotEmpty()) textBuilder.appendLine(helpText)
val message = SendForwardMsgRequest.Message(
data = SendForwardMsgRequest.PurpleData(textBuilder.toString()),
type = MessageType.Text
)
val topMessage = SendForwardMsgRequest.TopForwardMsg(
data = SendForwardMsgRequest.MessageData(
content = listOf(message),
nickname = selfNickName,
userId = ID.long(selfId)
),
type = MessageType.Node
)
val request = SendForwardMsgRequest(
groupId = ID.long(groupMessagePollingModule.targetGroupId),
messages = listOf(topMessage),
news = listOf(SendForwardMsgRequest.ForwardModelNews("点击查看模块 $moduleName 帮助")),
prompt = "模块 $moduleName 帮助",
source = "📚 HelpModule",
summary = "模块 $moduleName 帮助信息"
)
napCatClient.sendUnit(request)
updateTriggerState(msg)
}
private suspend fun sendText(msg: MsgHistorySpecificMsg, text: String) {
val request = SendGroupMsgRequest(
MessageElement.reply(ID.long(msg.realId), text),
ID.long(groupMessagePollingModule.targetGroupId)
)
napCatClient.sendUnit(request)
updateTriggerState(msg)
}
private fun updateTriggerState(msg: MsgHistorySpecificMsg) {
lastTriggerState.lastTriggeredRealId = msg.realId
lastTriggerState.lastTriggerTime = msg.time
saveState(lastTriggerState)
}
private suspend fun sendCooldownMessage(client: NapCatClient, realId: Long, remaining: Long) {
val msg = "⏳ Help 查询过于频繁,请稍后再试(剩余 $remaining 秒)"
LoggerUtil.logger.info("[$name] 发送冷却提示: $msg")
client.sendUnit(
SendGroupMsgRequest(
MessageElement.reply(ID.long(realId), msg),
ID.long(groupMessagePollingModule.targetGroupId)
)
)
}
// ---------------- 持久化 ----------------
override fun saveState(state: HelpState) {
try {
if (stateFile.exists()) stateFile.copyTo(stateBackupFile, overwrite = true)
stateFile.writeText(Json.encodeToString(state))
LoggerUtil.logger.info("[$name] 已保存状态: lastTriggeredRealId=${state.lastTriggeredRealId}, lastTriggerTime=${state.lastTriggerTime}")
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 保存状态失败", e)
}
}
override fun loadState(): HelpState {
return try {
val fileToRead = when {
stateFile.exists() -> stateFile
stateBackupFile.exists() -> stateBackupFile
else -> null
}
if (fileToRead == null) return HelpState()
Json.decodeFromString<HelpState>(fileToRead.readText())
} catch (e: Exception) {
LoggerUtil.logger.warn("[$name] 读取状态失败,使用默认值", e)
HelpState()
}
}
override fun help(): String = "发送 'help' 获取所有模块帮助信息"
override fun info(): String = "模块: $name\n功能: 提供全局模块帮助信息\n版本: 1.0"
}

View File

@ -4,16 +4,24 @@ import kotlinx.coroutines.*
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import top.r3944realms.ltdmanager.blessingskin.data.InvitationCode
import top.r3944realms.ltdmanager.blessingskin.request.invitecode.GenerateInvitationCodeRequest 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.blessingskin.response.invitecode.InvitationCodeGenerationResponse
import top.r3944realms.ltdmanager.core.client.response.ResponseResult
import top.r3944realms.ltdmanager.core.mail.mail import top.r3944realms.ltdmanager.core.mail.mail
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownManager
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownScope
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownStateProvider
import top.r3944realms.ltdmanager.module.common.filter.TriggerMessageFilter
import top.r3944realms.ltdmanager.module.common.filter.type.CooldownFilter
import top.r3944realms.ltdmanager.module.common.filter.type.IgnoreSelfFilter
import top.r3944realms.ltdmanager.module.common.filter.type.KeywordFilter
import top.r3944realms.ltdmanager.module.common.filter.type.NewMessageFilter
import top.r3944realms.ltdmanager.module.exception.InvitationCodeException import top.r3944realms.ltdmanager.module.exception.InvitationCodeException
import top.r3944realms.ltdmanager.napcat.NapCatClient import top.r3944realms.ltdmanager.napcat.NapCatClient
import top.r3944realms.ltdmanager.napcat.data.ID import top.r3944realms.ltdmanager.napcat.data.ID
import top.r3944realms.ltdmanager.napcat.data.MessageElement import top.r3944realms.ltdmanager.napcat.data.MessageElement
import top.r3944realms.ltdmanager.napcat.data.MessageType import top.r3944realms.ltdmanager.napcat.data.msghistory.MsgHistorySpecificMsg
import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent
import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest
import top.r3944realms.ltdmanager.utils.HtmlTemplateUtil import top.r3944realms.ltdmanager.utils.HtmlTemplateUtil
import top.r3944realms.ltdmanager.utils.LoggerUtil import top.r3944realms.ltdmanager.utils.LoggerUtil
@ -63,24 +71,67 @@ api格式 https://skins.r3944realms.top/api/invitation-codes/generate?token=XXXX
*/ */
class InvitationCodesModule( class InvitationCodesModule(
moduleName: String,
private val groupMessagePollingModule: GroupMessagePollingModule, private val groupMessagePollingModule: GroupMessagePollingModule,
private val mailModule: MailModule, private val mailModule: MailModule,
private val apiToken: String, private val apiToken: String,
private val selfId: Long, selfId: Long,
private val cooldownMillis: Long = 120_000, private val cooldownMillis: Long = 120_000,
private val keywords: Set<String> = setOf("申请邀请码") private val keywords: Set<String> = setOf("申请邀请码")
) : BaseModule(), PersistentState<InvitationCodesModule.LastTriggerMapState> { ) : BaseModule(Modules.INVITATION_CODE, moduleName), PersistentState<InvitationCodesModule.LastTriggerMapState> {
override val name: String = "InvitationCodesModule"
private var scope: CoroutineScope? = null private var scope: CoroutineScope? = null
private val stateFile: File = getStateFileInternal("invitation_codes_quarry_state.json", name)
private val stateBackupFile: File = getStateFileInternal("invitation_codes_quarry_state.json.bak", name)
private val cooldownManager by lazy{ CooldownManager(
cooldownMillis = cooldownMillis,
scope = CooldownScope.PerUser,
stateProvider = object : CooldownStateProvider<LastTriggerMapState> {
override fun load() = loadState()
override fun save(state: LastTriggerMapState) = saveState(state)
},
getLastTrigger = { state, qq ->
val detail = state.map[qq]
(detail?.time ?: -1L) to (detail?.lastCooldownRealId ?: -1L)
},
updateTrigger = { state, qq, realId, time ->
val id = requireNotNull(qq)
state.updateLastTrigger(id, realId, time)
},
updateCooldownRealId = { state, qq, realId ->
val id = requireNotNull(qq)
state.updateLastCooldownRealId(id, realId)
},
groupId = groupMessagePollingModule.targetGroupId
)
}
// 在 InvitationCodesModule 类里添加:
private val triggerFilter = TriggerMessageFilter(
listOf(
IgnoreSelfFilter(selfId),
NewMessageFilter { qq ->
lastTriggerMapState.getLastTriggerTime(qq) to lastTriggerMapState.getLastTriggerRealId(qq)
},
KeywordFilter(keywords),
CooldownFilter(
cooldownManager = cooldownManager,
sendCooldown = { msg, remain ->
sendCooldownMessage(
napCatClient,
msg.userId,
msg.realId,
"⏳ 申请邀请码过于频繁(剩余 $remain 秒后自动申请)"
)
}
)
)
)
// 持久化文件(带锁 + 备份)
private val stateFile = getStateFile("mc_server_status_state.json")
private val stateBackupFile = getStateFile("invitation_codes_quarry_state.json.bak")
private val fileLock = ReentrantLock() private val fileLock = ReentrantLock()
private var lastTriggerMapState = loadState() private var lastTriggerMapState = loadState()
override fun getStateFile(): File = stateFile override fun getStateFileInternal(): File = stateFile
override fun getState(): LastTriggerMapState = lastTriggerMapState override fun getState(): LastTriggerMapState = lastTriggerMapState
override fun onLoad() { override fun onLoad() {
LoggerUtil.logger.info("[$name] 模块已装载,目标群组: ${groupMessagePollingModule.targetGroupId}") LoggerUtil.logger.info("[$name] 模块已装载,目标群组: ${groupMessagePollingModule.targetGroupId}")
@ -115,14 +166,14 @@ class InvitationCodesModule(
// ========================= // =========================
// 消息处理主流程 // 消息处理主流程
// ========================= // =========================
private suspend fun handleMessages(messages: List<GetFriendMsgHistoryEvent.SpecificMsg>) { private suspend fun handleMessages(messages: List<MsgHistorySpecificMsg>) {
if (messages.isEmpty()) return if (messages.isEmpty()) return
val triggerMsgs = filterTriggerMessages(messages) val triggerMsgs = filterTriggerMessages(messages)
if (triggerMsgs.isEmpty()) return if (triggerMsgs.isEmpty()) return
try { try {
val hadValidCodeButNotUsed = mutableListOf<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>() val hadValidCodeButNotUsed = mutableListOf<Pair<Long, MsgHistorySpecificMsg>>()
val needNewCode = mutableListOf<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>() val needNewCode = mutableListOf<Pair<Long, MsgHistorySpecificMsg>>()
getIdAndSelectSituation(triggerMsgs, hadValidCodeButNotUsed, needNewCode) getIdAndSelectSituation(triggerMsgs, hadValidCodeButNotUsed, needNewCode)
createAndSearchInvitationCodeIdsThenUpdateDate(needNewCode) createAndSearchInvitationCodeIdsThenUpdateDate(needNewCode)
@ -135,34 +186,22 @@ class InvitationCodesModule(
} }
/** 过滤出符合条件的触发消息 */ /** 过滤出符合条件的触发消息 */
private fun filterTriggerMessages(messages: List<GetFriendMsgHistoryEvent.SpecificMsg>) private suspend fun filterTriggerMessages(
: List<GetFriendMsgHistoryEvent.SpecificMsg> { messages: List<MsgHistorySpecificMsg>
): List<MsgHistorySpecificMsg> {
val filtered = messages.asSequence() // 先应用通用过滤器
.filter { msg -> val filtered = triggerFilter.filter(messages)
msg.userId != selfId &&
(msg.time > lastTriggerMapState.getLastTriggerTime(msg.userId) || // 再做 groupBy -> 只保留每个用户最新一条
(msg.time == lastTriggerMapState.getLastTriggerTime(msg.userId) return filtered
&& 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 } .groupBy { it.userId }
.mapNotNull { (_, msgs) -> msgs.maxByOrNull { it.time } } .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>, private suspend fun getIdAndSelectSituation(msgs: List<MsgHistorySpecificMsg>,
hadVaildCodeButNotUseList : MutableList<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>, hadVaildCodeButNotUseList : MutableList<Pair<Long, MsgHistorySpecificMsg>>,
needNewCodeList: MutableList<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>) { needNewCodeList: MutableList<Pair<Long, MsgHistorySpecificMsg>>) {
if (msgs.isEmpty()) return if (msgs.isEmpty()) return
val qqIds = msgs.map { it.userId } val qqIds = msgs.map { it.userId }
@ -235,7 +274,7 @@ class InvitationCodesModule(
sendFailedMessage(napCatClient, text = "批量查询用户资格信息失败,请联系管理员: ${e.message}") sendFailedMessage(napCatClient, text = "批量查询用户资格信息失败,请联系管理员: ${e.message}")
} }
} }
private suspend fun hadVaildCodeButNotUseListHandler(list: List<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>) { private suspend fun hadVaildCodeButNotUseListHandler(list: List<Pair<Long, MsgHistorySpecificMsg>>) {
if (list.isEmpty()) return if (list.isEmpty()) return
val whiteListIds = list.map { it.first } val whiteListIds = list.map { it.first }
@ -358,33 +397,6 @@ class InvitationCodesModule(
} }
} }
// =========================
// 冷却逻辑
// =========================
private suspend fun filterCoolDownMessage(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean {
val triggerDetail = lastTriggerMapState.map[msg.userId]
val lastTriggerTime = triggerDetail?.time ?: -1L
val lastCooldownRealId = triggerDetail?.lastCooldownRealId ?: -1L
val nowSec = System.currentTimeMillis() / 1000 // 转成秒
if (lastTriggerTime == -1L || nowSec - lastTriggerTime >= cooldownMillis / 1000) {
// 正常触发
return true
}
// 冷却中,如果本消息未发送过冷却提示
if (msg.realId != lastCooldownRealId) {
val remaining = ((cooldownMillis / 1000) - (nowSec - lastTriggerTime)).coerceAtLeast(1)
val msgText = "⏳ 申请邀请码过于频繁(剩余 $remaining 秒后将为你自动申请)"
sendCooldownMessage(napCatClient, msg.userId, msg.realId, msgText)
// 记录这条消息已发送过冷却提示
lastTriggerMapState = lastTriggerMapState.updateLastCooldownRealId(msg.userId, msg.realId)
}
return false
}
private suspend fun sendCooldownMessage(client: NapCatClient, qq: Long, realId: Long, msg: String) { private suspend fun sendCooldownMessage(client: NapCatClient, qq: Long, realId: Long, msg: String) {
val request = SendGroupMsgRequest( val request = SendGroupMsgRequest(
MessageElement.reply(ID.long(realId), msg), MessageElement.reply(ID.long(realId), msg),
@ -394,7 +406,7 @@ class InvitationCodesModule(
lastTriggerMapState = lastTriggerMapState.updateLastTrigger(qq, realId, -1) lastTriggerMapState = lastTriggerMapState.updateLastTrigger(qq, realId, -1)
} }
private suspend fun createAndSearchInvitationCodeIdsThenUpdateDate( private suspend fun createAndSearchInvitationCodeIdsThenUpdateDate(
needNewTokenIdAndMsgPairs: List<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>, needNewTokenIdAndMsgPairs: List<Pair<Long, MsgHistorySpecificMsg>>,
) { ) {
if (needNewTokenIdAndMsgPairs.isEmpty()) return if (needNewTokenIdAndMsgPairs.isEmpty()) return
@ -419,23 +431,33 @@ class InvitationCodesModule(
/** /**
* 1. 创建邀请码 * 1. 创建邀请码
*/ */
private suspend fun createInvitationCodes(amount: Int): List<InvitationCodeGenerationResponse.InvitationCode>? { private suspend fun createInvitationCodes(amount: Int): List<InvitationCode>? {
return try { return try {
val response = blessingSkinClient.submitRequest( val response = blessingSkinClient.submitRequest(
GenerateInvitationCodeRequest(amount = amount, token = apiToken) GenerateInvitationCodeRequest(amount = amount, token = apiToken)
) )
response
.onFailure {
}
.onSuccess {
}
when (response) { when (response) {
is ResponseResult.Success -> { is ResponseResult.Success -> {
if (response.response.success) { if (response.response is InvitationCodeGenerationResponse) {
response.response.data if (response.response.success) {
response.response.data
} else
LoggerUtil.logger.warn("[$name] API返回失败: ${response.response.message}")
null
} else { } else {
LoggerUtil.logger.warn("[$name] API返回失败: ${response.response.message}") LoggerUtil.logger.warn("[$name] 返回非预期对象类型: ${response.response.javaClass}")
null null
} }
} }
is ResponseResult.Failure -> { is ResponseResult.Failure -> {
LoggerUtil.logger.warn("[$name] 创建邀请码失败: ${response.failure.failedResult}") LoggerUtil.logger.warn("[$name] 创建邀请码失败: ${response.failure.failedMessage}")
null null
} }
} }
@ -449,8 +471,8 @@ class InvitationCodesModule(
* 2. 验证数量匹配 * 2. 验证数量匹配
*/ */
private fun validateCodeCountMatch( private fun validateCodeCountMatch(
invitationCodes: List<InvitationCodeGenerationResponse.InvitationCode>?, invitationCodes: List<InvitationCode>?,
needNewTokenIdAndMsgPairs: List<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>> needNewTokenIdAndMsgPairs: List<Pair<Long, MsgHistorySpecificMsg>>
) { ) {
if (invitationCodes == null) { if (invitationCodes == null) {
throw InvitationCodeException.ApiFailureException("获取邀请码请求失败") throw InvitationCodeException.ApiFailureException("获取邀请码请求失败")
@ -646,5 +668,34 @@ class InvitationCodesModule(
} }
} }
} }
// 在 InvitationCodesModule 类中补全:
override fun info(): String {
return """
模块: $name
功能: 自动处理群组内申请邀请码消息
描述:
1. 监听群消息过滤关键词和冷却
2. 根据QQ号查询白名单状态
3. 自动创建或发送邀请码并通过邮件发送
4. 已触发和未触发状态会持久化保存
关键词: $keywords
冷却时间: ${cooldownMillis / 1000}
目标群组: ${groupMessagePollingModule.targetGroupId}
""".trimIndent()
}
override fun help(): String {
return """
使用说明:
1. 在群里发送${keywords}触发本模块
2. 模块会自动判断你的白名单状态
- 若已使用过邀请码会提醒你不要重复申请
- 若已有邀请码但未使用会重新发送邮件提醒
- 若未生成邀请码会调用API生成并发送邮件
3. 请求过于频繁时会有冷却提示
4. 所有操作都有日志记录可供管理员审计
5. 异常情况会发送失败提示消息
""".trimIndent()
}
} }

View File

@ -10,6 +10,7 @@ import java.util.concurrent.LinkedBlockingQueue
import kotlin.concurrent.thread import kotlin.concurrent.thread
class MailModule( class MailModule(
moduleName: String,
private val protocol: String = "SMTP", private val protocol: String = "SMTP",
private val host: String, private val host: String,
private val port: Int, private val port: Int,
@ -18,9 +19,7 @@ class MailModule(
private val enableAuth: Boolean = true, private val enableAuth: Boolean = true,
private val enableTLS: Boolean = true, private val enableTLS: Boolean = true,
private val intervalMillis: Long = 2000L // 每封邮件之间的间隔(默认 2s private val intervalMillis: Long = 2000L // 每封邮件之间的间隔(默认 2s
) : BaseModule() { ) : BaseModule(Modules.MAIL, moduleName) {
override val name: String = "MailModule"
private lateinit var session: Session private lateinit var session: Session
private val queue = LinkedBlockingQueue<Mail>() // 邮件队列 private val queue = LinkedBlockingQueue<Mail>() // 邮件队列
@ -142,4 +141,36 @@ class MailModule(
Transport.send(message) Transport.send(message)
} }
override fun info(): String {
return buildString {
appendLine("[$name] 邮件发送模块")
appendLine("功能: 异步发送邮件,支持收件人/抄送/密送,支持 HTML 或纯文本邮件。")
appendLine("SMTP 配置:")
appendLine(" - 协议: $protocol")
appendLine(" - 主机: $host")
appendLine(" - 端口: $port")
appendLine(" - 发件人邮箱: $senderEmailAddress")
appendLine(" - 身份认证: ${if (enableAuth) "启用" else "禁用"}")
appendLine(" - TLS/SSL: ${if (enableTLS) "启用" else "禁用"}")
appendLine("队列行为:")
appendLine(" - 邮件发送间隔: ${intervalMillis}ms")
appendLine(" - 队列长度: ${queue.size}")
appendLine(" - 当前发送线程状态: ${if (workerThread?.isAlive == true) "运行中" else "未运行"}")
}
}
override fun help(): String {
return buildString {
appendLine("📖 [$name] 使用帮助:")
appendLine("1. 创建 Mail 对象,设置收件人、主题和正文")
appendLine(" 例如: Mail(to = listOf(\"example@mail.com\"), subject = \"测试\", body = \"Hello\")")
appendLine("2. 调用 enqueue(mail) 加入发送队列")
appendLine(" 邮件将异步发送,间隔 $intervalMillis ms")
appendLine("3. 模块卸载时会自动停止发送线程")
appendLine()
appendLine("注意:")
appendLine(" - 确保 SMTP 配置正确,否则发送失败")
appendLine(" - 发件人邮箱需要允许 SMTP/授权码登录")
}
}
} }

View File

@ -4,11 +4,20 @@ import kotlinx.coroutines.*
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import top.r3944realms.ltdmanager.mcserver.McServerStatus import top.r3944realms.ltdmanager.mcserver.McServerStatus
import top.r3944realms.ltdmanager.module.common.CommandParser
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownManager
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownScope
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownStateProvider
import top.r3944realms.ltdmanager.module.common.filter.TriggerMessageFilter
import top.r3944realms.ltdmanager.module.common.filter.type.CommandFilter
import top.r3944realms.ltdmanager.module.common.filter.type.CooldownFilter
import top.r3944realms.ltdmanager.module.common.filter.type.IgnoreSelfFilter
import top.r3944realms.ltdmanager.module.common.filter.type.NewMessageFilter
import top.r3944realms.ltdmanager.napcat.NapCatClient import top.r3944realms.ltdmanager.napcat.NapCatClient
import top.r3944realms.ltdmanager.napcat.data.ID import top.r3944realms.ltdmanager.napcat.data.ID
import top.r3944realms.ltdmanager.napcat.data.MessageElement import top.r3944realms.ltdmanager.napcat.data.MessageElement
import top.r3944realms.ltdmanager.napcat.data.MessageType import top.r3944realms.ltdmanager.napcat.data.MessageType
import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent import top.r3944realms.ltdmanager.napcat.data.msghistory.MsgHistorySpecificMsg
import top.r3944realms.ltdmanager.napcat.request.message.SendForwardMsgRequest import top.r3944realms.ltdmanager.napcat.request.message.SendForwardMsgRequest
import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest
import top.r3944realms.ltdmanager.utils.LoggerUtil import top.r3944realms.ltdmanager.utils.LoggerUtil
@ -17,30 +26,72 @@ import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock import kotlin.concurrent.withLock
class McServerStatusModule( class McServerStatusModule(
moduleName: String,
private val groupMessagePollingModule: GroupMessagePollingModule, private val groupMessagePollingModule: GroupMessagePollingModule,
private val selfId: Long, private val selfId: Long,
private val selfNickName: String, private val selfNickName: String,
private val cooldownSeconds: Long = 60, private val cooldownMillis: Long = 60_000L,
private val commands: List<String> = listOf("/mcs", "/s"), private val commands: List<String> = listOf("/mcs", "/s"),
private val presetServer: Map<Set<String>, String> = mapOf( private val presetServer: Map<Set<String>, String> = mapOf(
setOf("hp", "hypixel") to "mc.hypixel.net", setOf("hp", "hypixel") to "mc.hypixel.net",
setOf("pm", "mineplex") to "play.mineplex.com" setOf("pm", "mineplex") to "play.mineplex.com"
) )
) : BaseModule(), PersistentState<McServerStatusModule.CooldownState> { ) : BaseModule(Modules.MC_SERVER_STATUS, moduleName), PersistentState<McServerStatusModule.CooldownState> {
private val stateFile:File = getStateFileInternal("mc_server_status_state.json", name)
private val stateBackupFile:File = getStateFileInternal("mc_server_status_state.json.bak", name)
private val commandParser: CommandParser = CommandParser(commands)
private val cooldownManager by lazy {
CooldownManager(
cooldownMillis = cooldownMillis,
scope = CooldownScope.PerUser,
stateProvider = object : CooldownStateProvider<CooldownState> {
override fun load() = loadState()
override fun save(state: CooldownState) = saveState(state)
},
getLastTrigger = { state, qq ->
val detail = state.map[qq]
(detail?.time ?: -1L) to (detail?.lastCooldownRealId ?: -1L)
},
updateTrigger = { state, qq, realId, time ->
val id = requireNotNull(qq) { "userId required for per-user cooldown" }
state.updateLastTrigger(id, realId, time) }
,
updateCooldownRealId = { state, qq, realId ->
val id = requireNotNull(qq) { "userId required for per-user cooldown" }
state.updateLastCooldownRealId(id, realId)
},
groupId = groupMessagePollingModule.targetGroupId
)
}
private val triggerFilter = TriggerMessageFilter(
listOf(
IgnoreSelfFilter(selfId),
NewMessageFilter { qq ->
cooldownState.getLastTriggerTime(qq) to cooldownState.getLastTriggerRealId(qq)
},
CommandFilter(commandParser),
CooldownFilter(
cooldownManager = cooldownManager,
sendCooldown = { msg, remaining ->
sendCooldownMessage(napCatClient, msg.realId, "⏳ 查询过于频繁, $remaining 秒后执行查询,切勿重复发送")
}
)
)
)
private val presetServerByAlias: Map<String, String> by lazy { private val presetServerByAlias: Map<String, String> by lazy {
presetServer.flatMap { (aliases, ip) -> presetServer.flatMap { (aliases, ip) ->
aliases.map { it.lowercase() to ip } aliases.map { it.lowercase() to ip }
}.toMap() }.toMap()
} }
fun getServerIp(alias: String): String? = presetServerByAlias[alias.lowercase()] fun getServerIp(alias: String): String? = presetServerByAlias[alias.lowercase()]
override val name: String = "McServerStatusModule"
private var scope: CoroutineScope? = null private var scope: CoroutineScope? = null
private val stateFile = getStateFile("mc_server_status_state.json")
private val stateBackupFile = getStateFile("mc_server_status_state.json.bak")
private val fileLock = ReentrantLock() private val fileLock = ReentrantLock()
private var cooldownState = loadState() private var cooldownState = loadState()
override fun getStateFile(): File = stateFile override fun getStateFileInternal(): File = stateFile
override fun getState(): CooldownState = cooldownState override fun getState(): CooldownState = cooldownState
override fun onLoad() { override fun onLoad() {
@ -61,7 +112,7 @@ class McServerStatusModule(
LoggerUtil.logger.info("[$name] 模块已卸载完成") LoggerUtil.logger.info("[$name] 模块已卸载完成")
} }
private suspend fun handleMessages(messages: List<GetFriendMsgHistoryEvent.SpecificMsg>) { private suspend fun handleMessages(messages: List<MsgHistorySpecificMsg>) {
if (messages.isEmpty()) return if (messages.isEmpty()) return
val triggerMsgs = filterTriggerMessages(messages) val triggerMsgs = filterTriggerMessages(messages)
if (triggerMsgs.isEmpty()) return if (triggerMsgs.isEmpty()) return
@ -76,32 +127,11 @@ class McServerStatusModule(
saveState(cooldownState) saveState(cooldownState)
} }
} }
private suspend fun filterTriggerMessages(messages: List<GetFriendMsgHistoryEvent.SpecificMsg>)
: List<GetFriendMsgHistoryEvent.SpecificMsg> {
val filtered = messages.asSequence() private suspend fun filterTriggerMessages(
.filter { msg -> messages: List<MsgHistorySpecificMsg>
// 忽略自己消息 ): List<MsgHistorySpecificMsg> = triggerFilter.filter(messages)
msg.userId != selfId &&
// 新消息判断
(msg.time > cooldownState.getLastTriggerTime(msg.userId) ||
(msg.time == cooldownState.getLastTriggerTime(msg.userId) &&
msg.realId > cooldownState.getLastTriggerRealId(msg.userId)))
}
.filter { msg ->
// 检查命令
msg.message.any { seg ->
seg.type == MessageType.Text &&
(
seg.data.text?.let { text -> commands.any { cmd -> text.startsWith(cmd) } } == true
)
}
}
.filter { runBlocking { handleCooldown(it) } } // 这里处理冷却
.toList()
return filtered
}
private suspend fun sendFailedMessage( private suspend fun sendFailedMessage(
client: NapCatClient, client: NapCatClient,
qq: Long? = null, qq: Long? = null,
@ -129,31 +159,7 @@ class McServerStatusModule(
LoggerUtil.logger.info("[$name] 已发送 失败消息[无指定对象]") LoggerUtil.logger.info("[$name] 已发送 失败消息[无指定对象]")
} }
} }
/** 冷却提示消息 */ private suspend fun sendCooldownMessage(client: NapCatClient, realId: Long, text: String) {
private suspend fun handleCooldown(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean {
val trigger = cooldownState.map[msg.userId]
val lastTriggerTime = trigger?.time ?: -1L
val lastCooldownRealId = trigger?.lastCooldownRealId ?: -1L
val nowSec = System.currentTimeMillis() / 1000
// 未触发过或者已超过冷却
if (lastTriggerTime == -1L || nowSec - lastTriggerTime >= cooldownSeconds) {
return true
}
// 冷却中且未发送过冷却提示
if (msg.realId != lastCooldownRealId) {
val remaining = ((cooldownSeconds - (nowSec - lastTriggerTime))).coerceAtLeast(1)
val msgText = "⏳ 查询过于频繁, $remaining 秒后执行查询,切勿重复发送"
sendCooldownMessage(napCatClient, msg.userId, msg.realId, msgText)
cooldownState = cooldownState.updateLastCooldownRealId(msg.userId, msg.realId)
}
return false
}
private suspend fun sendCooldownMessage(client: NapCatClient, qq: Long, realId: Long, text: String) {
val request = SendGroupMsgRequest( val request = SendGroupMsgRequest(
MessageElement.reply(ID.long(realId), text), MessageElement.reply(ID.long(realId), text),
ID.long(groupMessagePollingModule.targetGroupId) ID.long(groupMessagePollingModule.targetGroupId)
@ -163,7 +169,7 @@ class McServerStatusModule(
private suspend fun processCommand(msg: GetFriendMsgHistoryEvent.SpecificMsg) { private suspend fun processCommand(msg: MsgHistorySpecificMsg) {
// 找出文本内容 // 找出文本内容
val text = msg.message val text = msg.message
.firstOrNull { it.type == MessageType.Text } .firstOrNull { it.type == MessageType.Text }
@ -171,16 +177,18 @@ class McServerStatusModule(
?.trim() ?.trim()
?: return ?: return
// 解析命令 // 使用命令解析器解析命令
val matchedCommand = commands.firstOrNull { text.startsWith(it) } ?: return val parsedCommand = commandParser.parseCommand(text) ?: return
var address = text.removePrefix(matchedCommand).trim() val (_, address) = parsedCommand
// 使用预设别名替换 // 使用预设别名替换
presetServerByAlias[address.lowercase()]?.let { presetIp -> val finalAddress = if (address.isNotEmpty()) {
address = presetIp presetServerByAlias[address.lowercase()] ?: address
} else {
""
} }
if (address.isEmpty()) { if (finalAddress.isEmpty()) {
sendFailedMessage( sendFailedMessage(
napCatClient, napCatClient,
msg.userId, msg.userId,
@ -192,9 +200,8 @@ class McServerStatusModule(
} }
try { try {
val status = mcSrvStatusClient.getServerStatus(address) // 返回 McServerStatus val status = mcSrvStatusClient.getServerStatus(finalAddress)
// 检查是否查询失败
if (!status.online) { if (!status.online) {
sendFailedMessage( sendFailedMessage(
napCatClient, msg.userId, msg.realId, msg.time, napCatClient, msg.userId, msg.realId, msg.time,
@ -203,9 +210,7 @@ class McServerStatusModule(
return return
} }
// 查询成功,发送状态消息 sendStatusForwardMessage(napCatClient, msg, finalAddress, status, msg.realId, msg.time)
sendStatusForwardMessage(napCatClient, msg, address, status, msg.realId, msg.time)
} catch (e: Exception) { } catch (e: Exception) {
LoggerUtil.logger.error("查询服务器状态失败: $address", e) LoggerUtil.logger.error("查询服务器状态失败: $address", e)
sendFailedMessage( sendFailedMessage(
@ -221,7 +226,7 @@ class McServerStatusModule(
// ---------------- 转发消息封装 ---------------- // ---------------- 转发消息封装 ----------------
private suspend fun sendStatusForwardMessage( private suspend fun sendStatusForwardMessage(
client: NapCatClient, client: NapCatClient,
msg: GetFriendMsgHistoryEvent.SpecificMsg, msg: MsgHistorySpecificMsg,
address: String, address: String,
status: McServerStatus, status: McServerStatus,
realId: Long, realId: Long,
@ -311,23 +316,36 @@ class McServerStatusModule(
data class CooldownState( data class CooldownState(
val map: Map<Long, TriggerDetail> = emptyMap() val map: Map<Long, TriggerDetail> = emptyMap()
) { ) {
// 获取上次处理时间
fun getLastTriggerTime(qq: Long): Long = map[qq]?.time ?: -1 fun getLastTriggerTime(qq: Long): Long = map[qq]?.time ?: -1
// 获取上次处理消息ID
fun getLastTriggerRealId(qq: Long): Long = map[qq]?.realId ?: -1 fun getLastTriggerRealId(qq: Long): Long = map[qq]?.realId ?: -1
fun updateLastTrigger(qq: Long, realId: Long, time: Long = -1): CooldownState {
// 获取上次冷却消息ID
fun getLastCooldownRealId(qq: Long): Long = map[qq]?.lastCooldownRealId ?: -1
// 冷却结束,允许处理消息 → 更新 time 和 realId
fun updateLastTrigger(qq: Long, realId: Long, time: Long): CooldownState {
val old = map[qq] val old = map[qq]
val newTime = if (time != -1L) time else old?.time ?: -1
val newMap = map.toMutableMap().apply { val newMap = map.toMutableMap().apply {
put(qq, TriggerDetail(realId, newTime, old?.lastCooldownRealId ?: -1)) put(qq, TriggerDetail(
realId = realId, // 当前允许处理消息ID
time = time, // 当前允许处理消息时间
lastCooldownRealId = old?.lastCooldownRealId ?: -1 // 保留冷却中记录的消息ID
))
} }
return copy(map = newMap) return copy(map = newMap)
} }
// 冷却中消息 → 只更新 lastCooldownRealId保留 time 和 realId
fun updateLastCooldownRealId(qq: Long, realId: Long): CooldownState { fun updateLastCooldownRealId(qq: Long, realId: Long): CooldownState {
val old = map[qq] val old = map[qq]
val newMap = map.toMutableMap().apply { val newMap = map.toMutableMap().apply {
put(qq, TriggerDetail( put(qq, TriggerDetail(
realId = old?.realId ?: -1, realId = old?.realId ?: -1, // 保持上次允许处理的消息ID
time = old?.time ?: -1, time = old?.time ?: -1, // 保持上次允许处理的时间
lastCooldownRealId = realId lastCooldownRealId = realId // 更新当前冷却拒绝的消息ID
)) ))
} }
return copy(map = newMap) return copy(map = newMap)
@ -336,9 +354,9 @@ class McServerStatusModule(
@Serializable @Serializable
data class TriggerDetail( data class TriggerDetail(
val realId: Long, val realId: Long, // 上次允许处理消息ID
val time: Long, val time: Long, // 上次允许处理消息时间(秒)
val lastCooldownRealId: Long = -1L val lastCooldownRealId: Long = -1 // 上次被冷却拒绝的消息ID
) )
override fun loadState(): CooldownState { override fun loadState(): CooldownState {
@ -368,4 +386,33 @@ class McServerStatusModule(
} }
} }
} }
override fun info(): String {
return buildString {
appendLine("模块名称: $name")
appendLine("模块类型: McServerStatusModule")
appendLine("目标群组: ${groupMessagePollingModule.targetGroupId}")
appendLine("机器人昵称: $selfNickName (ID: $selfId)")
appendLine("冷却时间: ${cooldownMillis / 1000}")
appendLine("支持命令: ${commands.joinToString(", ")}")
appendLine("预设服务器别名:")
presetServer.forEach { (aliases, ip) ->
appendLine(" ${aliases.joinToString("/")} -> $ip")
}
appendLine("状态文件路径: ${stateFile.absolutePath}")
appendLine("状态备份文件路径: ${stateBackupFile.absolutePath}")
}
}
// 返回模块使用帮助
override fun help(): String = buildString {
appendLine("使用帮助 - McServerStatusModule")
appendLine("指令格式: /mcs <服务器别名或IP> 或 /s <服务器别名或IP>")
appendLine("示例:")
presetServerByAlias.forEach { (alias, ip) ->
appendLine(" /mcs $alias -> 查询服务器 $ip 状态")
}
appendLine("注意事项:")
appendLine(" - 查询冷却时间为 ${cooldownMillis / 1000}")
appendLine(" - 输入服务器 IP 或别名均可")
appendLine(" - 查询结果会以转发消息形式发送到群组")
}
} }

View File

@ -0,0 +1,236 @@
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.napcat.data.ID
import top.r3944realms.ltdmanager.napcat.data.MessageElement
import top.r3944realms.ltdmanager.napcat.event.NapCatEvent
import top.r3944realms.ltdmanager.napcat.event.account.GetStrangerInfoEvent
import top.r3944realms.ltdmanager.napcat.event.group.GetGroupIgnoredNotifiesEvent
import top.r3944realms.ltdmanager.napcat.event.group.GetGroupSystemMsgEvent
import top.r3944realms.ltdmanager.napcat.request.account.GetStrangerInfoRequest
import top.r3944realms.ltdmanager.napcat.request.group.GetGroupIgnoredNotifiesRequest
import top.r3944realms.ltdmanager.napcat.request.group.GetGroupSystemMsgRequest
import top.r3944realms.ltdmanager.napcat.request.group.SetGroupAddRequestRequest
import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.io.File
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
/**
* 模块: 入群申请自动处理
* 功能:
* 1. 监听目标群的入群申请事件
* 2. 根据 answers 列表自动同意或拒绝
*/
class ModGroupHandlerModule(
moduleName: String,
private val targetGroupId: Long,
private val answers: List<String> = listOf("正确答案"),
private val pollIntervalMillis: Long = 30_000L
) : BaseModule(Modules.MOD_GROUP_HANDLER, moduleName), PersistentState<ModGroupHandlerModule.RejectRecords> {
private var scope: CoroutineScope? = null
private val stateFile: File = getStateFileInternal("reject_records.json", name)
private val fileLock = ReentrantLock()
private var stateCache: RejectRecords? = null
private val json = Json { prettyPrint = true; encodeDefaults = true }
@Serializable
data class RejectRecord(
val userId: Long,
var reason: MutableList<String> = mutableListOf(),
var rejectCount: Int = 0
)
/**
* 记录所有被拒绝用户的Mapkey = userId
*/
@Serializable
data class RejectRecords(
val records: MutableMap<Long, RejectRecord> = mutableMapOf()
)
override fun getStateFileInternal(): File = stateFile
override fun getState(): RejectRecords {
if (stateCache == null) stateCache = loadState()
return stateCache!!
}
override fun saveState(state: RejectRecords) {
fileLock.withLock {
try {
stateFile.writeText(json.encodeToString(state))
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 保存拒绝记录失败", e)
}
}
}
override fun loadState(): RejectRecords {
return try {
if (!stateFile.exists()) return RejectRecords()
val text = stateFile.readText()
json.decodeFromString(RejectRecords.serializer(), text)
} catch (e: Exception) {
LoggerUtil.logger.warn("[$name] 拒绝记录加载失败,使用默认值", e)
RejectRecords()
}
}
private fun addReject(userId: Long, reason: String) {
val state = getState()
val record = state.records[userId]
if (record != null) {
record.rejectCount += 1
record.reason.add(reason)
} else {
state.records[userId] = RejectRecord(userId, mutableListOf(reason), 1)
}
saveState(state)
}
private fun getRejectRecord(userId: Long): RejectRecord? {
return getState().records[userId]
}
override fun onLoad() {
LoggerUtil.logger.info("[$name] 模块已装载,目标群组: $targetGroupId")
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope!!.launch {
LoggerUtil.logger.info("[$name] 轮询协程启动")
while (isActive && loaded) {
try {
handleEvents()
delay(pollIntervalMillis)
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 轮询异常", e)
}
}
}
}
override suspend fun onUnload() {
LoggerUtil.logger.info("[$name] 模块卸载")
scope?.cancel()
}
private suspend fun handleEvents() {
val systemEvent: GetGroupSystemMsgEvent = napCatClient.send(GetGroupSystemMsgRequest())
handleEvent(systemEvent)
val ignoredEvent: GetGroupIgnoredNotifiesEvent = napCatClient.send(GetGroupIgnoredNotifiesRequest())
handleEvent(ignoredEvent)
}
private suspend fun handleEvent(event: Any) {
if (!loaded) return
val provider: GroupRequestProvider? = when (event) {
is GetGroupSystemMsgEvent -> event.asProvider()
is GetGroupIgnoredNotifiesEvent -> event.asProvider()
else -> null
}
provider?.getAllRequests()?.forEach { request ->
if (!request.checked && request.groupId == targetGroupId) {
LoggerUtil.logger.info("[$name] 处理请求: requestId=${request.requestId},requestQQ =${request.invitorUin}")
val pattern = """答案:(.*)""".toRegex()
val answer = pattern.find(request.message)?.groupValues?.get(1) ?: ""
val answerAllow = answers.contains(answer)
if (answerAllow) {
val info = napCatClient.send<GetStrangerInfoEvent>(GetStrangerInfoRequest(ID.long(request.invitorUin)))
val levelAllow = info.data.qqLevel >= 16
val setRequest = SetGroupAddRequestRequest(
levelAllow,
request.requestId.toString(),
if(!levelAllow) "QQ等级低于16级" else ""
)
napCatClient.send<NapCatEvent>(setRequest)
if (levelAllow) {
napCatClient.send<NapCatEvent>(
SendGroupMsgRequest(
listOf(
MessageElement.at(ID.long(request.invitorUin), request.requesterNick),
MessageElement.text("\n"),
MessageElement.text(
formatRejectRecordMessage(request.invitorUin)
)
), ID.long(targetGroupId)
)
)
}
LoggerUtil.logger.info("[$name] 已${if (levelAllow) "同意" else "拒绝"} 请求${if(!levelAllow) ",等级不够,${info.data.qqLevel}" else "" }: ${request.requestId}")
if(levelAllow) stateCache?.records?.remove(request.invitorUin)
} else {
val rejectCount = (getRejectRecord(request.invitorUin)?.rejectCount ?: 0) + 1
napCatClient.sendUnit(SetGroupAddRequestRequest(false, request.requestId.toString(), "答案错误,请输入标准答案,拒绝次数:${rejectCount}"))
addReject(request.invitorUin, answer)
LoggerUtil.logger.info("[$name] 答案错误:${answer},已拒绝请求: ${request.requestId}")
}
}
}
}
private fun formatRejectRecordMessage(userId: Long): String {
val record = getRejectRecord(userId)
return if (record != null) {
"""
📊 用户审核记录
🔹 用户QQ号${record.userId}
🔹 尝试次数${record.rejectCount}
🔹 最终评分${rate(record.rejectCount)}
📝 尝试答案
${ "\n" + record.reason.joinToString("\n") { "$it" }}
提示请仔细阅读群文档后再在群里提问否则你会失去你的大脑🧠
""".trimIndent()
} else {
"""
📊 用户审核记录
🔹 用户QQ号${userId}
🔹 尝试次数0
🔹 最终评分SSS
💡 该用户尚未有审核记录
提示请仔细阅读群文档后再在群里提问否则你会失去你的大脑🧠
""".trimIndent()
}
}
private fun rate(count: Int): String = when (count) {
0 -> "S"
1 -> "A"
2 -> "B"
3 -> "C"
4 -> "D"
else -> "F"
}
interface GroupRequestProvider {
fun getAllRequests(): List<GetGroupSystemMsgEvent.SystemInfo>
}
private fun GetGroupSystemMsgEvent.asProvider(): GroupRequestProvider = object : GroupRequestProvider {
override fun getAllRequests(): List<GetGroupSystemMsgEvent.SystemInfo> =
data.invitedRequest + data.joinRequests
}
private fun GetGroupIgnoredNotifiesEvent.asProvider(): GroupRequestProvider = object : GroupRequestProvider {
override fun getAllRequests(): List<GetGroupSystemMsgEvent.SystemInfo> =
data.invitedRequest + data.joinRequests
}
override fun info(): String = """
模块: $name
功能: 自动处理指定群组的入群申请
1. 根据答案列表自动同意或拒绝
2. 拒绝记录会保存到本地并可查询尝试次数和尝试答案
3. 用户通过验证且等级满足要求时会向群里发送消息显示用户QQ号尝试次数评分和尝试答案
版本: 1.0
""".trimIndent()
override fun help(): String = "轮询群组入群申请,根据答案列表自动同意或拒绝,并记录拒绝用户信息"
}

View File

@ -0,0 +1,193 @@
package top.r3944realms.ltdmanager.module
import top.r3944realms.ltdmanager.GlobalManager
import top.r3944realms.ltdmanager.core.config.ModuleConfig
import top.r3944realms.ltdmanager.core.config.ModuleConfig.Module.ModuleType.*
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import top.r3944realms.ltdmanager.module.exception.ConfigError
object ModuleFactory {
fun createModule(config: ModuleConfig.Module): BaseModule {
return when(config.type) {
GROUP_MESSAGE_POLLING_MODULE -> createGroupMessagePolling(config)
GROUP_REQUEST_HANDLER_MODULE -> createGroupRequestHandler(config)
MAIL_MODULE -> createMail(config)
BAN_MODULE -> createBan(config)
DG_LAB_MODULE -> createDgLab(config)
INVITE_MODULE -> createInvite(config)
MC_SERVER_STATUS_MODULE -> createMcServerStatus(config)
RCON_PLAYER_LIST_MODULE -> createRconPlayerList(config)
STATE_MODULE -> createState(config)
MOD_GROUP_HANDLER_MODULE -> createModGroupHandler(config)
HELP_MODULE -> createHelpModule(config)
UNKNOWN_MODULE -> throw ConfigError(ConfigError.Type.INVALID_PARAMETER, "unknown module")
}
}
private fun resolveDependency(dep: ModuleConfig.Module.Dependency?, name: String): BaseModule? {
if (dep != null) {
return GlobalManager.moduleManager.getModule(dep.getDepName())
} else throw ConfigError (ConfigError.Type.MISSING_PARAMETER, "dependency", name)
}
private fun createGroupMessagePolling(config: ModuleConfig.Module): GroupMessagePollingModule {
val targetGroupId = config.long("target-group-id")
val pollIntervalMillis = config.getOrDefault("poll-interval-millis", 5_000L)
val msgHistoryCheck = config.getOrDefault("msg-history-check", 15)
return GroupMessagePollingModule(
config.name, targetGroupId,
pollIntervalMillis, msgHistoryCheck,
)
}
private fun createGroupRequestHandler(config: ModuleConfig.Module): GroupRequestHandlerModule {
val targetGroupId = config.long("target-group-id")
val pollIntervalMillis = config.getOrDefault("poll-interval-millis", 30_000L)
return GroupRequestHandlerModule(
config.name, GlobalManager.napCatClient,
targetGroupId, pollIntervalMillis
)
}
private fun createMail(config: ModuleConfig.Module): MailModule {
val mailConfig = YamlConfigLoader.loadMailConfig()
return MailModule(
moduleName = config.name,
host = mailConfig.host.toString(),
authToken = mailConfig.decryptedPassword.toString(),
port = mailConfig.port!!,
senderEmailAddress = mailConfig.mailAddress!!
)
}
private fun createBan(config: ModuleConfig.Module): BanModule {
val selfId = config.long("self-id")
val adminIds = config.list<Long>("admin-ids")
val muteCommandPrefixList = config.stringList("mute-command-prefix-list")
val unmuteCommandPrefixList = config.stringList("unmute-command-prefix-list")
val groupMessagePollingModule = resolveDependency(config.findDependency(GROUP_MESSAGE_POLLING_MODULE), "groupMessagePolling") as GroupMessagePollingModule
val minBanMinutes = config.int("min-ban-minutes")
val maxBanMinutes = config.int("max-ban-minutes")
val factorX: Int = config.int("factor-x")
return BanModule(
config.name,
groupMessagePollingModule,
selfId,
adminIds,
muteCommandPrefixList,
unmuteCommandPrefixList,
minBanMinutes,
maxBanMinutes,
factorX
)
}
private fun createDgLab(config: ModuleConfig.Module): DGLabModule {
val selfId = config.long("self-id")
val adminIds = config.list<Long>("admin-ids")
val maxClientNumber = config.int("max-client-number")
val commandHead = config.stringList("command-head")
val groupMessagePollingModule = resolveDependency(config.findDependency(GROUP_MESSAGE_POLLING_MODULE), "groupMessagePolling") as GroupMessagePollingModule
return DGLabModule(
config.name,
groupMessagePollingModule,
selfId,
adminIds,
maxClientNumber,
commandHead
)
}
private fun createInvite(config: ModuleConfig.Module): InvitationCodesModule {
val selfId = config.long("self-id")
val groupMessagePollingModule = resolveDependency(config.findDependency(GROUP_MESSAGE_POLLING_MODULE), "groupMessagePolling") as GroupMessagePollingModule
val mailModule = resolveDependency(config.findDependency(MAIL_MODULE), "mailModule") as MailModule
val blessingSkinConfig = YamlConfigLoader.loadBlessingSkinServerConfig()
val cooldownMillis = config.getOrDefault("cooldown-millis", 120_000L)
val keywords = config.stringList("keywords")
return InvitationCodesModule(
config.name,
groupMessagePollingModule,
mailModule,
blessingSkinConfig.invitationApi?.decryptedToken!!,
selfId,
cooldownMillis,
keywords.toSet()
)
}
private fun createMcServerStatus(config: ModuleConfig.Module): McServerStatusModule {
val selfId = config.long("self-id")
val cooldownMillis = config.getOrDefault("cooldown-millis", 60_000L)
val groupMessagePollingModule = resolveDependency(config.findDependency(GROUP_MESSAGE_POLLING_MODULE), "groupMessagePolling") as GroupMessagePollingModule
val commands = config.stringList("commands")
val selfNickName = config.string("self-nick-name")
val preset = config.map<Set<String>, String>("preset-server")
return McServerStatusModule(
config.name,
groupMessagePollingModule,
selfId,
selfNickName,
cooldownMillis,
commands,
preset
)
}
private fun createRconPlayerList(config: ModuleConfig.Module): RconPlayerListModule {
val toolConfig = YamlConfigLoader.loadToolConfig()
val selfId = config.long("self-id")
val cooldownMillis = config.getOrDefault("cooldown-millis", 10_000L)
val rconTimeout = config.getOrDefault("rcon-timeout-millis", 2_000L)
val groupMessagePollingModule = resolveDependency(config.findDependency(GROUP_MESSAGE_POLLING_MODULE), "groupMessagePolling") as GroupMessagePollingModule
val selfNickName = config.string("self-nick-name")
val keywords = config.stringList("keywords")
return RconPlayerListModule(
config.name,
groupMessagePollingModule,
rconTimeout,
cooldownMillis,
selfId,
selfNickName,
toolConfig.rcon.mcRconToolPath.toString(),
toolConfig.rcon.mcRconToolConfigPath.toString(),
keywords.toSet()
)
}
private fun createState(config: ModuleConfig.Module): StateModule {
val onlineName = config.string("online-name")
val offlineName = config.string("offline-name")
return StateModule(
config.name,
onlineName,
offlineName
)
}
private fun createModGroupHandler(config: ModuleConfig.Module): ModGroupHandlerModule {
val targetGroupId = config.long("target-group-id")
val answers = config.stringList("answers")
val pollingMillis = config.getOrDefault("poll-interval-millis", 30_000L)
return ModGroupHandlerModule(
config.name,
targetGroupId,
answers,
pollingMillis
)
}
private fun createHelpModule(config: ModuleConfig.Module): HelpModule {
val selfId = config.long("self-id")
val cooldownMillis = config.getOrDefault("cooldown-millis", 10_000L)
val groupMessagePollingModule = resolveDependency(config.findDependency(GROUP_MESSAGE_POLLING_MODULE), "groupMessagePolling") as GroupMessagePollingModule
val selfNickName = config.string("self-nick-name")
val keywords = config.stringList("keywords")
return HelpModule(
config.name,
groupMessagePollingModule,
selfId,
selfNickName,
keywords,
cooldownMillis
)
}
}

View File

@ -1,15 +1,43 @@
package top.r3944realms.ltdmanager.module package top.r3944realms.ltdmanager.module
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import top.r3944realms.ltdmanager.utils.LoggerUtil import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.util.*
class ModuleManager { class ModuleManager {
private val modules = mutableMapOf<String, BaseModule>() private val modules = mutableMapOf<String, BaseModule>()
private var hasLoaded = false
fun getModules(): Map<String, BaseModule> {
return (modules).toMap()
}
fun getModule(name: String): BaseModule? {
return modules[name]
}
fun loadConfig() {
if (!hasLoaded) {
hasLoaded = true
val moduleConfig = YamlConfigLoader.loadModuleConfig()
moduleConfig.modules.let {
val enableBaseModules = LinkedList<BaseModule>()
if (it != null) {
for (mod in it) {
val module = ModuleFactory.createModule(mod)
register(module)
if (mod.enabled) {
enableBaseModules.add(module)
}
}
}
for (module in enableBaseModules) {
load(module.name)
}
}
}
}
/** /**
* 注册模块到管理器 * 注册模块到管理器
*/ */
fun registerModule(module: BaseModule) { fun register(module: BaseModule) {
if (modules.containsKey(module.name)) { if (modules.containsKey(module.name)) {
LoggerUtil.logger.warn("模块已注册: ${module.name}") LoggerUtil.logger.warn("模块已注册: ${module.name}")
return return
@ -17,11 +45,19 @@ class ModuleManager {
modules[module.name] = module modules[module.name] = module
LoggerUtil.logger.info("模块注册: ${module.name}") LoggerUtil.logger.info("模块注册: ${module.name}")
} }
/**
* 注册多模块到管理器
*/
fun register(moduleList: List<BaseModule>) {
for (module in moduleList) {
register(module)
}
}
/** /**
* 加载指定模块 * 加载指定模块
*/ */
fun loadModule(name: String) { fun load(name: String) {
val module = modules[name] val module = modules[name]
if (module == null) { if (module == null) {
LoggerUtil.logger.warn("尝试加载不存在的模块: $name") LoggerUtil.logger.warn("尝试加载不存在的模块: $name")
@ -41,7 +77,7 @@ class ModuleManager {
/** /**
* 卸载指定模块 * 卸载指定模块
*/ */
suspend fun unloadModule(name: String) { suspend fun unload(name: String) {
val module = modules[name] val module = modules[name]
if (module == null) { if (module == null) {
LoggerUtil.logger.warn("尝试卸载不存在的模块: $name") LoggerUtil.logger.warn("尝试卸载不存在的模块: $name")
@ -74,6 +110,13 @@ class ModuleManager {
} }
} }
/**
* 提供获取所有模块信息的方法
*/
fun getAllModuleInfo(): Map<String, String> {
return modules.mapValues { it.value.info() }
}
/** /**
* 获取所有模块名称 * 获取所有模块名称
*/ */
@ -89,14 +132,14 @@ class ModuleManager {
* 扩展方法批量加载模块 * 扩展方法批量加载模块
*/ */
fun ModuleManager.loadModules(vararg names: String) { fun ModuleManager.loadModules(vararg names: String) {
names.forEach { loadModule(it) } names.forEach { load(it) }
} }
/** /**
* 扩展方法批量卸载模块 * 扩展方法批量卸载模块
*/ */
suspend fun ModuleManager.unloadModules(vararg names: String) { suspend fun ModuleManager.unloadModules(vararg names: String) {
names.forEach { unloadModule(it) } names.forEach { unload(it) }
} }
/** /**
* 关闭所有模块 * 关闭所有模块

View File

@ -0,0 +1,26 @@
package top.r3944realms.ltdmanager.module
import java.util.*
object Modules {
private val MODULES: MutableList<String> = LinkedList();
val BAN: String = register("BanModule")
val APPLY_WHITELIST: String = register("ApplyWhitelistModule")
val DG_LAB: String = register("DGLabModule")
val GROUP_MESSAGE_POLLING: String = register("GroupMessagePollingModule")
val GROUP_REQUEST_HANDLER: String = register("GroupRequestHandlerModule")
val HELP: String = register("HelpModule")
val MAIL: String = register("MailModule")
val MC_SERVER_STATUS: String = register("MCServerStatusModule")
val MOD_GROUP_HANDLER: String = register("ModGroupHandlerModule")
val RCON_PLAYER_LIST: String = register("RconPlayerListModule")
val INVITATION_CODE: String = register("InvitationCodeModule")
val STATE: String = register("StateModule")
fun register(name: String): String {
MODULES.add(name)
return name
}
fun getModules(): Array<String> {
return MODULES.toTypedArray();
}
}

View File

@ -1,15 +1,16 @@
package top.r3944realms.ltdmanager.module package top.r3944realms.ltdmanager.module
import top.r3944realms.ltdmanager.utils.FileNameFilter
import java.io.File import java.io.File
interface PersistentState<T> { interface PersistentState<T> {
fun getStateFile(): File fun getStateFileInternal(): File
fun getState(): T fun getState(): T
fun saveState(state: T) fun saveState(state: T)
fun loadState(): T fun loadState(): T
// 默认实现:统一管理 data 目录下的文件 // 默认实现:统一管理 data 目录下的文件
fun getStateFile(name: String): File { fun getStateFileInternal(name: String, subName: String): File {
val dataDir = File("data") val dataDir = File("data", FileNameFilter.filterFileName(subName))
if (!dataDir.exists()) dataDir.mkdirs() if (!dataDir.exists()) dataDir.mkdirs()
return File(dataDir, name) return File(dataDir, name)
} }

View File

@ -5,11 +5,19 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import top.r3944realms.ltdmanager.module.RconPlayerListModule.LastTriggerState import top.r3944realms.ltdmanager.module.RconPlayerListModule.LastTriggerState
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownManager
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownScope
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownStateProvider
import top.r3944realms.ltdmanager.module.common.filter.TriggerMessageFilter
import top.r3944realms.ltdmanager.module.common.filter.type.CooldownFilter
import top.r3944realms.ltdmanager.module.common.filter.type.IgnoreSelfFilter
import top.r3944realms.ltdmanager.module.common.filter.type.KeywordFilter
import top.r3944realms.ltdmanager.module.common.filter.type.NewMessageFilter
import top.r3944realms.ltdmanager.napcat.NapCatClient import top.r3944realms.ltdmanager.napcat.NapCatClient
import top.r3944realms.ltdmanager.napcat.data.ID import top.r3944realms.ltdmanager.napcat.data.ID
import top.r3944realms.ltdmanager.napcat.data.MessageElement import top.r3944realms.ltdmanager.napcat.data.MessageElement
import top.r3944realms.ltdmanager.napcat.data.MessageType import top.r3944realms.ltdmanager.napcat.data.MessageType
import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent import top.r3944realms.ltdmanager.napcat.data.msghistory.MsgHistorySpecificMsg
import top.r3944realms.ltdmanager.napcat.request.message.SendForwardMsgRequest import top.r3944realms.ltdmanager.napcat.request.message.SendForwardMsgRequest
import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest
import top.r3944realms.ltdmanager.utils.CmdUtil import top.r3944realms.ltdmanager.utils.CmdUtil
@ -18,26 +26,61 @@ import java.io.File
import java.util.concurrent.TimeoutException import java.util.concurrent.TimeoutException
class RconPlayerListModule( class RconPlayerListModule(
moduleName: String,
private val groupMessagePollingModule: GroupMessagePollingModule, private val groupMessagePollingModule: GroupMessagePollingModule,
private val rconTimeOut: Long = 2_000L, private val rconTimeOut: Long = 2_000L,
private val cooldownMillis: Long = 30_000L, private val cooldownMillis: Long = 30_000L,
private var lastSuccessTime: Long = 0L,
private val selfId: Long, private val selfId: Long,
private val selfNickName: String, private val selfNickName: String,
private val rconPath: String, private val rconPath: String,
private val rconConfigPath: String, private val rconConfigPath: String,
private val keywords: Set<String> = setOf("查看玩家列表", "玩家列表", "在线玩家") private val keywords: Set<String> = setOf("查看玩家列表", "玩家列表", "在线玩家")
) : BaseModule(), PersistentState<LastTriggerState> { ) : BaseModule(Modules.RCON_PLAYER_LIST, moduleName), PersistentState<LastTriggerState> {
private val cooldownManager by lazy {
override val name: String = "RconPlayerListModule" CooldownManager(
cooldownMillis = cooldownMillis,
scope = CooldownScope.Global,
stateProvider = object : CooldownStateProvider<LastTriggerState> {
override fun load() = loadState()
override fun save(state: LastTriggerState) = saveState(state)
},
getLastTrigger = { state, _ -> state.lastTriggerTime to state.lastTriggeredRealId },
updateTrigger = { state, _, realId, time ->
// ✅ 消息成功触发时更新状态
state.updateTrigger(realId, time)
state
},
updateCooldownRealId = { state, _, realId ->
// ✅ 消息被冷却拒绝时更新 lastCooldownRealId
state.updateCooldownRealId(realId)
state
},
groupId = groupMessagePollingModule.targetGroupId
)
}
/** 抽象过滤器组合 —— lazy 避免初始化顺序问题 */
private val triggerFilter by lazy {
TriggerMessageFilter(
listOf(
IgnoreSelfFilter(selfId),
NewMessageFilter { _ ->
lastTriggerState.lastTriggerTime to lastTriggerState.lastTriggeredRealId
},
KeywordFilter(keywords),
CooldownFilter(cooldownManager) { msg, remain ->
sendCooldownMessage(napCatClient, msg.realId, remain)
}
)
)
}
private var scope : CoroutineScope? = null private var scope : CoroutineScope? = null
// 持久化文件路径 // 持久化文件路径
private val stateFile = getStateFile("rcon_playerlist_state.json") private val stateFile: File = getStateFileInternal("rcon_playerlist_state.json", name)
private val stateBackupFile = getStateFile("invitation_codes_quarry_state.json.bak") private val stateBackupFile: File = getStateFileInternal("rcon_playerlist_state.json.bak", name)
override fun getStateFile(): File = stateFile override fun getStateFileInternal(): File = stateFile
// 保存最新触发过的消息 realId 和 time // 保存最新触发过的消息 realId 和 time
private var lastTriggerState: LastTriggerState = loadState() private var lastTriggerState: LastTriggerState = loadState()
@ -67,104 +110,60 @@ class RconPlayerListModule(
LoggerUtil.logger.info("[$name] 模块已卸载完成") LoggerUtil.logger.info("[$name] 模块已卸载完成")
} }
private suspend fun handleMessages(messages: List<GetFriendMsgHistoryEvent.SpecificMsg>) { private suspend fun handleMessages(messages: List<MsgHistorySpecificMsg>) {
val triggerMessages = messages val filtered = triggerFilter.filter(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()) { // RCON 模块只取最新的一条消息
val triggerMsg = triggerMessages.maxBy { it.time } val triggerMsg = filtered.maxByOrNull { it.time }
LoggerUtil.logger.info("[$name] 找到触发消息 realId=${triggerMsg.realId}, time=${triggerMsg.time}, userId=${triggerMsg.userId}") if (triggerMsg != null) {
processTrigger(triggerMsg) try {
processTrigger(triggerMsg)
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 处理触发消息失败", e)
sendFailedMessage(napCatClient, triggerMsg.realId, triggerMsg.time, "处理异常: ${e.message}")
}
} }
} }
private suspend fun processTrigger(msg: GetFriendMsgHistoryEvent.SpecificMsg) { private suspend fun processTrigger(msg: MsgHistorySpecificMsg) {
val now = System.currentTimeMillis() LoggerUtil.logger.info("[$name] 执行 RCON 查询")
// ✅ 冷却检查(首次触发直接允许)
val canTrigger = (lastSuccessTime == 0L) || (now - lastSuccessTime >= cooldownMillis)
if (!canTrigger) {
val remaining = ((cooldownMillis - (now - lastSuccessTime)) / 1000).coerceAtLeast(1)
LoggerUtil.logger.info("[$name] 冷却中,拒绝执行,剩余 $remaining")
sendCooldownMessage(napCatClient, msg.realId, msg.time)
return
}
// ✅ 执行 RCON 命令
val commands = listOf("forge tps", "list") val commands = listOf("forge tps", "list")
LoggerUtil.logger.info("[$name] 执行 RCON 命令: $commands") LoggerUtil.logger.info("[$name] 执行 RCON 命令: $commands")
runCatching { runCatching {
val tpsOutput = runCatching { val tpsOutput = CmdUtil.runExeCommand(
CmdUtil.runExeCommand( rconPath, "-c", rconConfigPath,
rconPath, "-T", (rconTimeOut / 1000).toString() + "s", "forge tps"
"-c", rconConfigPath, )
"-T", (rconTimeOut / 1000).toString() + "s", val listOutput = CmdUtil.runExeCommand(
"forge tps" rconPath, "-c", rconConfigPath,
) "-T", (rconTimeOut / 1000).toString() + "s", "list"
}.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")) { if (tpsOutput.contains("i/o timeout") || listOutput.contains("i/o timeout")) {
throw TimeoutException() throw TimeoutException()
} }
// 合并输出,后续一起解析
buildString { buildString {
appendLine(tpsOutput.trim()) appendLine(tpsOutput.trim())
appendLine("--------") appendLine("--------")
appendLine(listOutput.trim()) appendLine(listOutput.trim())
} }
}.onFailure { ex -> }.onFailure { ex ->
lastSuccessTime = now // ✅ 成功/失败都要刷新冷却开始时间 LoggerUtil.logger.error("[$name] RCON 查询失败", ex)
if (ex is TimeoutException) { if (ex is TimeoutException) {
LoggerUtil.logger.warn("[$name] RCON 连接超时: ${ex.message}") sendFailedMessage(napCatClient, msg.realId, msg.time, "⏳ RCON 连接超时")
sendFailedMessage(napCatClient, msg.realId, msg.time) // ✅ 更新触发状态 & 持久化
} else { lastTriggerState.lastTriggeredRealId = msg.realId
LoggerUtil.logger.error("[$name] RCON 命令执行失败", ex) lastTriggerState.lastTriggerTime = msg.time
sendFailedMessage( saveState(lastTriggerState)
napCatClient, return
msg.realId,
msg.time,
"系统内部错误请联系管理员:${ex.message}"
)
throw ex
} }
throw ex
}.onSuccess { output -> }.onSuccess { output ->
lastSuccessTime = now
LoggerUtil.logger.info("[$name] RCON 命令执行成功,输出长度: ${output.length}")
LoggerUtil.logger.debug("[$name] RCON 输出内容: $output")
val tpsInfo = parseTPS(output) val tpsInfo = parseTPS(output)
val playerListInfo = parsePlayerList(output) val playerListInfo = parsePlayerList(output)
LoggerUtil.logger.info(
"[$name] 解析成功: TPS=${tpsInfo.overall.meanTPS}, 在线 ${playerListInfo.onlineCount}"
)
sendForwardMessage(napCatClient, tpsInfo, playerListInfo, msg.realId, msg.time) sendForwardMessage(napCatClient, tpsInfo, playerListInfo, msg.realId, msg.time)
} }
@ -175,11 +174,8 @@ class RconPlayerListModule(
} }
private suspend fun sendCooldownMessage(client: NapCatClient, realId: Long, time: Long) { private suspend fun sendCooldownMessage(client: NapCatClient, realId: Long, remaining: Long) {
val now = System.currentTimeMillis()
val remaining = ((cooldownMillis - (now - lastSuccessTime)) / 1000).coerceAtLeast(1) // 至少显示 1 秒
val msg = "⏳ 查询过于频繁,请稍后再试(剩余 $remaining 秒)" val msg = "⏳ 查询过于频繁,请稍后再试(剩余 $remaining 秒)"
LoggerUtil.logger.info("[$name] 发送冷却提示: $msg") LoggerUtil.logger.info("[$name] 发送冷却提示: $msg")
val request = SendGroupMsgRequest( val request = SendGroupMsgRequest(
@ -187,11 +183,6 @@ class RconPlayerListModule(
ID.long(groupMessagePollingModule.targetGroupId) ID.long(groupMessagePollingModule.targetGroupId)
) )
client.sendUnit(request) client.sendUnit(request)
// 更新触发状态,但不更新 lastSuccessTime避免延长冷却
lastTriggerState.lastTriggeredRealId = realId
lastTriggerState.lastTriggerTime = time
saveState(lastTriggerState)
} }
private val failedMessages = listOf( private val failedMessages = listOf(
@ -479,13 +470,30 @@ class RconPlayerListModule(
// ---------------- 持久化部分 ---------------- // ---------------- 持久化部分 ----------------
@Serializable @Serializable
data class LastTriggerState(var lastTriggeredRealId: Long, var lastTriggerTime: Long) data class LastTriggerState(
var lastTriggeredRealId: Long = -1, // 上次允许处理消息ID
var lastTriggerTime: Long = 0, // 上次允许处理时间(毫秒或秒都可以,根据你的逻辑)
var lastCooldownRealId: Long = -1 // 上次冷却期间被拒绝的消息ID
) {
/** ✅ 冷却结束,更新触发状态 */
fun updateTrigger(realId: Long, time: Long) {
lastTriggeredRealId = realId
lastTriggerTime = time
// 保留 lastCooldownRealId 不变
}
/** ⚠️ 冷却中更新冷却消息ID */
fun updateCooldownRealId(realId: Long) {
lastCooldownRealId = realId
// 保留 lastTriggeredRealId 和 lastTriggerTime
}
}
override fun saveState(state: LastTriggerState) { override fun saveState(state: LastTriggerState) {
try { try {
// 先备份现有主文件 // 先备份现有主文件
if (stateFile.exists()) { if (stateFile.exists()) {
stateFile.copyTo(File(stateFile.parent, stateFile.name + ".bak"), overwrite = true) stateFile.copyTo(stateBackupFile, overwrite = true)
} }
// 写入主文件 // 写入主文件
@ -500,7 +508,7 @@ class RconPlayerListModule(
return try { return try {
val fileToRead = when { val fileToRead = when {
stateFile.exists() -> stateFile stateFile.exists() -> stateFile
File(stateFile.parent, stateFile.name + ".bak").exists() -> File(stateFile.parent, stateFile.name + ".bak") stateBackupFile.exists() -> stateBackupFile
else -> null else -> null
} }
@ -517,5 +525,36 @@ class RconPlayerListModule(
LastTriggerState(-1L, 0L) LastTriggerState(-1L, 0L)
} }
} }
// 返回模块基本信息
override fun info(): String = buildString {
appendLine("模块名称: $name")
appendLine("模块类型: RconPlayerListModule")
appendLine("目标群组: ${groupMessagePollingModule.targetGroupId}")
appendLine("机器人昵称: $selfNickName (ID: $selfId)")
appendLine("冷却时间: ${cooldownMillis / 1000}")
appendLine("RCON 命令路径: $rconPath")
appendLine("RCON 配置文件路径: $rconConfigPath")
appendLine("RCON 超时时间: $rconTimeOut ms")
appendLine("关键词触发: ${keywords.joinToString(", ")}")
appendLine("状态文件路径: ${stateFile.absolutePath}")
appendLine("状态备份文件路径: ${stateBackupFile.absolutePath}")
appendLine("上次触发消息ID: ${lastTriggerState.lastTriggeredRealId}")
appendLine("上次触发时间: ${lastTriggerState.lastTriggerTime}")
}
// 返回模块使用帮助
override fun help(): String = buildString {
appendLine("使用帮助 - RconPlayerListModule")
appendLine("功能: 查询服务器 TPS 和在线玩家列表,通过关键词触发或冷却机制限制频率")
appendLine("触发关键词: ${keywords.joinToString(", ")}")
appendLine("示例:")
keywords.forEach { keyword ->
appendLine(" - 在群里发送 \"$keyword\" 将触发 RCON 查询")
}
appendLine("注意事项:")
appendLine(" - 查询冷却时间为 ${cooldownMillis / 1000}")
appendLine(" - RCON 查询可能受服务器响应时间影响")
appendLine(" - 查询结果会以转发消息形式发送到群组")
}
} }

View File

@ -0,0 +1,27 @@
package top.r3944realms.ltdmanager.module
import kotlinx.coroutines.*
import top.r3944realms.ltdmanager.napcat.request.account.SetQQProfileRequest
//TODO: 有问题不要使用 #unload得考虑下怎么写
class StateModule(
moduleName: String,
private val onlineName: String,
private val offlineName: String,
): BaseModule(Modules.STATE, moduleName) {
private var scope: CoroutineScope? = null
override fun onLoad() {
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope!!.launch {
if (loaded) updateProfile(onlineName)
}
}
private suspend fun updateProfile(name: String) {
napCatClient.sendUrgentUnit(SetQQProfileRequest(name))
}
override suspend fun onUnload() {
updateProfile(offlineName)
scope!!.cancel()
}
}

View File

@ -0,0 +1,342 @@
package top.r3944realms.ltdmanager.module.common
/**
* 高级命令解析器
* 支持自定义参数语法和参数验证
*/
class AdvancedCommandParser {
private val commands = mutableListOf<CommandDefinition>()
/**
* 命令定义类
*/
data class CommandDefinition(
val name: String,
val aliases: List<String> = emptyList(),
val syntax: String = "",
val description: String = "",
val parameterPattern: Regex = DEFAULT_PARAMETER_PATTERN
) {
val allCommandForms: List<String> get() = listOf(name) + aliases
}
/**
* 解析结果
*/
data class ParseResult(
val command: String,
val arguments: Map<String, String> = emptyMap(),
val rawArguments: List<String> = emptyList(),
val isValid: Boolean = true,
val errorMessage: String? = null,
val commandDefinition: CommandDefinition? = null
)
companion object {
// 默认参数模式:<参数名> 或 [可选参数名]
val DEFAULT_PARAMETER_PATTERN = Regex("""<(\w+)>|\[(\w+)]""")
// 常用参数模式
/**
* 必需参数
*/
val ANGLE_BRACKETS = Regex("""<(\w+)>""") // <param>
/**
* 可选参数
*/
val SQUARE_BRACKETS = Regex("""\[(\w+)]""") // [param]
/**
* 自定义参数类型
*/
val CURLY_BRACES = Regex("""\{(\w+)}""") // {param}
}
/**
* 注册命令
*/
fun registerCommand(
name: String,
aliases: List<String> = emptyList(),
syntax: String = "",
description: String = "",
parameterPattern: Regex = DEFAULT_PARAMETER_PATTERN
): AdvancedCommandParser {
commands.add(CommandDefinition(name, aliases, syntax, description, parameterPattern))
return this
}
/**
* 批量注册命令
*/
fun registerCommands(vararg commandDefs: CommandDefinition): AdvancedCommandParser {
commands.addAll(commandDefs)
return this
}
/**
* 智能分割参数正确处理引号内的空格
*/
private fun smartSplit(input: String): List<String> {
val result = mutableListOf<String>()
val current = StringBuilder()
var inQuotes = false
var quoteChar: Char? = null
var escapeNext = false
for (char in input) {
when {
escapeNext -> {
current.append(char)
escapeNext = false
}
char == '\\' -> {
escapeNext = true
}
char == '"' || char == '\'' -> {
if (inQuotes && char == quoteChar) {
// 结束引号
inQuotes = false
quoteChar = null
} else if (!inQuotes) {
// 开始引号
inQuotes = true
quoteChar = char
} else {
current.append(char)
}
}
char == ' ' && !inQuotes -> {
// 空格分隔,但不是引号内
if (current.isNotEmpty()) {
result.add(current.toString())
current.clear()
}
}
else -> {
current.append(char)
}
}
}
if (current.isNotEmpty()) {
result.add(current.toString())
}
return result
}
/**
* 解析命令
*/
private fun parse(input: String): ParseResult {
val trimmedInput = input.trim()
if (trimmedInput.isEmpty()) {
return ParseResult("", isValid = false, errorMessage = "输入为空")
}
// 分割命令和参数
val parts = smartSplit(trimmedInput)
val commandPart = parts[0]
// 查找匹配的命令定义
val commandDef = commands.find { def ->
def.allCommandForms.any { it.equals(commandPart, ignoreCase = true) }
}
if (commandDef == null) {
return ParseResult(
commandPart,
isValid = false,
errorMessage = "未知命令: $commandPart"
)
}
// 解析参数
val arguments = parseArguments(commandDef, parts.drop(1))
val rawArgs = parts.drop(1)
return ParseResult(
command = commandDef.name,
arguments = arguments,
rawArguments = rawArgs,
commandDefinition = commandDef
)
}
/**
* 解析参数
*/
private fun parseArguments(commandDef: CommandDefinition, args: List<String>): Map<String, String> {
val parameters = extractParameterNames(commandDef.syntax, commandDef.parameterPattern)
val result = mutableMapOf<String, String>()
if (parameters.isEmpty()) {
args.forEachIndexed { index, value -> result["arg${index + 1}"] = value }
return result
}
val positionals = mutableListOf<String>()
val namedParams = mutableMapOf<String, String>()
var i = 0
// 第一遍:处理命名参数
while (i < args.size) {
when {
args[i].startsWith("--") -> {
val paramName = args[i].substring(2)
if (paramName in parameters) {
if (i + 1 < args.size && !args[i + 1].startsWith("-")) {
namedParams[paramName] = args[i + 1]
i += 2
} else {
namedParams[paramName] = "true"
i += 1
}
} else {
positionals.add(args[i])
i += 1
}
}
args[i].startsWith("-") && args[i].length > 1 && !args[i].startsWith("--") -> {
val paramName = args[i].substring(1)
if (paramName in parameters) {
if (i + 1 < args.size && !args[i + 1].startsWith("-")) {
namedParams[paramName] = args[i + 1]
i += 2
} else {
namedParams[paramName] = "true"
i += 1
}
} else {
positionals.add(args[i])
i += 1
}
}
else -> {
positionals.add(args[i])
i += 1
}
}
}
// 第二遍:映射位置参数
var posIndex = 0
for (paramName in parameters) {
if (paramName !in namedParams && posIndex < positionals.size) {
result[paramName] = positionals[posIndex]
posIndex++
} else if (paramName in namedParams) {
result[paramName] = namedParams[paramName]!!
}
}
// 处理额外参数
for (j in posIndex until positionals.size) {
result["extraArg${j - posIndex + 1}"] = positionals[j]
}
return result
}
/**
* 从语法字符串中提取参数名
*/
private fun extractParameterNames(syntax: String, pattern: Regex): List<String> {
if (syntax.isEmpty()) return emptyList()
return pattern.findAll(syntax).map { matchResult ->
matchResult.groupValues[1].ifEmpty { matchResult.groupValues[2] }
}.toList()
}
/**
* 验证参数是否符合要求
*/
fun validateArguments(result: ParseResult): ParseResult {
if (!result.isValid) return result
val commandDef = result.commandDefinition ?: return result.copy(
isValid = false,
errorMessage = "命令定义不存在"
)
val requiredParams = extractParameterNames(commandDef.syntax, ANGLE_BRACKETS)
val missingParams = requiredParams.filter { it !in result.arguments }
return if (missingParams.isNotEmpty()) {
result.copy(
isValid = false,
errorMessage = "缺少必需参数: ${missingParams.joinToString()}"
)
} else {
result
}
}
/**
* 获取命令的帮助信息增强版
*/
fun getCommandHelp(commandName: String): String? {
val commandDef = commands.find { it.name == commandName || commandName in it.aliases }
return commandDef?.let { def ->
buildString {
appendLine("命令: ${def.name}")
if (def.aliases.isNotEmpty()) {
appendLine("别名: ${def.aliases.joinToString()}")
}
appendLine("用法: ${def.name} ${def.syntax}")
appendLine("描述: ${def.description}")
// 显示参数说明
val params = extractParameterNames(def.syntax, def.parameterPattern)
if (params.isNotEmpty()) {
appendLine("参数:")
params.forEach { param ->
val isRequired = def.syntax.contains("<$param>")
appendLine(" ${if (isRequired) "<$param>" else "[$param]"} - ${if (isRequired) "必需" else "可选"}")
}
}
}
}
}
/**
* 获取所有注册的命令
*/
fun getRegisteredCommands(): List<CommandDefinition> = commands.toList()
/**
* 获取所有命令的帮助信息
*/
fun getAllCommandsHelp(): String {
return buildString {
appendLine("可用命令:")
appendLine("=".repeat(10))
commands.forEach { def ->
appendLine("${def.name} - ${def.description}")
if (def.aliases.isNotEmpty()) {
appendLine(" 别名: ${def.aliases.joinToString()}")
}
appendLine(" 用法: ${def.name} ${def.syntax}")
appendLine()
}
}
}
/**
* 检查输入是否包含有效命令
*/
fun containsCommand(input: String): Boolean {
val trimmedInput = input.trim()
if (trimmedInput.isEmpty()) return false
val commandPart = trimmedInput.split("\\s+".toRegex())[0]
return commands.any { def ->
def.allCommandForms.any { it.equals(commandPart, ignoreCase = true) }
}
}
/**
* 快速解析包含验证
*/
fun parseAndValidate(input: String): ParseResult {
return validateArguments(parse(input))
}
}

View File

@ -0,0 +1,55 @@
package top.r3944realms.ltdmanager.module.common
/**
* 命令解析器
* 严格模式只支持命令后带空格的情况避免误读
*/
class CommandParser(private val commands: List<String>) {
/**
* 获取指令
*/
fun getCommands(): List<String> = commands
/**
* 解析命令
* @param text 输入的文本
* @return Pair<命令, 参数> null如果不是有效命令
*/
fun parseCommand(text: String): Pair<String, String>? {
val trimmedText = text.trim()
// 查找匹配的命令(必须后面跟着空格或字符串结束)
val matchedCommand = commands.firstOrNull { command ->
trimmedText.startsWith("$command ") || trimmedText == command
} ?: return null
// 获取参数部分
val argument = if (trimmedText.length > matchedCommand.length) {
trimmedText.substring(matchedCommand.length).trim()
} else {
""
}
return Pair(matchedCommand, argument)
}
/**
* 检查文本是否包含有效命令
*/
fun containsCommand(text: String): Boolean {
return parseCommand(text.trim()) != null
}
/**
* 获取命令部分不包含参数
*/
fun getCommandOnly(text: String): String? {
return parseCommand(text.trim())?.first
}
/**
* 获取参数部分不包含命令
*/
fun getArgumentOnly(text: String): String {
return parseCommand(text.trim())?.second ?: ""
}
}

View File

@ -0,0 +1,72 @@
package top.r3944realms.ltdmanager.module.common.cooldown
import top.r3944realms.ltdmanager.GlobalManager.napCatClient
import top.r3944realms.ltdmanager.napcat.data.ID
import top.r3944realms.ltdmanager.napcat.request.group.SetGroupBanRequest
import kotlin.math.max
/**
* 管理冷却
* @param S 状态类型
*/
class CooldownManager<S>(
private val cooldownMillis: Long,
private val scope: CooldownScope,
private val stateProvider: CooldownStateProvider<S>,
private val getLastTrigger: (S, Long?) -> Pair<Long, Long>, // (lastTimeSec, lastRealId)
private val updateTrigger: (S, Long?, Long, Long) -> S, // 更新 lastTimeSec, lastRealId
private val updateCooldownRealId: (S, Long?, Long) -> S,
private val groupId: Long, // 所属群组 ID用于禁言
private val banSeconds: Int = 60 // 重复发送禁言时间
) {
private var state: S = stateProvider.load()
/**
* 检查冷却
* @param userId PerUser 模式必须
* @param realId 消息 realId
*/
suspend fun checkAndHandle(userId: Long?, realId: Long): CooldownResult {
require(scope != CooldownScope.PerUser || userId != null) { "userId required for per-user cooldown" }
val (lastTimeSec, lastCooldownRealId) = getLastTrigger(state, userId)
val cooldownSec = cooldownMillis / 1000
val now = System.currentTimeMillis() / 1000
val elapsed = if (lastTimeSec == -1L) Long.MAX_VALUE else now - lastTimeSec
return if (elapsed >= cooldownSec) {
// ✅ 冷却结束,允许处理消息
state = updateTrigger(state, userId, realId, now)
stateProvider.save(state)
CooldownResult(allowed = true, remaining = 0, notify = false)
} else {
val remaining = max(0, cooldownSec - elapsed)
val notify = realId != lastCooldownRealId // 第一次触发冷却提示
if (notify) {
// 第一次冷却提示,记录消息 ID
state = updateCooldownRealId(state, userId, realId)
stateProvider.save(state)
} else {
// // ⚠️ 重复发送冷却消息 -> 禁言
// if (userId != null) {
// banUser(userId, groupId, banSeconds)
// }
}
CooldownResult(allowed = false, remaining = remaining, notify = notify)
}
}
private suspend fun banUser(userId: Long, groupId: Long, seconds: Int) {
val request = SetGroupBanRequest(
duration = seconds.toDouble(),
groupId = ID.long(groupId),
userId = ID.long(userId)
)
napCatClient.sendUnit(request)
}
}

View File

@ -0,0 +1,13 @@
package top.r3944realms.ltdmanager.module.common.cooldown
/**
* 冷却结果
* @param allowed 是否允许触发
* @param remaining 剩余秒数如果未允许触发
* @param notify 是否可以发送冷却提示
*/
data class CooldownResult(
val allowed: Boolean,
val remaining: Long = 0L,
val notify: Boolean = true
)

View File

@ -0,0 +1,6 @@
package top.r3944realms.ltdmanager.module.common.cooldown
sealed class CooldownScope {
data object Global : CooldownScope()
data object PerUser : CooldownScope()
}

View File

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

View File

@ -0,0 +1,7 @@
package top.r3944realms.ltdmanager.module.common.filter
import top.r3944realms.ltdmanager.napcat.data.msghistory.MsgHistorySpecificMsg
interface MessageFilter {
suspend fun test(msg: MsgHistorySpecificMsg): Boolean
}

View File

@ -0,0 +1,17 @@
package top.r3944realms.ltdmanager.module.common.filter
import top.r3944realms.ltdmanager.napcat.data.msghistory.MsgHistorySpecificMsg
class TriggerMessageFilter(private val filters: List<MessageFilter>) {
suspend fun filter(messages: List<MsgHistorySpecificMsg>)
: List<MsgHistorySpecificMsg> {
val result = mutableListOf<MsgHistorySpecificMsg>()
for (msg in messages) {
if (filters.all { it.test(msg) }) {
result.add(msg)
}
}
return result
}
}

View File

@ -0,0 +1,17 @@
package top.r3944realms.ltdmanager.module.common.filter.type
import top.r3944realms.ltdmanager.module.common.AdvancedCommandParser
import top.r3944realms.ltdmanager.module.common.filter.MessageFilter
import top.r3944realms.ltdmanager.napcat.data.MessageType
import top.r3944realms.ltdmanager.napcat.data.msghistory.MsgHistorySpecificMsg
class AdvancedCommonFilter(private val advancedCommandParser: AdvancedCommandParser): MessageFilter {
override suspend fun test(msg: MsgHistorySpecificMsg): Boolean {
return msg.message.any { seg ->
seg.type == MessageType.Text && seg.data.text?.let { text ->
advancedCommandParser.getRegisteredCommands().map { it.name }.any { name -> text.startsWith(name) }
} == true
}
}
}

View File

@ -0,0 +1,15 @@
package top.r3944realms.ltdmanager.module.common.filter.type
import top.r3944realms.ltdmanager.module.common.CommandParser
import top.r3944realms.ltdmanager.module.common.filter.MessageFilter
import top.r3944realms.ltdmanager.napcat.data.MessageType
import top.r3944realms.ltdmanager.napcat.data.msghistory.MsgHistorySpecificMsg
/** 命令解析器匹配 */
class CommandFilter(private val parser: CommandParser) : MessageFilter {
override suspend fun test(msg: MsgHistorySpecificMsg): Boolean {
return msg.message.any { seg ->
seg.type == MessageType.Text && seg.data.text?.let { parser.containsCommand(it) } == true
}
}
}

View File

@ -0,0 +1,19 @@
package top.r3944realms.ltdmanager.module.common.filter.type
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownManager
import top.r3944realms.ltdmanager.module.common.filter.MessageFilter
import top.r3944realms.ltdmanager.napcat.data.msghistory.MsgHistorySpecificMsg
class CooldownFilter(
private val cooldownManager: CooldownManager<*>,
private val sendCooldown: suspend (MsgHistorySpecificMsg, Long) -> Unit
) : MessageFilter {
override suspend fun test(msg: MsgHistorySpecificMsg): Boolean {
val result = cooldownManager.checkAndHandle(msg.userId, msg.realId)
if (!result.allowed && result.notify) {
sendCooldown(msg, result.remaining)
}
return result.allowed
}
}

Some files were not shown because too many files have changed in this diff Show More