Compare commits
10 Commits
2b5af6b52c
...
81e1237635
| Author | SHA1 | Date | |
|---|---|---|---|
| 81e1237635 | |||
| 988ed191ec | |||
| 37eeaf143c | |||
| 4da8263b45 | |||
| e45f2f6272 | |||
| a0f5504404 | |||
| f95c6701e5 | |||
| 88f574eea1 | |||
| 9f83026e56 | |||
| 95e21f8b84 |
|
|
@ -1,11 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<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>
|
||||
<synchronize>true</synchronize>
|
||||
<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>
|
||||
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||
|
|
|
|||
12
.idea/inspectionProfiles/Project_Default.xml
Normal file
12
.idea/inspectionProfiles/Project_Default.xml
Normal 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>
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ASMIdeaPluginConfiguration">
|
||||
<asm skipDebug="false" skipFrames="false" skipCode="false" expandFrames="false" />
|
||||
|
|
|
|||
124
.idea/uiDesigner.xml
Normal file
124
.idea/uiDesigner.xml
Normal 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>
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||
|
||||
fun k(v: String) = project.property(v) as String
|
||||
|
||||
plugins {
|
||||
kotlin("jvm") 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
|
||||
}
|
||||
|
||||
group = project.property("project_group") as String
|
||||
version = project.property("project_version") as String
|
||||
group = k("project_group")
|
||||
version = k("project_version")
|
||||
|
||||
repositories {
|
||||
|
||||
|
|
@ -22,6 +24,17 @@ repositories {
|
|||
maven {
|
||||
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
|
||||
dependencies {
|
||||
|
|
@ -35,6 +48,10 @@ repositories {
|
|||
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.3") // 推荐使用kotlinx.serialization替代Gson
|
||||
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-jdbc:0.41.1")
|
||||
|
|
@ -58,6 +75,19 @@ repositories {
|
|||
|
||||
// 协程
|
||||
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"))
|
||||
|
|
|
|||
|
|
@ -3,4 +3,5 @@ org.gradle.downloadSources=false
|
|||
org.gradle.parallel=true
|
||||
org.gradle.degree_of_parallelism=16
|
||||
project_group=top.r3944realms.ltdmanager
|
||||
project_version=1.3-SNAPSHOT
|
||||
project_version=1.16-SNAPSHOT
|
||||
dg_lab_version=4.4.14.18
|
||||
|
|
|
|||
BIN
libs/DgLab-common-4.3.13.18.jar
Normal file
BIN
libs/DgLab-common-4.3.13.18.jar
Normal file
Binary file not shown.
|
|
@ -2,7 +2,9 @@ package top.r3944realms.ltdmanager
|
|||
|
||||
import kotlinx.coroutines.*
|
||||
import top.r3944realms.ltdmanager.blessingskin.BlessingSkinClient
|
||||
import top.r3944realms.ltdmanager.chevereto.CheveretoClient
|
||||
import top.r3944realms.ltdmanager.core.mysql.MysqlHikariConnectPool
|
||||
import top.r3944realms.ltdmanager.mcms.MCSMClient
|
||||
import top.r3944realms.ltdmanager.mcserver.McSrvStatusClient
|
||||
import top.r3944realms.ltdmanager.module.ModuleManager
|
||||
import top.r3944realms.ltdmanager.napcat.NapCatClient
|
||||
|
|
@ -19,6 +21,10 @@ object GlobalManager {
|
|||
MysqlHikariConnectPool()
|
||||
}
|
||||
|
||||
fun initApplication() {
|
||||
moduleManager.loadConfig()
|
||||
}
|
||||
|
||||
// NapCat 客户端
|
||||
val napCatClient: NapCatClient by lazy {
|
||||
NapCatClient.create()
|
||||
|
|
@ -29,6 +35,12 @@ object GlobalManager {
|
|||
val blessingSkinClient: BlessingSkinClient by lazy {
|
||||
BlessingSkinClient.create()
|
||||
}
|
||||
val cheveretoClient: CheveretoClient by lazy {
|
||||
CheveretoClient.create()
|
||||
}
|
||||
val mcsmClient: MCSMClient by lazy {
|
||||
MCSMClient.create()
|
||||
}
|
||||
|
||||
val moduleManager: ModuleManager by lazy { ModuleManager() }
|
||||
|
||||
|
|
@ -67,7 +79,9 @@ object GlobalManager {
|
|||
"NapCatClient" to { napCatClient.close() },
|
||||
"McSrvStatusClient" to { mcSrvStatusClient.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) ->
|
||||
|
|
@ -94,4 +108,5 @@ object GlobalManager {
|
|||
isRunning.set(false)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -1,27 +1,24 @@
|
|||
package top.r3944realms.ltdmanager.blessingskin
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult
|
||||
import top.r3944realms.ltdmanager.core.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.utils.Environment
|
||||
import top.r3944realms.ltdmanager.utils.LoggerUtil
|
||||
import java.net.URLEncoder
|
||||
import java.util.*
|
||||
|
||||
class BlessingSkinClient private constructor() : AutoCloseable {
|
||||
class BlessingSkinClient private constructor() : IClient<BlessingSkinRequest, BlessingSkinQueueItem, BlessingSkinResponse, FailedBlessingSkinResponse> {
|
||||
private val client = HttpClient(CIO) {
|
||||
expectSuccess = false
|
||||
|
||||
|
|
@ -40,170 +37,40 @@ class BlessingSkinClient private constructor() : AutoCloseable {
|
|||
// 限流控制
|
||||
private val semaphore = Semaphore(5)
|
||||
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())
|
||||
|
||||
init {
|
||||
startQueueProcessor()
|
||||
init()
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交请求
|
||||
*/
|
||||
suspend fun <T : BlessingSkinResponse, F : FailedBlessingSkinResponse> submitRequest(
|
||||
request: BlessingSkinRequest<T, F>,
|
||||
priority: Int = 5,
|
||||
maxRetries: Int = 3
|
||||
): ResponseResult<T, F> {
|
||||
val deferred = CompletableDeferred<ResponseResult<T, F>>()
|
||||
requestMutex.withLock {
|
||||
requestQueue.add(BlessingSkinQueueItem(request, deferred, priority, maxRetries, true))
|
||||
}
|
||||
return deferred.await()
|
||||
override fun getBaseUrl(): String = blessingSkinServerConfig.url!!
|
||||
|
||||
override fun getType(): String = "BlessingSkinClient"
|
||||
|
||||
override fun getClient(): HttpClient = client
|
||||
|
||||
override fun getSemaphore(): Semaphore = semaphore
|
||||
|
||||
override fun getRequestMutex(): Mutex = requestMutex
|
||||
|
||||
override fun getResponseQueue(): PriorityQueue<BlessingSkinQueueItem> = requestQueue
|
||||
|
||||
override fun getScope(): CoroutineScope = scope
|
||||
|
||||
override fun createFailureResponse(exception: Exception?): IFailedResponse {
|
||||
return FailedBlessingSkinResponse.Default(exception?.stackTraceToString()?:"ERROR")
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动队列处理器
|
||||
*/
|
||||
private fun startQueueProcessor() {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
val item = requestMutex.withLock {
|
||||
requestQueue.poll()
|
||||
}
|
||||
if (item == null) {
|
||||
delay(50)
|
||||
continue
|
||||
}
|
||||
processQueueItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理队列项
|
||||
*/
|
||||
private suspend fun processQueueItem(item: BlessingSkinQueueItem<BlessingSkinResponse, FailedBlessingSkinResponse>) {
|
||||
semaphore.withPermit {
|
||||
val (request, deferred, _, maxRetries, expectsResponse) = item
|
||||
var attempt = 0
|
||||
var lastError: Exception? = null
|
||||
|
||||
while (attempt < maxRetries) {
|
||||
try {
|
||||
// 构建完整的URL,包括查询参数
|
||||
val fullUrl = buildFullUrlWithQueryParams(request)
|
||||
|
||||
if (!Environment.isProduction()) {
|
||||
LoggerUtil.logger.debug("发送请求到: $fullUrl")
|
||||
LoggerUtil.logger.debug("请求方法: {}", request.method())
|
||||
}
|
||||
|
||||
val response = client.request(fullUrl) {
|
||||
method = request.method()
|
||||
|
||||
|
||||
// 设置请求头
|
||||
headers {
|
||||
request.headers().invoke(this)
|
||||
}
|
||||
|
||||
// 对于非GET请求,设置请求体
|
||||
if (request.method() != HttpMethod.Get) {
|
||||
setBody(request.toJSON())
|
||||
}
|
||||
}
|
||||
|
||||
val responseText: String = response.body()
|
||||
|
||||
if (!Environment.isProduction()) {
|
||||
LoggerUtil.logger.debug("响应状态: {}", response.status)
|
||||
LoggerUtil.logger.debug("响应内容: $responseText")
|
||||
}
|
||||
|
||||
// 检查是否是HTML响应(重定向)
|
||||
if (isHtmlResponse(responseText)) {
|
||||
throw IllegalStateException("接收到HTML重定向响应,请检查API URL配置")
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
val result = request.getResponse(responseText, response.status)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(deferred as CompletableDeferred<ResponseResult<BlessingSkinResponse, FailedBlessingSkinResponse>>).complete(result)
|
||||
|
||||
return
|
||||
|
||||
} catch (e: Exception) {
|
||||
lastError = e
|
||||
attempt++
|
||||
|
||||
if (!request.shouldRetryOnFailure() || attempt >= maxRetries) {
|
||||
break
|
||||
}
|
||||
|
||||
LoggerUtil.logger.warn("BlessingSkin请求失败 (尝试 $attempt/$maxRetries): ${e.message}")
|
||||
delay((attempt * 1000L)) // 指数退避
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重试都失败或不应重试
|
||||
val errorResponse = createFailureResponse(lastError, request)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(deferred as CompletableDeferred<ResponseResult<BlessingSkinResponse, FailedBlessingSkinResponse>>).complete(
|
||||
ResponseResult.Failure(errorResponse)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整的URL,包含查询参数
|
||||
*/
|
||||
private fun buildFullUrlWithQueryParams(request: BlessingSkinRequest<*, *>): String {
|
||||
val baseUrl = blessingSkinServerConfig.url?.removeSuffix("/")
|
||||
val path = request.path().removePrefix("/")
|
||||
|
||||
// 构建基础URL
|
||||
val urlBuilder = StringBuilder("$baseUrl/$path")
|
||||
|
||||
// 添加查询参数
|
||||
val queryParams = request.queryParameters().entries.joinToString("&") { (key, value) ->
|
||||
"${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}"
|
||||
}
|
||||
|
||||
if (queryParams.isNotEmpty()) {
|
||||
urlBuilder.append("?").append(queryParams)
|
||||
}
|
||||
|
||||
return urlBuilder.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是HTML响应
|
||||
*/
|
||||
private fun isHtmlResponse(text: String): Boolean {
|
||||
return text.contains("<!DOCTYPE html>", ignoreCase = true) ||
|
||||
text.contains("<html>", ignoreCase = true) ||
|
||||
text.contains("Redirecting", ignoreCase = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建失败响应
|
||||
*/
|
||||
private fun createFailureResponse(
|
||||
exception: Exception?,
|
||||
request: BlessingSkinRequest<*, *>
|
||||
): FailedBlessingSkinResponse {
|
||||
return FailedBlessingSkinResponse.Default(
|
||||
failedResult = exception?.message ?: "未知错误",
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
scope.cancel()
|
||||
runBlocking {
|
||||
client.close()
|
||||
}
|
||||
override fun addToQueue(
|
||||
request: BlessingSkinRequest,
|
||||
deferredC: CompletableDeferred<ResponseResult<BlessingSkinResponse, FailedBlessingSkinResponse>>,
|
||||
priority: Int,
|
||||
maxRetries: Int
|
||||
): BlessingSkinQueueItem {
|
||||
val element = BlessingSkinQueueItem(request, deferredC, priority, maxRetries, false)
|
||||
requestQueue.add(element)
|
||||
return element
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ import kotlinx.coroutines.CompletableDeferred
|
|||
import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
|
||||
import top.r3944realms.ltdmanager.core.client.QueueItem
|
||||
|
||||
data class BlessingSkinQueueItem<out T:BlessingSkinResponse,out F:FailedBlessingSkinResponse>(
|
||||
val request: BlessingSkinRequest<T,F>,
|
||||
val deferred: CompletableDeferred<*>,
|
||||
var retries: Int,
|
||||
val priority: Int,
|
||||
val expectsResponse: Boolean // true 表示返回 BlessingSkinResponse, false 表示 Unit
|
||||
) : Comparable<BlessingSkinQueueItem<@UnsafeVariance T, @UnsafeVariance F>> {
|
||||
override fun compareTo(other: BlessingSkinQueueItem<@UnsafeVariance T, @UnsafeVariance F>): Int = priority.compareTo(other.priority)
|
||||
}
|
||||
data class BlessingSkinQueueItem (
|
||||
val request0: BlessingSkinRequest,
|
||||
val deferred0: CompletableDeferred<*>,
|
||||
val priority0: Int,
|
||||
var retries0: Int,
|
||||
val expectsResponse0: Boolean
|
||||
) : QueueItem<BlessingSkinRequest, BlessingSkinResponse, FailedBlessingSkinResponse> (
|
||||
request0, deferred0, retries0, priority0, expectsResponse0
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -1,79 +1,13 @@
|
|||
package top.r3944realms.ltdmanager.blessingskin.request
|
||||
|
||||
import io.ktor.http.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult
|
||||
import top.r3944realms.ltdmanager.core.client.request.IRequest
|
||||
|
||||
@Serializable
|
||||
abstract class BlessingSkinRequest<out T : BlessingSkinResponse, out F : FailedBlessingSkinResponse>(
|
||||
abstract class BlessingSkinRequest(
|
||||
@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
|
||||
}
|
||||
override val createTime: Long = System.currentTimeMillis()
|
||||
): IRequest<BlessingSkinResponse, FailedBlessingSkinResponse>
|
||||
|
|
@ -6,8 +6,8 @@ import kotlinx.serialization.Transient
|
|||
import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.invitecode.InvitationCodeGenerationResponse
|
||||
import top.r3944realms.ltdmanager.core.client.response.ResponseResult
|
||||
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
|
||||
import java.util.*
|
||||
|
||||
|
|
@ -17,9 +17,7 @@ class GenerateInvitationCodeRequest(
|
|||
val token: String? = null,
|
||||
@Transient
|
||||
val amount: Int? = 1,
|
||||
@Transient
|
||||
override val createTime: Long = System.currentTimeMillis()
|
||||
) : BlessingSkinRequest<InvitationCodeGenerationResponse, FailedBlessingSkinResponse.Default>() {
|
||||
) : BlessingSkinRequest() {
|
||||
|
||||
override fun toJSON(): String {
|
||||
// 对于GET请求,参数在URL中,body可以为空
|
||||
|
|
@ -66,7 +64,7 @@ class GenerateInvitationCodeRequest(
|
|||
} catch (e: Exception) {
|
||||
ResponseResult.Failure(
|
||||
FailedBlessingSkinResponse.Default(
|
||||
failedResult = "解析响应失败: ${e.message}"
|
||||
failedMessage = "解析响应失败: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,15 @@ import kotlinx.serialization.json.Json
|
|||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.polymorphic
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.invitecode.InvitationCodeGenerationResponse
|
||||
import top.r3944realms.ltdmanager.core.client.response.IResponse
|
||||
|
||||
@Serializable
|
||||
abstract class BlessingSkinResponse (
|
||||
@Transient
|
||||
open val httpStatusCode: HttpStatusCode = HttpStatusCode.OK,
|
||||
override val httpStatusCode: HttpStatusCode = HttpStatusCode.OK,
|
||||
@Transient
|
||||
open val createTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
override val createTime: Long = System.currentTimeMillis()
|
||||
) : IResponse {
|
||||
companion object {
|
||||
// 通用的反序列化方法
|
||||
inline fun <reified T : BlessingSkinResponse> decode(jsonString: String): T {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,12 @@ package top.r3944realms.ltdmanager.blessingskin.response
|
|||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import top.r3944realms.ltdmanager.core.client.response.IFailedResponse
|
||||
|
||||
@Serializable
|
||||
abstract class FailedBlessingSkinResponse: BlessingSkinResponse() {
|
||||
abstract fun failedMessage(): String
|
||||
abstract class FailedBlessingSkinResponse: BlessingSkinResponse(), IFailedResponse {
|
||||
@Serializable
|
||||
class Default(@Transient val failedResult: String? = "未知错误") : FailedBlessingSkinResponse() {
|
||||
override fun failedMessage(): String = failedResult!!
|
||||
class Default(@Transient override val failedMessage: String = "未知错误") : FailedBlessingSkinResponse() {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
package top.r3944realms.ltdmanager.blessingskin.response.invitecode
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import top.r3944realms.ltdmanager.blessingskin.data.InvitationCode
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
|
||||
@Serializable
|
||||
data class InvitationCodeGenerationResponse(
|
||||
|
|
@ -10,12 +10,4 @@ data class InvitationCodeGenerationResponse(
|
|||
val data: List<InvitationCode>? = null
|
||||
) : BlessingSkinResponse() {
|
||||
|
||||
@Serializable
|
||||
data class InvitationCode(
|
||||
val code: String,
|
||||
@SerialName("generated_at")
|
||||
val generatedAt: String,
|
||||
@SerialName("expires_at")
|
||||
val expiresAt: String
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package top.r3944realms.ltdmanager.core.client.response
|
||||
|
||||
interface IFailedResponse : IResponse {
|
||||
val failedMessage: String
|
||||
val thrownException: Exception
|
||||
get() = Exception(failedMessage)
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package top.r3944realms.ltdmanager.core.client.response
|
||||
|
||||
import io.ktor.http.*
|
||||
|
||||
interface IResponse {
|
||||
val httpStatusCode: HttpStatusCode
|
||||
val createTime: Long
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ data class BlessingSkinServerConfig(
|
|||
try {
|
||||
encryptedToken = "ENC(${CryptoUtil.encrypt(encryptedToken!!)})"
|
||||
YamlUpdater.updateYaml(
|
||||
YamlConfigLoader.configFilePath.toString(),
|
||||
YamlConfigLoader.appConfigFilePath.toString(),
|
||||
"blessing-skin-server.invitation-api.encrypted-token",
|
||||
encryptedToken!!
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ data class DatabaseConfig(
|
|||
try {
|
||||
encryptedPassword = "ENC(${CryptoUtil.encrypt(encryptedPassword!!)})"
|
||||
YamlUpdater.updateYaml(
|
||||
YamlConfigLoader.configFilePath.toString(),
|
||||
YamlConfigLoader.appConfigFilePath.toString(),
|
||||
"database.encrypted-password",
|
||||
this.encryptedPassword!!
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ data class HttpConfig(
|
|||
try {
|
||||
encryptedToken = "ENC(${CryptoUtil.encrypt(encryptedToken!!)})"
|
||||
YamlUpdater.updateYaml(
|
||||
YamlConfigLoader.configFilePath.toString(),
|
||||
YamlConfigLoader.appConfigFilePath.toString(),
|
||||
"http.encrypted-token",
|
||||
this.encryptedToken!!
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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=***)"
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ data class MailConfig(
|
|||
try {
|
||||
encryptedPassword = "ENC(${CryptoUtil.encrypt(encryptedPassword!!)})"
|
||||
YamlUpdater.updateYaml(
|
||||
YamlConfigLoader.configFilePath.toString(),
|
||||
YamlConfigLoader.appConfigFilePath.toString(),
|
||||
"mail.encrypted-password",
|
||||
this.encryptedPassword!!
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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=***)"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ data class ToolConfig(
|
|||
try {
|
||||
rconPassword = "ENC(${CryptoUtil.encrypt(rconPassword!!)})"
|
||||
YamlUpdater.updateYaml(
|
||||
YamlConfigLoader.configFilePath.toString(),
|
||||
YamlConfigLoader.appConfigFilePath.toString(),
|
||||
"tools.rcon.rcon-password",
|
||||
rconPassword!!
|
||||
)
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ data class WebsocketConfig(
|
|||
try {
|
||||
encryptedToken = "ENC(${CryptoUtil.encrypt(encryptedToken!!)})"
|
||||
YamlUpdater.updateYaml(
|
||||
YamlConfigLoader.configFilePath.toString(),
|
||||
YamlConfigLoader.appConfigFilePath.toString(),
|
||||
"websocket.encrypted-token",
|
||||
this.encryptedToken!!
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,42 +8,49 @@ import org.yaml.snakeyaml.introspector.PropertyUtils
|
|||
import top.r3944realms.ltdmanager.utils.ConfigInitializer
|
||||
import top.r3944realms.ltdmanager.utils.NamingConventionUtil
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
|
||||
object YamlConfigLoader {
|
||||
val configFilePath = Paths.get("config/application.yaml") // 配置文件路径
|
||||
private val _config by lazy { loadConfig() } // 延迟初始化
|
||||
val config: ConfigWrapper get() = _config
|
||||
val appConfigFilePath: Path = Paths.get("config/application.yaml") // 配置文件路径
|
||||
val moduleConfigFilePath: Path = Paths.get("config/module.yaml") // 配置文件路径
|
||||
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 {
|
||||
// 第一次启动确保配置文件存在
|
||||
ConfigInitializer.initConfig("module.yaml", "config", false)
|
||||
ConfigInitializer.initConfig("application.yaml", "config")
|
||||
|
||||
// 初始化后加密(确保只执行一次)
|
||||
runCatching {
|
||||
ensureConfigEncrypted(_config)
|
||||
ensureConfigEncrypted(_app_config)
|
||||
}.onFailure { e ->
|
||||
println("初始化加密失败: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
private fun ensureConfigEncrypted(config: ConfigWrapper?) {
|
||||
private fun ensureConfigEncrypted(config: AppConfigWrapper?) {
|
||||
config?.database?.encryptPassword()
|
||||
config?.websocket?.encryptToken()
|
||||
config?.http?.encryptToken()
|
||||
config?.mcsm?.encryptApi()
|
||||
config?.mail?.encryptPassword()
|
||||
config?.tools?.rcon?.encryptPassword()
|
||||
config?.blessingSkinServer?.invitationApi?.encryptToken()
|
||||
config?.dgLab?.wsServer?.encryptPassword()
|
||||
config?.imgTu?.encryptPassword()
|
||||
}
|
||||
private fun loadConfig(): ConfigWrapper {
|
||||
if (!Files.exists(configFilePath)) {
|
||||
throw RuntimeException("配置文件未找到: $configFilePath")
|
||||
private fun loadAppConfigWrapper(): AppConfigWrapper {
|
||||
if (!Files.exists(appConfigFilePath)) {
|
||||
throw RuntimeException("应用配置文件未找到: $appConfigFilePath")
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
} 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() {
|
||||
override fun getProperty(type: Class<*>, name: String): Property {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadDatabaseConfig(): DatabaseConfig = config.database
|
||||
fun loadCryptoConfig(): CryptoConfig = config.crypto
|
||||
fun loadWebsocketConfig(): WebsocketConfig = config.websocket
|
||||
fun loadHttpConfig(): HttpConfig = config.http
|
||||
fun loadModeConfig(): ModeConfig = config.mode
|
||||
fun loadToolConfig(): ToolConfig = config.tools
|
||||
fun loadMailConfig(): MailConfig = config.mail
|
||||
fun loadBlessingSkinServerConfig(): BlessingSkinServerConfig = config.blessingSkinServer
|
||||
data class ConfigWrapper(
|
||||
fun loadDatabaseConfig(): DatabaseConfig = appConfig.database
|
||||
fun loadCryptoConfig(): CryptoConfig = appConfig.crypto
|
||||
fun loadMcsmConfig(): McsmConfig = appConfig.mcsm
|
||||
fun loadWebsocketConfig(): WebsocketConfig = appConfig.websocket
|
||||
fun loadHttpConfig(): HttpConfig = appConfig.http
|
||||
fun loadModeConfig(): ModeConfig = appConfig.mode
|
||||
fun loadToolConfig(): ToolConfig = appConfig.tools
|
||||
fun loadMailConfig(): MailConfig = appConfig.mail
|
||||
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 crypto: CryptoConfig = CryptoConfig(),
|
||||
var mode: ModeConfig = ModeConfig(),
|
||||
|
|
@ -84,7 +111,13 @@ object YamlConfigLoader {
|
|||
var http: HttpConfig = HttpConfig(),
|
||||
var tools: ToolConfig = ToolConfig(),
|
||||
var mail: MailConfig = MailConfig(),
|
||||
var mcsm: McsmConfig = McsmConfig(),
|
||||
var blessingSkinServer: BlessingSkinServerConfig = BlessingSkinServerConfig(),
|
||||
var dgLab: DgLabConfig = DgLabConfig(),
|
||||
var imgTu: ImgTuConfig = ImgTuConfig(),
|
||||
)
|
||||
|
||||
data class ModuleConfigWrapper(
|
||||
var module: ModuleConfig = ModuleConfig(),
|
||||
)
|
||||
}
|
||||
151
src/main/kotlin/top/r3944realms/ltdmanager/dglab/DgLab.kt
Normal file
151
src/main/kotlin/top/r3944realms/ltdmanager/dglab/DgLab.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package top.r3944realms.ltdmanager.dglab.manager
|
||||
|
||||
interface IManager<T> {
|
||||
fun startAll()
|
||||
fun stopAll()
|
||||
fun getInstance(): T?
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package top.r3944realms.ltdmanager.dglab.model.pulseware
|
||||
|
||||
class PulseWaveClassTransform {
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -1,86 +1,165 @@
|
|||
package top.r3944realms.ltdmanager
|
||||
|
||||
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
|
||||
import top.r3944realms.ltdmanager.module.McServerStatusModule
|
||||
import top.r3944realms.ltdmanager.module.*
|
||||
|
||||
|
||||
// DSL
|
||||
fun main() = GlobalManager.runBlockingMain {
|
||||
val groupId:Long = 538751386
|
||||
val selfQQId = 3327379836
|
||||
val selfNickName = "闲趣老土豆"
|
||||
// 创建模块实例
|
||||
val groupModule = GroupRequestHandlerModule(
|
||||
client = GlobalManager.napCatClient,
|
||||
targetGroupId = groupId
|
||||
)
|
||||
val groupMsgPollingModule = GroupMessagePollingModule(
|
||||
targetGroupId = groupId,
|
||||
pollIntervalMillis = 5_000L,
|
||||
msgHistoryCheck = 15
|
||||
)
|
||||
val toolConfig = YamlConfigLoader.loadToolConfig()
|
||||
val rconModule = RconPlayerListModule(
|
||||
groupMessagePollingModule = groupMsgPollingModule,
|
||||
rconTimeOut = 2_000L,
|
||||
cooldownMillis = 10_000L,
|
||||
selfId = selfQQId,
|
||||
selfNickName = selfNickName,
|
||||
rconPath = toolConfig.rcon.mcRconToolPath.toString(),
|
||||
rconConfigPath = toolConfig.rcon.mcRconToolConfigPath.toString(),
|
||||
keywords = setOf(
|
||||
//形容
|
||||
"土豆", "马铃薯", "Potato", "potato", "POTATO",
|
||||
"Potatoes", "potatoes", "POTATOES", "🥔",
|
||||
//正经
|
||||
"列表","服务器状态", "TPS", "tps", "list", "List"
|
||||
)
|
||||
)
|
||||
val mailConfig = YamlConfigLoader.loadMailConfig()
|
||||
val mailModule = MailModule(
|
||||
host = mailConfig.host.toString(),
|
||||
authToken = mailConfig.decryptedPassword.toString(),
|
||||
port = mailConfig.port!!,
|
||||
senderEmailAddress = mailConfig.mailAddress!!,
|
||||
)
|
||||
val blessingSkinConfig = YamlConfigLoader.loadBlessingSkinServerConfig()
|
||||
val invitationCodesModule = InvitationCodesModule(
|
||||
groupMessagePollingModule = groupMsgPollingModule,
|
||||
mailModule = mailModule,
|
||||
apiToken = blessingSkinConfig.invitationApi?.decryptedToken!!,
|
||||
selfId = selfQQId,
|
||||
keywords = setOf(
|
||||
"申请皮肤站注册邀请码",
|
||||
"申请土豆服务器注册邀请码",
|
||||
"申请LTD邀请码",
|
||||
"Apply for an invitation code"
|
||||
)
|
||||
)
|
||||
val mcServerStatusModule = McServerStatusModule(
|
||||
groupMessagePollingModule = groupMsgPollingModule,
|
||||
selfId = selfQQId,
|
||||
cooldownSeconds = 20,
|
||||
selfNickName = selfNickName,
|
||||
commands = listOf("/m", "/mcs", "seek", "s"),
|
||||
presetServer = mapOf(
|
||||
setOf("先行土豆", "先行", "pre", "Pre", "BF", "bf", "p", "P") to "n2.akiracloud.net:10599",
|
||||
setOf("土豆", "老土豆", "七周目", "7" ,"ZZ", "zz", "Zz", "seven") to "main.mmccdd.top:11106",
|
||||
)
|
||||
)
|
||||
|
||||
// 注册模块到全局模块管理器
|
||||
GlobalManager.moduleManager.registerModule(groupModule)
|
||||
GlobalManager.moduleManager.registerModule(groupMsgPollingModule)
|
||||
GlobalManager.moduleManager.registerModule(mcServerStatusModule)
|
||||
GlobalManager.moduleManager.registerModule(rconModule)
|
||||
GlobalManager.moduleManager.registerModule(mailModule)
|
||||
GlobalManager.moduleManager.registerModule(invitationCodesModule)
|
||||
|
||||
// 加载模块
|
||||
GlobalManager.moduleManager.loadModule(groupModule.name)
|
||||
GlobalManager.moduleManager.loadModule(groupMsgPollingModule.name)
|
||||
GlobalManager.moduleManager.loadModule(mcServerStatusModule.name)
|
||||
GlobalManager.moduleManager.loadModule(rconModule.name)
|
||||
GlobalManager.moduleManager.loadModule(mailModule.name)
|
||||
GlobalManager.moduleManager.loadModule(invitationCodesModule.name)
|
||||
GlobalManager.initApplication()
|
||||
// val commonGroupId:Long = 538751386
|
||||
// val whitelistGroupId:Long = 920719236
|
||||
// val selfQQId = 3327379836
|
||||
// val selfNickName = "闲趣老土豆"
|
||||
// // 创建模块实例
|
||||
// val groupModule = GroupRequestHandlerModule(
|
||||
// moduleName = "WhiteListGroup",
|
||||
// client = GlobalManager.napCatClient,
|
||||
// targetGroupId = whitelistGroupId
|
||||
// )
|
||||
// val commonGroupMsgPollingModule = GroupMessagePollingModule(
|
||||
// moduleName = "CommonGroupMsgPolling",
|
||||
// targetGroupId = commonGroupId,
|
||||
// pollIntervalMillis = 5_000L,
|
||||
// msgHistoryCheck = 15
|
||||
// )
|
||||
// val whiteListGroupMsgPollingModule = GroupMessagePollingModule(
|
||||
// moduleName = "WhiteListGroup",
|
||||
// targetGroupId = whitelistGroupId,
|
||||
// pollIntervalMillis = 5_000L,
|
||||
// msgHistoryCheck = 15
|
||||
// )
|
||||
// val commonHelpModule = HelpModule(
|
||||
// moduleName = "CommonGroup",
|
||||
// keywords = listOf("help", "帮助"),
|
||||
// groupMessagePollingModule = commonGroupMsgPollingModule,
|
||||
// selfId = selfQQId,
|
||||
// selfNickName = selfNickName,
|
||||
// )
|
||||
// val whitelistHelpModule = HelpModule(
|
||||
// moduleName = "WhiteListGroup",
|
||||
// keywords = listOf("help", "帮助"),
|
||||
// groupMessagePollingModule = whiteListGroupMsgPollingModule,
|
||||
// selfId = selfQQId,
|
||||
// selfNickName = selfNickName,
|
||||
// )
|
||||
// val toolConfig = YamlConfigLoader.loadToolConfig()
|
||||
// val corconModule = RconPlayerListModule(
|
||||
// moduleName = "CommonGroup",
|
||||
// groupMessagePollingModule = commonGroupMsgPollingModule,
|
||||
// rconTimeOut = 2_000L,
|
||||
// cooldownMillis = 10_000L,
|
||||
// selfId = selfQQId,
|
||||
// selfNickName = selfNickName,
|
||||
// rconPath = toolConfig.rcon.mcRconToolPath.toString(),
|
||||
// rconConfigPath = toolConfig.rcon.mcRconToolConfigPath.toString(),
|
||||
// keywords = setOf(
|
||||
// //形容
|
||||
// "土豆", "马铃薯", "Potato", "potato", "POTATO",
|
||||
// "Potatoes", "potatoes", "POTATOES", "🥔",
|
||||
// //正经
|
||||
// "列表","服务器状态", "TPS", "tps", "list", "List"
|
||||
// )
|
||||
// )
|
||||
// val rconModule = RconPlayerListModule(
|
||||
// moduleName = "WhiteListGroup",
|
||||
// groupMessagePollingModule = whiteListGroupMsgPollingModule,
|
||||
// rconTimeOut = 2_000L,
|
||||
// cooldownMillis = 10_000L,
|
||||
// selfId = selfQQId,
|
||||
// selfNickName = selfNickName,
|
||||
// rconPath = toolConfig.rcon.mcRconToolPath.toString(),
|
||||
// rconConfigPath = toolConfig.rcon.mcRconToolConfigPath.toString(),
|
||||
// keywords = setOf(
|
||||
// //形容
|
||||
// "土豆", "马铃薯", "Potato", "potato", "POTATO",
|
||||
// "Potatoes", "potatoes", "POTATOES", "🥔",
|
||||
// //正经
|
||||
// "列表","服务器状态", "TPS", "tps", "list", "List"
|
||||
// )
|
||||
// )
|
||||
//// val mailConfig = YamlConfigLoader.loadMailConfig()
|
||||
//// val mailModule = MailModule(
|
||||
//// moduleName = "WhiteListGroup",
|
||||
//// host = mailConfig.host.toString(),
|
||||
//// authToken = mailConfig.decryptedPassword.toString(),
|
||||
//// port = mailConfig.port!!,
|
||||
//// senderEmailAddress = mailConfig.mailAddress!!,
|
||||
//// )
|
||||
//// val blessingSkinConfig = YamlConfigLoader.loadBlessingSkinServerConfig()
|
||||
//// val invitationCodesModule = InvitationCodesModule(
|
||||
//// moduleName = "WhiteListGroup",
|
||||
//// groupMessagePollingModule = 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)
|
||||
}
|
||||
211
src/main/kotlin/top/r3944realms/ltdmanager/mcms/MCSMClient.kt
Normal file
211
src/main/kotlin/top/r3944realms/ltdmanager/mcms/MCSMClient.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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!!
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
344
src/main/kotlin/top/r3944realms/ltdmanager/module/BanModule.kt
Normal file
344
src/main/kotlin/top/r3944realms/ltdmanager/module/BanModule.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package top.r3944realms.ltdmanager.module
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import org.intellij.lang.annotations.MagicConstant
|
||||
import top.r3944realms.ltdmanager.GlobalManager
|
||||
import top.r3944realms.ltdmanager.utils.LoggerUtil
|
||||
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) {}
|
||||
LoggerUtil.syncInfo("[$name] 模块已安全停止")
|
||||
}
|
||||
/**
|
||||
* 模块说明 / 帮助信息
|
||||
* 默认返回空字符串,子类可重写提供具体帮助文本
|
||||
*/
|
||||
open fun help(): String = ""
|
||||
/** 模块基础信息,用于 HelpModule 显示 */
|
||||
open fun info(): String = "模块 $name 未提供详细信息"
|
||||
/**
|
||||
* 提供访问全局 NapCatClient 的快捷方式
|
||||
*/
|
||||
protected val napCatClient get() = GlobalManager.napCatClient
|
||||
|
||||
/**
|
||||
* 提供访问全局 blessingSkinClient 的快捷方式
|
||||
*/
|
||||
protected val blessingSkinClient get() = GlobalManager.blessingSkinClient
|
||||
|
||||
/**
|
||||
* 提供访问全局 mcSrvStatusClient 的快捷方式
|
||||
*/
|
||||
protected val mcSrvStatusClient get() = GlobalManager.mcSrvStatusClient
|
||||
|
||||
/**
|
||||
* 提供访问全局 加载模块 的快捷方式
|
||||
*/
|
||||
protected val moduleMap get() = GlobalManager.moduleManager.getModules()
|
||||
|
||||
/**
|
||||
* 获取数据库连接
|
||||
* 使用 try-with-resources 时会自动关闭
|
||||
*/
|
||||
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初始化")
|
||||
}
|
||||
}
|
||||
489
src/main/kotlin/top/r3944realms/ltdmanager/module/DGLabModule.kt
Normal file
489
src/main/kotlin/top/r3944realms/ltdmanager/module/DGLabModule.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,26 +5,25 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
|||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import top.r3944realms.ltdmanager.napcat.data.ID
|
||||
import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent
|
||||
import top.r3944realms.ltdmanager.napcat.data.msghistory.MsgHistorySpecificMsg
|
||||
import top.r3944realms.ltdmanager.napcat.event.message.GetGroupMsgHistoryEvent
|
||||
import top.r3944realms.ltdmanager.napcat.request.message.GetGroupMsgHistoryRequest
|
||||
import top.r3944realms.ltdmanager.utils.LoggerUtil
|
||||
|
||||
class GroupMessagePollingModule(
|
||||
moduleName: String,
|
||||
val targetGroupId: Long,
|
||||
private val pollIntervalMillis: Long = 5_000L,
|
||||
private val msgHistoryCheck: Int = 15
|
||||
) : BaseModule() {
|
||||
|
||||
override val name: String = "MessagePollingModule"
|
||||
private val msgHistoryCheck: Int = 15,
|
||||
) : BaseModule(Modules.GROUP_MESSAGE_POLLING, moduleName) {
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
// 用 Flow 存消息,其他模块可以订阅
|
||||
private val _messagesFlow = MutableSharedFlow<List<GetFriendMsgHistoryEvent.SpecificMsg>>(
|
||||
private val _messagesFlow = MutableSharedFlow<List<MsgHistorySpecificMsg>>(
|
||||
replay = 1, // 保留最近一份消息
|
||||
extraBufferCapacity = 1
|
||||
)
|
||||
val messagesFlow: SharedFlow<List<GetFriendMsgHistoryEvent.SpecificMsg>> = _messagesFlow.asSharedFlow()
|
||||
val messagesFlow: SharedFlow<List<MsgHistorySpecificMsg>> = _messagesFlow.asSharedFlow()
|
||||
|
||||
override fun onLoad() {
|
||||
LoggerUtil.logger.info("[$name] 启动消息轮询 (群: $targetGroupId)")
|
||||
|
|
@ -32,12 +31,12 @@ class GroupMessagePollingModule(
|
|||
scope!!.launch {
|
||||
while (isActive && loaded) {
|
||||
try {
|
||||
val event = napCatClient.send(
|
||||
val event = getNapCatClientOrNull()?.send<GetGroupMsgHistoryEvent>(
|
||||
GetGroupMsgHistoryRequest(
|
||||
count = msgHistoryCheck,
|
||||
groupId = ID.long(targetGroupId)
|
||||
)
|
||||
) as? GetGroupMsgHistoryEvent
|
||||
)
|
||||
|
||||
val messages = event?.data?.messages ?: emptyList()
|
||||
LoggerUtil.logger.debug("[$name] 拉取到 ${messages.size} 条消息")
|
||||
|
|
|
|||
|
|
@ -11,12 +11,11 @@ import top.r3944realms.ltdmanager.napcat.request.group.SetGroupAddRequestRequest
|
|||
import top.r3944realms.ltdmanager.utils.LoggerUtil
|
||||
|
||||
class GroupRequestHandlerModule(
|
||||
moduleName: String,
|
||||
private val client: NapCatClient,
|
||||
private val targetGroupId: Long,
|
||||
private val pollIntervalMillis: Long = 30_000L,
|
||||
) : BaseModule() {
|
||||
|
||||
override val name: String = "GroupRequestHandlerModule"
|
||||
) : BaseModule(Modules.GROUP_REQUEST_HANDLER, moduleName) {
|
||||
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
|
|
@ -138,7 +137,7 @@ class GroupRequestHandlerModule(
|
|||
try {
|
||||
getConnection().use { conn ->
|
||||
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)
|
||||
val rs = stmt.executeQuery()
|
||||
|
|
@ -176,4 +175,7 @@ class GroupRequestHandlerModule(
|
|||
return data.invitedRequest + data.joinRequests
|
||||
}
|
||||
}
|
||||
override fun info(): String = "模块: $name\n功能: 自动处理群组加群请求\n版本: 1.0"
|
||||
|
||||
override fun help(): String = "本模块会轮询群组加群请求并根据数据库白名单自动同意或拒绝"
|
||||
}
|
||||
245
src/main/kotlin/top/r3944realms/ltdmanager/module/HelpModule.kt
Normal file
245
src/main/kotlin/top/r3944realms/ltdmanager/module/HelpModule.kt
Normal 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"
|
||||
}
|
||||
|
|
@ -4,16 +4,24 @@ import kotlinx.coroutines.*
|
|||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
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.response.ResponseResult
|
||||
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.module.common.cooldown.CooldownManager
|
||||
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownScope
|
||||
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownStateProvider
|
||||
import top.r3944realms.ltdmanager.module.common.filter.TriggerMessageFilter
|
||||
import top.r3944realms.ltdmanager.module.common.filter.type.CooldownFilter
|
||||
import top.r3944realms.ltdmanager.module.common.filter.type.IgnoreSelfFilter
|
||||
import top.r3944realms.ltdmanager.module.common.filter.type.KeywordFilter
|
||||
import top.r3944realms.ltdmanager.module.common.filter.type.NewMessageFilter
|
||||
import top.r3944realms.ltdmanager.module.exception.InvitationCodeException
|
||||
import top.r3944realms.ltdmanager.napcat.NapCatClient
|
||||
import top.r3944realms.ltdmanager.napcat.data.ID
|
||||
import top.r3944realms.ltdmanager.napcat.data.MessageElement
|
||||
import top.r3944realms.ltdmanager.napcat.data.MessageType
|
||||
import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent
|
||||
import top.r3944realms.ltdmanager.napcat.data.msghistory.MsgHistorySpecificMsg
|
||||
import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest
|
||||
import top.r3944realms.ltdmanager.utils.HtmlTemplateUtil
|
||||
import top.r3944realms.ltdmanager.utils.LoggerUtil
|
||||
|
|
@ -63,24 +71,67 @@ api格式 https://skins.r3944realms.top/api/invitation-codes/generate?token=XXXX
|
|||
*/
|
||||
|
||||
class InvitationCodesModule(
|
||||
moduleName: String,
|
||||
private val groupMessagePollingModule: GroupMessagePollingModule,
|
||||
private val mailModule: MailModule,
|
||||
private val apiToken: String,
|
||||
private val selfId: Long,
|
||||
selfId: Long,
|
||||
private val cooldownMillis: Long = 120_000,
|
||||
private val keywords: Set<String> = setOf("申请邀请码")
|
||||
) : BaseModule(), PersistentState<InvitationCodesModule.LastTriggerMapState> {
|
||||
) : BaseModule(Modules.INVITATION_CODE, moduleName), PersistentState<InvitationCodesModule.LastTriggerMapState> {
|
||||
|
||||
override val name: String = "InvitationCodesModule"
|
||||
private var scope: CoroutineScope? = null
|
||||
private val stateFile: File = getStateFileInternal("invitation_codes_quarry_state.json", name)
|
||||
private val stateBackupFile: File = getStateFileInternal("invitation_codes_quarry_state.json.bak", name)
|
||||
private val cooldownManager by lazy{ CooldownManager(
|
||||
cooldownMillis = cooldownMillis,
|
||||
scope = CooldownScope.PerUser,
|
||||
stateProvider = object : CooldownStateProvider<LastTriggerMapState> {
|
||||
override fun load() = loadState()
|
||||
override fun save(state: LastTriggerMapState) = saveState(state)
|
||||
},
|
||||
getLastTrigger = { state, qq ->
|
||||
val detail = state.map[qq]
|
||||
(detail?.time ?: -1L) to (detail?.lastCooldownRealId ?: -1L)
|
||||
},
|
||||
updateTrigger = { state, qq, realId, time ->
|
||||
val id = requireNotNull(qq)
|
||||
state.updateLastTrigger(id, realId, time)
|
||||
},
|
||||
updateCooldownRealId = { state, qq, realId ->
|
||||
val id = requireNotNull(qq)
|
||||
state.updateLastCooldownRealId(id, realId)
|
||||
},
|
||||
groupId = groupMessagePollingModule.targetGroupId
|
||||
)
|
||||
}
|
||||
// 在 InvitationCodesModule 类里添加:
|
||||
private val triggerFilter = TriggerMessageFilter(
|
||||
listOf(
|
||||
IgnoreSelfFilter(selfId),
|
||||
NewMessageFilter { qq ->
|
||||
lastTriggerMapState.getLastTriggerTime(qq) to lastTriggerMapState.getLastTriggerRealId(qq)
|
||||
},
|
||||
KeywordFilter(keywords),
|
||||
CooldownFilter(
|
||||
cooldownManager = cooldownManager,
|
||||
sendCooldown = { msg, remain ->
|
||||
sendCooldownMessage(
|
||||
napCatClient,
|
||||
msg.userId,
|
||||
msg.realId,
|
||||
"⏳ 申请邀请码过于频繁(剩余 $remain 秒后自动申请)"
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
// 持久化文件(带锁 + 备份)
|
||||
private val stateFile = getStateFile("mc_server_status_state.json")
|
||||
private val stateBackupFile = getStateFile("invitation_codes_quarry_state.json.bak")
|
||||
private val fileLock = ReentrantLock()
|
||||
|
||||
private var lastTriggerMapState = loadState()
|
||||
override fun getStateFile(): File = stateFile
|
||||
override fun getStateFileInternal(): File = stateFile
|
||||
override fun getState(): LastTriggerMapState = lastTriggerMapState
|
||||
override fun onLoad() {
|
||||
LoggerUtil.logger.info("[$name] 模块已装载,目标群组: ${groupMessagePollingModule.targetGroupId}")
|
||||
|
|
@ -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
|
||||
val triggerMsgs = filterTriggerMessages(messages)
|
||||
if (triggerMsgs.isEmpty()) return
|
||||
|
||||
try {
|
||||
val hadValidCodeButNotUsed = mutableListOf<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>()
|
||||
val needNewCode = mutableListOf<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>()
|
||||
val hadValidCodeButNotUsed = mutableListOf<Pair<Long, MsgHistorySpecificMsg>>()
|
||||
val needNewCode = mutableListOf<Pair<Long, MsgHistorySpecificMsg>>()
|
||||
|
||||
getIdAndSelectSituation(triggerMsgs, hadValidCodeButNotUsed, needNewCode)
|
||||
createAndSearchInvitationCodeIdsThenUpdateDate(needNewCode)
|
||||
|
|
@ -135,34 +186,22 @@ class InvitationCodesModule(
|
|||
}
|
||||
|
||||
/** 过滤出符合条件的触发消息 */
|
||||
private fun filterTriggerMessages(messages: List<GetFriendMsgHistoryEvent.SpecificMsg>)
|
||||
: List<GetFriendMsgHistoryEvent.SpecificMsg> {
|
||||
private suspend fun filterTriggerMessages(
|
||||
messages: List<MsgHistorySpecificMsg>
|
||||
): List<MsgHistorySpecificMsg> {
|
||||
|
||||
val filtered = messages.asSequence()
|
||||
.filter { msg ->
|
||||
msg.userId != selfId &&
|
||||
(msg.time > lastTriggerMapState.getLastTriggerTime(msg.userId) ||
|
||||
(msg.time == lastTriggerMapState.getLastTriggerTime(msg.userId)
|
||||
&& msg.realId > lastTriggerMapState.getLastTriggerRealId(msg.userId))) &&
|
||||
msg.message.any { seg ->
|
||||
seg.type == MessageType.Text &&
|
||||
seg.data.text?.let { text -> keywords.any { keyword -> text == keyword } } == true
|
||||
}
|
||||
}
|
||||
// 先应用通用过滤器
|
||||
val filtered = triggerFilter.filter(messages)
|
||||
|
||||
// 再做 groupBy -> 只保留每个用户最新一条
|
||||
return filtered
|
||||
.groupBy { it.userId }
|
||||
.mapNotNull { (_, msgs) -> msgs.maxByOrNull { it.time } }
|
||||
.filter { runBlocking { filterCoolDownMessage(it) } }
|
||||
.toList()
|
||||
|
||||
if (filtered.isNotEmpty()) {
|
||||
LoggerUtil.logger.info("[$name] 待处理消息队列: $filtered")
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
private suspend fun getIdAndSelectSituation(msgs: List<GetFriendMsgHistoryEvent.SpecificMsg>,
|
||||
hadVaildCodeButNotUseList : MutableList<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>,
|
||||
needNewCodeList: MutableList<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>) {
|
||||
private suspend fun getIdAndSelectSituation(msgs: List<MsgHistorySpecificMsg>,
|
||||
hadVaildCodeButNotUseList : MutableList<Pair<Long, MsgHistorySpecificMsg>>,
|
||||
needNewCodeList: MutableList<Pair<Long, MsgHistorySpecificMsg>>) {
|
||||
if (msgs.isEmpty()) return
|
||||
|
||||
val qqIds = msgs.map { it.userId }
|
||||
|
|
@ -235,7 +274,7 @@ class InvitationCodesModule(
|
|||
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
|
||||
|
||||
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) {
|
||||
val request = SendGroupMsgRequest(
|
||||
MessageElement.reply(ID.long(realId), msg),
|
||||
|
|
@ -394,7 +406,7 @@ class InvitationCodesModule(
|
|||
lastTriggerMapState = lastTriggerMapState.updateLastTrigger(qq, realId, -1)
|
||||
}
|
||||
private suspend fun createAndSearchInvitationCodeIdsThenUpdateDate(
|
||||
needNewTokenIdAndMsgPairs: List<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>,
|
||||
needNewTokenIdAndMsgPairs: List<Pair<Long, MsgHistorySpecificMsg>>,
|
||||
) {
|
||||
if (needNewTokenIdAndMsgPairs.isEmpty()) return
|
||||
|
||||
|
|
@ -419,23 +431,33 @@ class InvitationCodesModule(
|
|||
/**
|
||||
* 1. 创建邀请码
|
||||
*/
|
||||
private suspend fun createInvitationCodes(amount: Int): List<InvitationCodeGenerationResponse.InvitationCode>? {
|
||||
private suspend fun createInvitationCodes(amount: Int): List<InvitationCode>? {
|
||||
return try {
|
||||
val response = blessingSkinClient.submitRequest(
|
||||
GenerateInvitationCodeRequest(amount = amount, token = apiToken)
|
||||
)
|
||||
response
|
||||
.onFailure {
|
||||
|
||||
}
|
||||
.onSuccess {
|
||||
|
||||
}
|
||||
when (response) {
|
||||
is ResponseResult.Success -> {
|
||||
if (response.response is InvitationCodeGenerationResponse) {
|
||||
if (response.response.success) {
|
||||
response.response.data
|
||||
} else {
|
||||
} else
|
||||
LoggerUtil.logger.warn("[$name] API返回失败: ${response.response.message}")
|
||||
null
|
||||
} else {
|
||||
LoggerUtil.logger.warn("[$name] 返回非预期对象类型: ${response.response.javaClass}")
|
||||
null
|
||||
}
|
||||
}
|
||||
is ResponseResult.Failure -> {
|
||||
LoggerUtil.logger.warn("[$name] 创建邀请码失败: ${response.failure.failedResult}")
|
||||
LoggerUtil.logger.warn("[$name] 创建邀请码失败: ${response.failure.failedMessage}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -449,8 +471,8 @@ class InvitationCodesModule(
|
|||
* 2. 验证数量匹配
|
||||
*/
|
||||
private fun validateCodeCountMatch(
|
||||
invitationCodes: List<InvitationCodeGenerationResponse.InvitationCode>?,
|
||||
needNewTokenIdAndMsgPairs: List<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>
|
||||
invitationCodes: List<InvitationCode>?,
|
||||
needNewTokenIdAndMsgPairs: List<Pair<Long, MsgHistorySpecificMsg>>
|
||||
) {
|
||||
if (invitationCodes == null) {
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import java.util.concurrent.LinkedBlockingQueue
|
|||
import kotlin.concurrent.thread
|
||||
|
||||
class MailModule(
|
||||
moduleName: String,
|
||||
private val protocol: String = "SMTP",
|
||||
private val host: String,
|
||||
private val port: Int,
|
||||
|
|
@ -18,9 +19,7 @@ class MailModule(
|
|||
private val enableAuth: Boolean = true,
|
||||
private val enableTLS: Boolean = true,
|
||||
private val intervalMillis: Long = 2000L // 每封邮件之间的间隔(默认 2s)
|
||||
) : BaseModule() {
|
||||
|
||||
override val name: String = "MailModule"
|
||||
) : BaseModule(Modules.MAIL, moduleName) {
|
||||
|
||||
private lateinit var session: Session
|
||||
private val queue = LinkedBlockingQueue<Mail>() // 邮件队列
|
||||
|
|
@ -142,4 +141,36 @@ class MailModule(
|
|||
|
||||
Transport.send(message)
|
||||
}
|
||||
override fun info(): String {
|
||||
return buildString {
|
||||
appendLine("[$name] 邮件发送模块")
|
||||
appendLine("功能: 异步发送邮件,支持收件人/抄送/密送,支持 HTML 或纯文本邮件。")
|
||||
appendLine("SMTP 配置:")
|
||||
appendLine(" - 协议: $protocol")
|
||||
appendLine(" - 主机: $host")
|
||||
appendLine(" - 端口: $port")
|
||||
appendLine(" - 发件人邮箱: $senderEmailAddress")
|
||||
appendLine(" - 身份认证: ${if (enableAuth) "启用" else "禁用"}")
|
||||
appendLine(" - TLS/SSL: ${if (enableTLS) "启用" else "禁用"}")
|
||||
appendLine("队列行为:")
|
||||
appendLine(" - 邮件发送间隔: ${intervalMillis}ms")
|
||||
appendLine(" - 队列长度: ${queue.size}")
|
||||
appendLine(" - 当前发送线程状态: ${if (workerThread?.isAlive == true) "运行中" else "未运行"}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun help(): String {
|
||||
return buildString {
|
||||
appendLine("📖 [$name] 使用帮助:")
|
||||
appendLine("1. 创建 Mail 对象,设置收件人、主题和正文")
|
||||
appendLine(" 例如: Mail(to = listOf(\"example@mail.com\"), subject = \"测试\", body = \"Hello\")")
|
||||
appendLine("2. 调用 enqueue(mail) 加入发送队列")
|
||||
appendLine(" 邮件将异步发送,间隔 $intervalMillis ms")
|
||||
appendLine("3. 模块卸载时会自动停止发送线程")
|
||||
appendLine()
|
||||
appendLine("注意:")
|
||||
appendLine(" - 确保 SMTP 配置正确,否则发送失败")
|
||||
appendLine(" - 发件人邮箱需要允许 SMTP/授权码登录")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,20 @@ import kotlinx.coroutines.*
|
|||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import top.r3944realms.ltdmanager.mcserver.McServerStatus
|
||||
import top.r3944realms.ltdmanager.module.common.CommandParser
|
||||
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownManager
|
||||
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownScope
|
||||
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownStateProvider
|
||||
import top.r3944realms.ltdmanager.module.common.filter.TriggerMessageFilter
|
||||
import top.r3944realms.ltdmanager.module.common.filter.type.CommandFilter
|
||||
import top.r3944realms.ltdmanager.module.common.filter.type.CooldownFilter
|
||||
import top.r3944realms.ltdmanager.module.common.filter.type.IgnoreSelfFilter
|
||||
import top.r3944realms.ltdmanager.module.common.filter.type.NewMessageFilter
|
||||
import top.r3944realms.ltdmanager.napcat.NapCatClient
|
||||
import top.r3944realms.ltdmanager.napcat.data.ID
|
||||
import top.r3944realms.ltdmanager.napcat.data.MessageElement
|
||||
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.other.SendGroupMsgRequest
|
||||
import top.r3944realms.ltdmanager.utils.LoggerUtil
|
||||
|
|
@ -17,30 +26,72 @@ import java.util.concurrent.locks.ReentrantLock
|
|||
import kotlin.concurrent.withLock
|
||||
|
||||
class McServerStatusModule(
|
||||
moduleName: String,
|
||||
private val groupMessagePollingModule: GroupMessagePollingModule,
|
||||
private val selfId: Long,
|
||||
private val selfNickName: String,
|
||||
private val cooldownSeconds: Long = 60,
|
||||
private val cooldownMillis: Long = 60_000L,
|
||||
private val commands: List<String> = listOf("/mcs", "/s"),
|
||||
private val presetServer: Map<Set<String>, String> = mapOf(
|
||||
setOf("hp", "hypixel") to "mc.hypixel.net",
|
||||
setOf("pm", "mineplex") to "play.mineplex.com"
|
||||
)
|
||||
) : BaseModule(), PersistentState<McServerStatusModule.CooldownState> {
|
||||
) : BaseModule(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 {
|
||||
presetServer.flatMap { (aliases, ip) ->
|
||||
aliases.map { it.lowercase() to ip }
|
||||
}.toMap()
|
||||
}
|
||||
fun getServerIp(alias: String): String? = presetServerByAlias[alias.lowercase()]
|
||||
override val name: String = "McServerStatusModule"
|
||||
private var scope: CoroutineScope? = null
|
||||
private val stateFile = getStateFile("mc_server_status_state.json")
|
||||
private val stateBackupFile = getStateFile("mc_server_status_state.json.bak")
|
||||
|
||||
private val fileLock = ReentrantLock()
|
||||
private var cooldownState = loadState()
|
||||
|
||||
override fun getStateFile(): File = stateFile
|
||||
override fun getStateFileInternal(): File = stateFile
|
||||
override fun getState(): CooldownState = cooldownState
|
||||
|
||||
override fun onLoad() {
|
||||
|
|
@ -61,7 +112,7 @@ class McServerStatusModule(
|
|||
LoggerUtil.logger.info("[$name] 模块已卸载完成")
|
||||
}
|
||||
|
||||
private suspend fun handleMessages(messages: List<GetFriendMsgHistoryEvent.SpecificMsg>) {
|
||||
private suspend fun handleMessages(messages: List<MsgHistorySpecificMsg>) {
|
||||
if (messages.isEmpty()) return
|
||||
val triggerMsgs = filterTriggerMessages(messages)
|
||||
if (triggerMsgs.isEmpty()) return
|
||||
|
|
@ -76,32 +127,11 @@ class McServerStatusModule(
|
|||
saveState(cooldownState)
|
||||
}
|
||||
}
|
||||
private suspend fun filterTriggerMessages(messages: List<GetFriendMsgHistoryEvent.SpecificMsg>)
|
||||
: List<GetFriendMsgHistoryEvent.SpecificMsg> {
|
||||
|
||||
val filtered = messages.asSequence()
|
||||
.filter { msg ->
|
||||
// 忽略自己消息
|
||||
msg.userId != selfId &&
|
||||
// 新消息判断
|
||||
(msg.time > cooldownState.getLastTriggerTime(msg.userId) ||
|
||||
(msg.time == cooldownState.getLastTriggerTime(msg.userId) &&
|
||||
msg.realId > cooldownState.getLastTriggerRealId(msg.userId)))
|
||||
}
|
||||
.filter { msg ->
|
||||
// 检查命令
|
||||
msg.message.any { seg ->
|
||||
seg.type == MessageType.Text &&
|
||||
(
|
||||
seg.data.text?.let { text -> commands.any { cmd -> text.startsWith(cmd) } } == true
|
||||
)
|
||||
}
|
||||
}
|
||||
.filter { runBlocking { handleCooldown(it) } } // 这里处理冷却
|
||||
.toList()
|
||||
private suspend fun filterTriggerMessages(
|
||||
messages: List<MsgHistorySpecificMsg>
|
||||
): List<MsgHistorySpecificMsg> = triggerFilter.filter(messages)
|
||||
|
||||
return filtered
|
||||
}
|
||||
private suspend fun sendFailedMessage(
|
||||
client: NapCatClient,
|
||||
qq: Long? = null,
|
||||
|
|
@ -129,31 +159,7 @@ class McServerStatusModule(
|
|||
LoggerUtil.logger.info("[$name] 已发送 失败消息[无指定对象]")
|
||||
}
|
||||
}
|
||||
/** 冷却提示消息 */
|
||||
|
||||
private suspend fun handleCooldown(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean {
|
||||
val trigger = cooldownState.map[msg.userId]
|
||||
val lastTriggerTime = trigger?.time ?: -1L
|
||||
val lastCooldownRealId = trigger?.lastCooldownRealId ?: -1L
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
|
||||
// 未触发过或者已超过冷却
|
||||
if (lastTriggerTime == -1L || nowSec - lastTriggerTime >= cooldownSeconds) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 冷却中且未发送过冷却提示
|
||||
if (msg.realId != lastCooldownRealId) {
|
||||
val remaining = ((cooldownSeconds - (nowSec - lastTriggerTime))).coerceAtLeast(1)
|
||||
val msgText = "⏳ 查询过于频繁, $remaining 秒后执行查询,切勿重复发送"
|
||||
sendCooldownMessage(napCatClient, msg.userId, msg.realId, msgText)
|
||||
cooldownState = cooldownState.updateLastCooldownRealId(msg.userId, msg.realId)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun sendCooldownMessage(client: NapCatClient, qq: Long, realId: Long, text: String) {
|
||||
private suspend fun sendCooldownMessage(client: NapCatClient, realId: Long, text: String) {
|
||||
val request = SendGroupMsgRequest(
|
||||
MessageElement.reply(ID.long(realId), text),
|
||||
ID.long(groupMessagePollingModule.targetGroupId)
|
||||
|
|
@ -163,7 +169,7 @@ class McServerStatusModule(
|
|||
|
||||
|
||||
|
||||
private suspend fun processCommand(msg: GetFriendMsgHistoryEvent.SpecificMsg) {
|
||||
private suspend fun processCommand(msg: MsgHistorySpecificMsg) {
|
||||
// 找出文本内容
|
||||
val text = msg.message
|
||||
.firstOrNull { it.type == MessageType.Text }
|
||||
|
|
@ -171,16 +177,18 @@ class McServerStatusModule(
|
|||
?.trim()
|
||||
?: return
|
||||
|
||||
// 解析命令
|
||||
val matchedCommand = commands.firstOrNull { text.startsWith(it) } ?: return
|
||||
var address = text.removePrefix(matchedCommand).trim()
|
||||
// 使用命令解析器解析命令
|
||||
val parsedCommand = commandParser.parseCommand(text) ?: return
|
||||
val (_, address) = parsedCommand
|
||||
|
||||
// 使用预设别名替换
|
||||
presetServerByAlias[address.lowercase()]?.let { presetIp ->
|
||||
address = presetIp
|
||||
val finalAddress = if (address.isNotEmpty()) {
|
||||
presetServerByAlias[address.lowercase()] ?: address
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
if (address.isEmpty()) {
|
||||
if (finalAddress.isEmpty()) {
|
||||
sendFailedMessage(
|
||||
napCatClient,
|
||||
msg.userId,
|
||||
|
|
@ -192,9 +200,8 @@ class McServerStatusModule(
|
|||
}
|
||||
|
||||
try {
|
||||
val status = mcSrvStatusClient.getServerStatus(address) // 返回 McServerStatus
|
||||
val status = mcSrvStatusClient.getServerStatus(finalAddress)
|
||||
|
||||
// 检查是否查询失败
|
||||
if (!status.online) {
|
||||
sendFailedMessage(
|
||||
napCatClient, msg.userId, msg.realId, msg.time,
|
||||
|
|
@ -203,9 +210,7 @@ class McServerStatusModule(
|
|||
return
|
||||
}
|
||||
|
||||
// 查询成功,发送状态消息
|
||||
sendStatusForwardMessage(napCatClient, msg, address, status, msg.realId, msg.time)
|
||||
|
||||
sendStatusForwardMessage(napCatClient, msg, finalAddress, status, msg.realId, msg.time)
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.error("查询服务器状态失败: $address", e)
|
||||
sendFailedMessage(
|
||||
|
|
@ -221,7 +226,7 @@ class McServerStatusModule(
|
|||
// ---------------- 转发消息封装 ----------------
|
||||
private suspend fun sendStatusForwardMessage(
|
||||
client: NapCatClient,
|
||||
msg: GetFriendMsgHistoryEvent.SpecificMsg,
|
||||
msg: MsgHistorySpecificMsg,
|
||||
address: String,
|
||||
status: McServerStatus,
|
||||
realId: Long,
|
||||
|
|
@ -311,23 +316,36 @@ class McServerStatusModule(
|
|||
data class CooldownState(
|
||||
val map: Map<Long, TriggerDetail> = emptyMap()
|
||||
) {
|
||||
// 获取上次处理时间
|
||||
fun getLastTriggerTime(qq: Long): Long = map[qq]?.time ?: -1
|
||||
|
||||
// 获取上次处理消息ID
|
||||
fun getLastTriggerRealId(qq: Long): Long = map[qq]?.realId ?: -1
|
||||
fun updateLastTrigger(qq: Long, realId: Long, time: Long = -1): CooldownState {
|
||||
|
||||
// 获取上次冷却消息ID
|
||||
fun getLastCooldownRealId(qq: Long): Long = map[qq]?.lastCooldownRealId ?: -1
|
||||
|
||||
// 冷却结束,允许处理消息 → 更新 time 和 realId
|
||||
fun updateLastTrigger(qq: Long, realId: Long, time: Long): CooldownState {
|
||||
val old = map[qq]
|
||||
val newTime = if (time != -1L) time else old?.time ?: -1
|
||||
val newMap = map.toMutableMap().apply {
|
||||
put(qq, TriggerDetail(realId, newTime, old?.lastCooldownRealId ?: -1))
|
||||
put(qq, TriggerDetail(
|
||||
realId = realId, // 当前允许处理消息ID
|
||||
time = time, // 当前允许处理消息时间
|
||||
lastCooldownRealId = old?.lastCooldownRealId ?: -1 // 保留冷却中记录的消息ID
|
||||
))
|
||||
}
|
||||
return copy(map = newMap)
|
||||
}
|
||||
|
||||
// 冷却中消息 → 只更新 lastCooldownRealId,保留 time 和 realId
|
||||
fun updateLastCooldownRealId(qq: Long, realId: Long): CooldownState {
|
||||
val old = map[qq]
|
||||
val newMap = map.toMutableMap().apply {
|
||||
put(qq, TriggerDetail(
|
||||
realId = old?.realId ?: -1,
|
||||
time = old?.time ?: -1,
|
||||
lastCooldownRealId = realId
|
||||
realId = old?.realId ?: -1, // 保持上次允许处理的消息ID
|
||||
time = old?.time ?: -1, // 保持上次允许处理的时间
|
||||
lastCooldownRealId = realId // 更新当前冷却拒绝的消息ID
|
||||
))
|
||||
}
|
||||
return copy(map = newMap)
|
||||
|
|
@ -336,9 +354,9 @@ class McServerStatusModule(
|
|||
|
||||
@Serializable
|
||||
data class TriggerDetail(
|
||||
val realId: Long,
|
||||
val time: Long,
|
||||
val lastCooldownRealId: Long = -1L
|
||||
val realId: Long, // 上次允许处理消息ID
|
||||
val time: Long, // 上次允许处理消息时间(秒)
|
||||
val lastCooldownRealId: Long = -1 // 上次被冷却拒绝的消息ID
|
||||
)
|
||||
|
||||
override fun loadState(): CooldownState {
|
||||
|
|
@ -368,4 +386,33 @@ class McServerStatusModule(
|
|||
}
|
||||
}
|
||||
}
|
||||
override fun info(): String {
|
||||
return buildString {
|
||||
appendLine("模块名称: $name")
|
||||
appendLine("模块类型: McServerStatusModule")
|
||||
appendLine("目标群组: ${groupMessagePollingModule.targetGroupId}")
|
||||
appendLine("机器人昵称: $selfNickName (ID: $selfId)")
|
||||
appendLine("冷却时间: ${cooldownMillis / 1000} 秒")
|
||||
appendLine("支持命令: ${commands.joinToString(", ")}")
|
||||
appendLine("预设服务器别名:")
|
||||
presetServer.forEach { (aliases, ip) ->
|
||||
appendLine(" ${aliases.joinToString("/")} -> $ip")
|
||||
}
|
||||
appendLine("状态文件路径: ${stateFile.absolutePath}")
|
||||
appendLine("状态备份文件路径: ${stateBackupFile.absolutePath}")
|
||||
}
|
||||
}
|
||||
// 返回模块使用帮助
|
||||
override fun help(): String = buildString {
|
||||
appendLine("使用帮助 - McServerStatusModule")
|
||||
appendLine("指令格式: /mcs <服务器别名或IP> 或 /s <服务器别名或IP>")
|
||||
appendLine("示例:")
|
||||
presetServerByAlias.forEach { (alias, ip) ->
|
||||
appendLine(" /mcs $alias -> 查询服务器 $ip 状态")
|
||||
}
|
||||
appendLine("注意事项:")
|
||||
appendLine(" - 查询冷却时间为 ${cooldownMillis / 1000} 秒")
|
||||
appendLine(" - 输入服务器 IP 或别名均可")
|
||||
appendLine(" - 查询结果会以转发消息形式发送到群组")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
/**
|
||||
* 记录所有被拒绝用户的Map,key = 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 = "轮询群组入群申请,根据答案列表自动同意或拒绝,并记录拒绝用户信息"
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,15 +1,43 @@
|
|||
package top.r3944realms.ltdmanager.module
|
||||
|
||||
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
|
||||
import top.r3944realms.ltdmanager.utils.LoggerUtil
|
||||
import java.util.*
|
||||
|
||||
class ModuleManager {
|
||||
|
||||
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)) {
|
||||
LoggerUtil.logger.warn("模块已注册: ${module.name}")
|
||||
return
|
||||
|
|
@ -17,11 +45,19 @@ class ModuleManager {
|
|||
modules[module.name] = module
|
||||
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]
|
||||
if (module == null) {
|
||||
LoggerUtil.logger.warn("尝试加载不存在的模块: $name")
|
||||
|
|
@ -41,7 +77,7 @@ class ModuleManager {
|
|||
/**
|
||||
* 卸载指定模块
|
||||
*/
|
||||
suspend fun unloadModule(name: String) {
|
||||
suspend fun unload(name: String) {
|
||||
val module = modules[name]
|
||||
if (module == null) {
|
||||
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) {
|
||||
names.forEach { loadModule(it) }
|
||||
names.forEach { load(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展方法:批量卸载模块
|
||||
*/
|
||||
suspend fun ModuleManager.unloadModules(vararg names: String) {
|
||||
names.forEach { unloadModule(it) }
|
||||
names.forEach { unload(it) }
|
||||
}
|
||||
/**
|
||||
* 关闭所有模块
|
||||
|
|
|
|||
26
src/main/kotlin/top/r3944realms/ltdmanager/module/Modules.kt
Normal file
26
src/main/kotlin/top/r3944realms/ltdmanager/module/Modules.kt
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
package top.r3944realms.ltdmanager.module
|
||||
|
||||
import top.r3944realms.ltdmanager.utils.FileNameFilter
|
||||
import java.io.File
|
||||
|
||||
interface PersistentState<T> {
|
||||
fun getStateFile(): File
|
||||
fun getStateFileInternal(): File
|
||||
fun getState(): T
|
||||
fun saveState(state: T)
|
||||
fun loadState(): T
|
||||
// 默认实现:统一管理 data 目录下的文件
|
||||
fun getStateFile(name: String): File {
|
||||
val dataDir = File("data")
|
||||
fun getStateFileInternal(name: String, subName: String): File {
|
||||
val dataDir = File("data", FileNameFilter.filterFileName(subName))
|
||||
if (!dataDir.exists()) dataDir.mkdirs()
|
||||
return File(dataDir, name)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,19 @@ import kotlinx.serialization.Serializable
|
|||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import top.r3944realms.ltdmanager.module.RconPlayerListModule.LastTriggerState
|
||||
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownManager
|
||||
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownScope
|
||||
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownStateProvider
|
||||
import top.r3944realms.ltdmanager.module.common.filter.TriggerMessageFilter
|
||||
import top.r3944realms.ltdmanager.module.common.filter.type.CooldownFilter
|
||||
import top.r3944realms.ltdmanager.module.common.filter.type.IgnoreSelfFilter
|
||||
import top.r3944realms.ltdmanager.module.common.filter.type.KeywordFilter
|
||||
import top.r3944realms.ltdmanager.module.common.filter.type.NewMessageFilter
|
||||
import top.r3944realms.ltdmanager.napcat.NapCatClient
|
||||
import top.r3944realms.ltdmanager.napcat.data.ID
|
||||
import top.r3944realms.ltdmanager.napcat.data.MessageElement
|
||||
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.other.SendGroupMsgRequest
|
||||
import top.r3944realms.ltdmanager.utils.CmdUtil
|
||||
|
|
@ -18,26 +26,61 @@ import java.io.File
|
|||
import java.util.concurrent.TimeoutException
|
||||
|
||||
class RconPlayerListModule(
|
||||
moduleName: String,
|
||||
private val groupMessagePollingModule: GroupMessagePollingModule,
|
||||
private val rconTimeOut: Long = 2_000L,
|
||||
private val cooldownMillis: Long = 30_000L,
|
||||
private var lastSuccessTime: Long = 0L,
|
||||
private val selfId: Long,
|
||||
private val selfNickName: String,
|
||||
private val rconPath: String,
|
||||
private val rconConfigPath: String,
|
||||
private val keywords: Set<String> = setOf("查看玩家列表", "玩家列表", "在线玩家")
|
||||
) : BaseModule(), PersistentState<LastTriggerState> {
|
||||
|
||||
override val name: String = "RconPlayerListModule"
|
||||
) : BaseModule(Modules.RCON_PLAYER_LIST, moduleName), PersistentState<LastTriggerState> {
|
||||
private val cooldownManager by lazy {
|
||||
CooldownManager(
|
||||
cooldownMillis = cooldownMillis,
|
||||
scope = CooldownScope.Global,
|
||||
stateProvider = object : CooldownStateProvider<LastTriggerState> {
|
||||
override fun load() = loadState()
|
||||
override fun save(state: LastTriggerState) = saveState(state)
|
||||
},
|
||||
getLastTrigger = { state, _ -> state.lastTriggerTime to state.lastTriggeredRealId },
|
||||
updateTrigger = { state, _, realId, time ->
|
||||
// ✅ 消息成功触发时更新状态
|
||||
state.updateTrigger(realId, time)
|
||||
state
|
||||
},
|
||||
updateCooldownRealId = { state, _, realId ->
|
||||
// ✅ 消息被冷却拒绝时更新 lastCooldownRealId
|
||||
state.updateCooldownRealId(realId)
|
||||
state
|
||||
},
|
||||
groupId = groupMessagePollingModule.targetGroupId
|
||||
)
|
||||
}
|
||||
/** 抽象过滤器组合 —— lazy 避免初始化顺序问题 */
|
||||
private val triggerFilter by lazy {
|
||||
TriggerMessageFilter(
|
||||
listOf(
|
||||
IgnoreSelfFilter(selfId),
|
||||
NewMessageFilter { _ ->
|
||||
lastTriggerState.lastTriggerTime to lastTriggerState.lastTriggeredRealId
|
||||
},
|
||||
KeywordFilter(keywords),
|
||||
CooldownFilter(cooldownManager) { msg, remain ->
|
||||
sendCooldownMessage(napCatClient, msg.realId, remain)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
private var scope : CoroutineScope? = null
|
||||
|
||||
// 持久化文件路径
|
||||
private val stateFile = getStateFile("rcon_playerlist_state.json")
|
||||
private val stateFile: File = getStateFileInternal("rcon_playerlist_state.json", name)
|
||||
|
||||
private val stateBackupFile = getStateFile("invitation_codes_quarry_state.json.bak")
|
||||
private val stateBackupFile: File = getStateFileInternal("rcon_playerlist_state.json.bak", name)
|
||||
|
||||
override fun getStateFile(): File = stateFile
|
||||
override fun getStateFileInternal(): File = stateFile
|
||||
|
||||
// 保存最新触发过的消息 realId 和 time
|
||||
private var lastTriggerState: LastTriggerState = loadState()
|
||||
|
|
@ -67,104 +110,60 @@ class RconPlayerListModule(
|
|||
LoggerUtil.logger.info("[$name] 模块已卸载完成")
|
||||
}
|
||||
|
||||
private suspend fun handleMessages(messages: List<GetFriendMsgHistoryEvent.SpecificMsg>) {
|
||||
val triggerMessages = messages
|
||||
.asSequence() // 使用序列提高性能,特别是消息量大时
|
||||
.filter { msg ->
|
||||
((msg.time > lastTriggerState.lastTriggerTime ||
|
||||
(msg.time == lastTriggerState.lastTriggerTime && msg.realId > lastTriggerState.lastTriggeredRealId))
|
||||
&& msg.userId != selfId) &&
|
||||
msg.message.any { seg ->
|
||||
seg.type == MessageType.Text &&
|
||||
seg.data.text?.let { text -> keywords.any { keyword -> text == keyword } } == true
|
||||
}
|
||||
}.toList()
|
||||
private suspend fun handleMessages(messages: List<MsgHistorySpecificMsg>) {
|
||||
val filtered = triggerFilter.filter(messages)
|
||||
|
||||
if (triggerMessages.isNotEmpty()) {
|
||||
val triggerMsg = triggerMessages.maxBy { it.time }
|
||||
LoggerUtil.logger.info("[$name] 找到触发消息 realId=${triggerMsg.realId}, time=${triggerMsg.time}, userId=${triggerMsg.userId}")
|
||||
// RCON 模块只取最新的一条消息
|
||||
val triggerMsg = filtered.maxByOrNull { it.time }
|
||||
if (triggerMsg != null) {
|
||||
try {
|
||||
processTrigger(triggerMsg)
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.error("[$name] 处理触发消息失败", e)
|
||||
sendFailedMessage(napCatClient, triggerMsg.realId, triggerMsg.time, "处理异常: ${e.message}")
|
||||
}
|
||||
}
|
||||
private suspend fun processTrigger(msg: GetFriendMsgHistoryEvent.SpecificMsg) {
|
||||
val now = System.currentTimeMillis()
|
||||
}
|
||||
private suspend fun processTrigger(msg: MsgHistorySpecificMsg) {
|
||||
LoggerUtil.logger.info("[$name] 执行 RCON 查询")
|
||||
|
||||
// ✅ 冷却检查(首次触发直接允许)
|
||||
val canTrigger = (lastSuccessTime == 0L) || (now - lastSuccessTime >= cooldownMillis)
|
||||
if (!canTrigger) {
|
||||
val remaining = ((cooldownMillis - (now - lastSuccessTime)) / 1000).coerceAtLeast(1)
|
||||
LoggerUtil.logger.info("[$name] 冷却中,拒绝执行,剩余 $remaining 秒")
|
||||
sendCooldownMessage(napCatClient, msg.realId, msg.time)
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ 执行 RCON 命令
|
||||
val commands = listOf("forge tps", "list")
|
||||
LoggerUtil.logger.info("[$name] 执行 RCON 命令: $commands")
|
||||
|
||||
runCatching {
|
||||
val tpsOutput = runCatching {
|
||||
CmdUtil.runExeCommand(
|
||||
rconPath,
|
||||
"-c", rconConfigPath,
|
||||
"-T", (rconTimeOut / 1000).toString() + "s",
|
||||
"forge tps"
|
||||
val tpsOutput = CmdUtil.runExeCommand(
|
||||
rconPath, "-c", rconConfigPath,
|
||||
"-T", (rconTimeOut / 1000).toString() + "s", "forge tps"
|
||||
)
|
||||
}.getOrElse { ex ->
|
||||
LoggerUtil.logger.warn("[$name] 执行 forge tps 失败: ${ex.message}")
|
||||
throw ex
|
||||
}
|
||||
|
||||
val listOutput = runCatching {
|
||||
CmdUtil.runExeCommand(
|
||||
rconPath,
|
||||
"-c", rconConfigPath,
|
||||
"-T", (rconTimeOut / 1000).toString() + "s",
|
||||
"list"
|
||||
val listOutput = CmdUtil.runExeCommand(
|
||||
rconPath, "-c", rconConfigPath,
|
||||
"-T", (rconTimeOut / 1000).toString() + "s", "list"
|
||||
)
|
||||
}.getOrElse { ex ->
|
||||
LoggerUtil.logger.warn("[$name] 执行 list 失败: ${ex.message}")
|
||||
throw ex
|
||||
}
|
||||
|
||||
if (tpsOutput.contains("i/o timeout") || listOutput.contains("i/o timeout")) {
|
||||
throw TimeoutException()
|
||||
}
|
||||
|
||||
// 合并输出,后续一起解析
|
||||
buildString {
|
||||
appendLine(tpsOutput.trim())
|
||||
appendLine("--------")
|
||||
appendLine(listOutput.trim())
|
||||
}
|
||||
}.onFailure { ex ->
|
||||
lastSuccessTime = now // ✅ 成功/失败都要刷新冷却开始时间
|
||||
|
||||
LoggerUtil.logger.error("[$name] RCON 查询失败", ex)
|
||||
if (ex is TimeoutException) {
|
||||
LoggerUtil.logger.warn("[$name] RCON 连接超时: ${ex.message}")
|
||||
sendFailedMessage(napCatClient, msg.realId, msg.time)
|
||||
} else {
|
||||
LoggerUtil.logger.error("[$name] RCON 命令执行失败", ex)
|
||||
sendFailedMessage(
|
||||
napCatClient,
|
||||
msg.realId,
|
||||
msg.time,
|
||||
"系统内部错误请联系管理员:${ex.message}"
|
||||
)
|
||||
throw ex
|
||||
sendFailedMessage(napCatClient, msg.realId, msg.time, "⏳ RCON 连接超时")
|
||||
// ✅ 更新触发状态 & 持久化
|
||||
lastTriggerState.lastTriggeredRealId = msg.realId
|
||||
lastTriggerState.lastTriggerTime = msg.time
|
||||
saveState(lastTriggerState)
|
||||
return
|
||||
}
|
||||
throw ex
|
||||
}.onSuccess { output ->
|
||||
lastSuccessTime = now
|
||||
LoggerUtil.logger.info("[$name] RCON 命令执行成功,输出长度: ${output.length}")
|
||||
LoggerUtil.logger.debug("[$name] RCON 输出内容: $output")
|
||||
|
||||
val tpsInfo = parseTPS(output)
|
||||
val playerListInfo = parsePlayerList(output)
|
||||
|
||||
LoggerUtil.logger.info(
|
||||
"[$name] 解析成功: TPS=${tpsInfo.overall.meanTPS}, 在线 ${playerListInfo.onlineCount} 人"
|
||||
)
|
||||
|
||||
sendForwardMessage(napCatClient, tpsInfo, playerListInfo, msg.realId, msg.time)
|
||||
}
|
||||
|
||||
|
|
@ -175,11 +174,8 @@ class RconPlayerListModule(
|
|||
}
|
||||
|
||||
|
||||
private suspend fun sendCooldownMessage(client: NapCatClient, realId: Long, time: Long) {
|
||||
val now = System.currentTimeMillis()
|
||||
val remaining = ((cooldownMillis - (now - lastSuccessTime)) / 1000).coerceAtLeast(1) // 至少显示 1 秒
|
||||
private suspend fun sendCooldownMessage(client: NapCatClient, realId: Long, remaining: Long) {
|
||||
val msg = "⏳ 查询过于频繁,请稍后再试(剩余 $remaining 秒)"
|
||||
|
||||
LoggerUtil.logger.info("[$name] 发送冷却提示: $msg")
|
||||
|
||||
val request = SendGroupMsgRequest(
|
||||
|
|
@ -187,11 +183,6 @@ class RconPlayerListModule(
|
|||
ID.long(groupMessagePollingModule.targetGroupId)
|
||||
)
|
||||
client.sendUnit(request)
|
||||
|
||||
// 更新触发状态,但不更新 lastSuccessTime(避免延长冷却)
|
||||
lastTriggerState.lastTriggeredRealId = realId
|
||||
lastTriggerState.lastTriggerTime = time
|
||||
saveState(lastTriggerState)
|
||||
}
|
||||
|
||||
private val failedMessages = listOf(
|
||||
|
|
@ -479,13 +470,30 @@ class RconPlayerListModule(
|
|||
// ---------------- 持久化部分 ----------------
|
||||
|
||||
@Serializable
|
||||
data class LastTriggerState(var lastTriggeredRealId: Long, var lastTriggerTime: Long)
|
||||
data class LastTriggerState(
|
||||
var lastTriggeredRealId: Long = -1, // 上次允许处理消息ID
|
||||
var lastTriggerTime: Long = 0, // 上次允许处理时间(毫秒或秒都可以,根据你的逻辑)
|
||||
var lastCooldownRealId: Long = -1 // 上次冷却期间被拒绝的消息ID
|
||||
) {
|
||||
/** ✅ 冷却结束,更新触发状态 */
|
||||
fun updateTrigger(realId: Long, time: Long) {
|
||||
lastTriggeredRealId = realId
|
||||
lastTriggerTime = time
|
||||
// 保留 lastCooldownRealId 不变
|
||||
}
|
||||
|
||||
/** ⚠️ 冷却中,更新冷却消息ID */
|
||||
fun updateCooldownRealId(realId: Long) {
|
||||
lastCooldownRealId = realId
|
||||
// 保留 lastTriggeredRealId 和 lastTriggerTime
|
||||
}
|
||||
}
|
||||
|
||||
override fun saveState(state: LastTriggerState) {
|
||||
try {
|
||||
// 先备份现有主文件
|
||||
if (stateFile.exists()) {
|
||||
stateFile.copyTo(File(stateFile.parent, stateFile.name + ".bak"), overwrite = true)
|
||||
stateFile.copyTo(stateBackupFile, overwrite = true)
|
||||
}
|
||||
|
||||
// 写入主文件
|
||||
|
|
@ -500,7 +508,7 @@ class RconPlayerListModule(
|
|||
return try {
|
||||
val fileToRead = when {
|
||||
stateFile.exists() -> stateFile
|
||||
File(stateFile.parent, stateFile.name + ".bak").exists() -> File(stateFile.parent, stateFile.name + ".bak")
|
||||
stateBackupFile.exists() -> stateBackupFile
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
|
@ -517,5 +525,36 @@ class RconPlayerListModule(
|
|||
LastTriggerState(-1L, 0L)
|
||||
}
|
||||
}
|
||||
// 返回模块基本信息
|
||||
override fun info(): String = buildString {
|
||||
appendLine("模块名称: $name")
|
||||
appendLine("模块类型: RconPlayerListModule")
|
||||
appendLine("目标群组: ${groupMessagePollingModule.targetGroupId}")
|
||||
appendLine("机器人昵称: $selfNickName (ID: $selfId)")
|
||||
appendLine("冷却时间: ${cooldownMillis / 1000} 秒")
|
||||
appendLine("RCON 命令路径: $rconPath")
|
||||
appendLine("RCON 配置文件路径: $rconConfigPath")
|
||||
appendLine("RCON 超时时间: $rconTimeOut ms")
|
||||
appendLine("关键词触发: ${keywords.joinToString(", ")}")
|
||||
appendLine("状态文件路径: ${stateFile.absolutePath}")
|
||||
appendLine("状态备份文件路径: ${stateBackupFile.absolutePath}")
|
||||
appendLine("上次触发消息ID: ${lastTriggerState.lastTriggeredRealId}")
|
||||
appendLine("上次触发时间: ${lastTriggerState.lastTriggerTime}")
|
||||
}
|
||||
|
||||
// 返回模块使用帮助
|
||||
override fun help(): String = buildString {
|
||||
appendLine("使用帮助 - RconPlayerListModule")
|
||||
appendLine("功能: 查询服务器 TPS 和在线玩家列表,通过关键词触发或冷却机制限制频率")
|
||||
appendLine("触发关键词: ${keywords.joinToString(", ")}")
|
||||
appendLine("示例:")
|
||||
keywords.forEach { keyword ->
|
||||
appendLine(" - 在群里发送 \"$keyword\" 将触发 RCON 查询")
|
||||
}
|
||||
appendLine("注意事项:")
|
||||
appendLine(" - 查询冷却时间为 ${cooldownMillis / 1000} 秒")
|
||||
appendLine(" - RCON 查询可能受服务器响应时间影响")
|
||||
appendLine(" - 查询结果会以转发消息形式发送到群组")
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?: ""
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package top.r3944realms.ltdmanager.module.common.cooldown
|
||||
|
||||
sealed class CooldownScope {
|
||||
data object Global : CooldownScope()
|
||||
data object PerUser : CooldownScope()
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package top.r3944realms.ltdmanager.module.common.cooldown
|
||||
|
||||
interface CooldownStateProvider<S> {
|
||||
fun load(): S
|
||||
fun save(state: S)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue
Block a user