├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── .github ├── ISSUE_TEMPLATE │ ├── -------.md │ ├── -----.md │ └── bug--.md └── workflows │ └── gradle-ci.yml ├── onebot-mirai ├── src │ ├── main │ │ └── kotlin │ │ │ └── com │ │ │ └── github │ │ │ └── yyuueexxiinngg │ │ │ └── onebot │ │ │ ├── util │ │ │ ├── TimeUtils.kt │ │ │ ├── GeneralUtils.kt │ │ │ ├── DatabaseUtils.kt │ │ │ ├── ApiParamsUtils.kt │ │ │ ├── VoiceUtils.kt │ │ │ ├── Json.kt │ │ │ ├── RichMessageHelper.kt │ │ │ ├── HttpClient.kt │ │ │ ├── ImgUtils.kt │ │ │ ├── Music.kt │ │ │ ├── EventFilter.kt │ │ │ └── CQMessgeParser.kt │ │ │ ├── web │ │ │ ├── HeartbeatScope.kt │ │ │ ├── queue │ │ │ │ ├── CacheRequestQueue.kt │ │ │ │ └── CacheSourceQueue.kt │ │ │ ├── http │ │ │ │ ├── HttpApiServer.kt │ │ │ │ ├── ReportService.kt │ │ │ │ └── HttpApiModule.kt │ │ │ └── websocket │ │ │ │ ├── WebsocketActions.kt │ │ │ │ ├── WebsocketServer.kt │ │ │ │ └── WebsocketReverseClient.kt │ │ │ ├── data │ │ │ └── common │ │ │ │ ├── DTO.kt │ │ │ │ ├── ContactDTO.kt │ │ │ │ ├── MessageDTO.kt │ │ │ │ ├── ResponseDTO.kt │ │ │ │ └── BotEventDTO.kt │ │ │ ├── PluginSettings.kt │ │ │ ├── Session.kt │ │ │ └── PluginBase.kt │ └── test │ │ └── kotlin │ │ └── mirai │ │ └── RunMirai.kt └── build.gradle.kts ├── settings.gradle.kts ├── .gitignore ├── onebot-kotlin ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── com │ └── github │ └── yyuueexxiinngg │ └── onebot │ └── Main.kt ├── gradlew.bat ├── gradlew ├── CHANGELOG.md └── README.md /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yyuueexxiinngg/onebot-kotlin/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | org.gradle.parallel=true 3 | # Kapt issue workaround with JDK 16 4 | # kapt.use.worker.api=false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/-------.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 疑问 / 帮助 3 | about: 询问一个问题 4 | title: "[Question]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/-----.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 新特性申请 3 | about: 请求添加一个功能 4 | title: "[Feature]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/util/TimeUtils.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot.util 2 | 3 | fun currentTimeMillis(): Long = System.currentTimeMillis() 4 | 5 | fun currentTimeSeconds(): Long = currentTimeMillis() / 1000 -------------------------------------------------------------------------------- /onebot-mirai/src/test/kotlin/mirai/RunMirai.kt: -------------------------------------------------------------------------------- 1 | package mirai 2 | 3 | import net.mamoe.mirai.console.terminal.MiraiConsoleTerminalLoader 4 | 5 | object RunMirai { 6 | 7 | // 执行 gradle task: runMiraiConsole 来自动编译, shadow, 复制, 并启动 pure console. 8 | @JvmStatic 9 | fun main(args: Array) { 10 | // 默认在 /test 目录下运行 11 | MiraiConsoleTerminalLoader.main(emptyArray()) 12 | } 13 | } -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | resolutionStrategy { 3 | eachPlugin { 4 | when (requested.id.id) { 5 | "com.github.johnrengelman.shadow" -> useModule("com.github.jengelman.gradle.plugins:shadow:${requested.version}") 6 | } 7 | } 8 | } 9 | 10 | repositories { 11 | maven(url = "https://mirrors.huaweicloud.com/repository/maven") 12 | gradlePluginPortal() 13 | mavenCentral() 14 | } 15 | } 16 | 17 | rootProject.name = "onebot" 18 | include(":onebot-mirai") 19 | include(":onebot-kotlin") -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/web/HeartbeatScope.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot.web 2 | 3 | import kotlinx.coroutines.CoroutineExceptionHandler 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.SupervisorJob 6 | import com.github.yyuueexxiinngg.onebot.logger 7 | import kotlin.coroutines.CoroutineContext 8 | 9 | class HeartbeatScope(coroutineContext: CoroutineContext) : CoroutineScope { 10 | override val coroutineContext: CoroutineContext = coroutineContext + CoroutineExceptionHandler { _, throwable -> 11 | logger.error("Exception in Heartbeat", throwable) 12 | } + SupervisorJob() 13 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug--.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: BUG反馈 3 | about: 提交一个 bug 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ``` 17 | 18 | 19 | 20 | ``` 21 | 22 | 23 | #### 复现 24 | 25 | 26 | 27 | 28 | 29 | 30 | #### 版本 31 | onebot-` `- ` ` 32 | 33 | 34 | 35 | 36 | 37 | mirai-core-` ` 38 | 39 | mirai-console-` ` 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/data/common/DTO.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Mamoe Technologies and contributors. 3 | * 4 | * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. 5 | * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link. 6 | * 7 | * https://github.com/mamoe/mirai/blob/master/LICENSE 8 | */ 9 | 10 | package com.github.yyuueexxiinngg.onebot.data.common 11 | 12 | import kotlinx.serialization.Serializable 13 | 14 | interface DTO 15 | 16 | @Serializable 17 | abstract class EventDTO { 18 | abstract var post_type: String 19 | abstract var self_id: Long 20 | abstract var time: Long 21 | } 22 | 23 | @Serializable 24 | class IgnoreEventDTO(override var self_id: Long) : EventDTO() { 25 | override var post_type = "IGNORED" 26 | override var time = System.currentTimeMillis() 27 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /test 2 | /.idea 3 | 4 | # Created by .ignore support plugin (hsz.mobi) 5 | ### Kotlin template 6 | # Compiled class file 7 | *.class 8 | 9 | # Log file 10 | *.log 11 | 12 | # BlueJ files 13 | *.ctxt 14 | 15 | # Mobile Tools for Java (J2ME) 16 | .mtj.tmp/ 17 | 18 | # Package Files # 19 | *.jar 20 | *.war 21 | *.nar 22 | *.ear 23 | *.zip 24 | *.tar.gz 25 | *.rar 26 | 27 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 28 | hs_err_pid* 29 | 30 | ### Gradle template 31 | .gradle 32 | **/build/ 33 | !src/**/build/ 34 | 35 | # Ignore Gradle GUI config 36 | gradle-app.setting 37 | 38 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 39 | !gradle-wrapper.jar 40 | 41 | # Cache of project 42 | .gradletasknamecache 43 | 44 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 45 | # gradle/wrapper/gradle-wrapper.properties 46 | 47 | -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/util/GeneralUtils.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot.util 2 | 3 | import java.security.MessageDigest 4 | 5 | @OptIn(ExperimentalUnsignedTypes::class) 6 | internal fun ByteArray.toUHexString( 7 | separator: String = " ", 8 | offset: Int = 0, 9 | length: Int = this.size - offset 10 | ): String { 11 | if (length == 0) { 12 | return "" 13 | } 14 | val lastIndex = offset + length 15 | return buildString(length * 2) { 16 | this@toUHexString.forEachIndexed { index, it -> 17 | if (index in offset until lastIndex) { 18 | var ret = it.toUByte().toString(16).uppercase() 19 | if (ret.length == 1) ret = "0$ret" 20 | append(ret) 21 | if (index < lastIndex - 1) append(separator) 22 | } 23 | } 24 | } 25 | } 26 | 27 | fun md5(data: ByteArray): ByteArray { 28 | return MessageDigest.getInstance("MD5").digest(data) 29 | } 30 | 31 | fun md5(str: String): ByteArray = md5(str.toByteArray()) -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/web/queue/CacheRequestQueue.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot.web.queue 2 | 3 | import net.mamoe.mirai.event.events.BotEvent 4 | import net.mamoe.mirai.event.events.BotInvitedJoinGroupRequestEvent 5 | import net.mamoe.mirai.event.events.MemberJoinRequestEvent 6 | import net.mamoe.mirai.event.events.NewFriendRequestEvent 7 | 8 | class CacheRequestQueue : LinkedHashMap() { 9 | 10 | var cacheSize = 512 11 | 12 | override fun get(key: Long): BotEvent = super.get(key) ?: throw NoSuchElementException() 13 | 14 | override fun put(key: Long, value: BotEvent): BotEvent? = super.put(key, value).also { 15 | if (size > cacheSize) { 16 | remove(this.entries.first().key) 17 | } 18 | } 19 | 20 | fun add(source: NewFriendRequestEvent) { 21 | put(source.eventId, source) 22 | } 23 | 24 | fun add(source: MemberJoinRequestEvent) { 25 | put(source.eventId, source) 26 | } 27 | 28 | fun add(source: BotInvitedJoinGroupRequestEvent) { 29 | put(source.eventId, source) 30 | } 31 | } -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/util/DatabaseUtils.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot.util 2 | 3 | import com.github.yyuueexxiinngg.onebot.PluginBase 4 | import com.github.yyuueexxiinngg.onebot.PluginSettings 5 | import net.mamoe.mirai.event.events.MessageEvent 6 | import net.mamoe.mirai.message.data.MessageChain.Companion.serializeToJsonString 7 | import net.mamoe.mirai.message.data.internalId 8 | import java.nio.ByteBuffer 9 | import java.util.zip.CRC32 10 | 11 | fun IntArray.toMessageId(botId: Long, contactId: Long): Int { 12 | val crc = CRC32() 13 | val messageId = "$botId$$contactId$${joinToString("-")}" 14 | crc.update(messageId.toByteArray()) 15 | return crc.value.toInt() 16 | } 17 | 18 | fun Int.toByteArray(): ByteArray = ByteBuffer.allocate(4).putInt(this).array() 19 | 20 | fun MessageEvent.saveMessageToDB() { 21 | if (PluginSettings.db.enable) { 22 | val messageId = message.internalId.toMessageId(bot.id, source.fromId) 23 | PluginBase.db?.put( 24 | messageId.toByteArray(), 25 | message.serializeToJsonString().toByteArray() 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/web/queue/CacheSourceQueue.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Mamoe Technologies and contributors. 3 | * 4 | * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. 5 | * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link. 6 | * 7 | * https://github.com/mamoe/mirai/blob/master/LICENSE 8 | */ 9 | 10 | package com.github.yyuueexxiinngg.onebot.web.queue 11 | 12 | import com.github.yyuueexxiinngg.onebot.util.toMessageId 13 | import net.mamoe.mirai.message.data.MessageSource 14 | 15 | class CacheSourceQueue : LinkedHashMap() { 16 | 17 | var cacheSize = 1024 18 | 19 | override fun get(key: Int): MessageSource = super.get(key) ?: throw NoSuchElementException() 20 | 21 | override fun put(key: Int, value: MessageSource): MessageSource? = super.put(key, value).also { 22 | if (size > cacheSize) { 23 | remove(this.entries.first().key) 24 | } 25 | } 26 | 27 | fun add(source: MessageSource) { 28 | put(source.internalIds.toMessageId(source.botId, source.fromId), source) 29 | } 30 | } -------------------------------------------------------------------------------- /.github/workflows/gradle-ci.yml: -------------------------------------------------------------------------------- 1 | name: Gradle CI 2 | 3 | on: 4 | push: 5 | branches: [ master, dev ] 6 | paths-ignore: 7 | - '**.md' 8 | pull_request: 9 | branches: [ master, dev ] 10 | paths-ignore: 11 | - '**.md' 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | name: Clone repository 20 | 21 | - name: Prepare Java 11 22 | uses: actions/setup-java@v1 23 | with: 24 | java-version: 11 25 | 26 | - name: Cache Gradle packages 27 | uses: actions/cache@v2 28 | with: 29 | path: ~/.gradle/caches 30 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts') }} 31 | restore-keys: ${{ runner.os }}-gradle 32 | 33 | - name: Build project 34 | run: | 35 | gradle wrapper 36 | bash gradlew shadow 37 | 38 | - name: Upload artifact 39 | uses: actions/upload-artifact@v2 40 | with: 41 | name: onebot-mirai 42 | path: onebot-mirai/build/libs/onebot-mirai-*-all.jar 43 | 44 | - name: Upload artifact 45 | uses: actions/upload-artifact@v2 46 | with: 47 | name: onebot-kotlin 48 | path: onebot-kotlin/build/libs/onebot-kotlin-*-all.jar 49 | -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/web/http/HttpApiServer.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot.web.http 2 | 3 | import com.github.yyuueexxiinngg.onebot.BotSession 4 | import com.github.yyuueexxiinngg.onebot.logger 5 | import io.ktor.server.cio.* 6 | import io.ktor.server.engine.* 7 | 8 | class HttpApiServer( 9 | private val session: BotSession 10 | ) { 11 | lateinit var server: ApplicationEngine 12 | 13 | init { 14 | val settings = session.settings.http 15 | logger.info("Bot: ${session.bot.id} HTTP API服务端是否配置开启: ${settings.enable}") 16 | if (settings.enable) { 17 | try { 18 | server = embeddedServer(CIO, environment = applicationEngineEnvironment { 19 | this.module { oneBotApiServer(session, settings) } 20 | connector { 21 | this.host = settings.host 22 | this.port = settings.port 23 | } 24 | }) 25 | server.start(false) 26 | } catch (e: Exception) { 27 | logger.error("Bot:${session.bot.id} HTTP API服务端模块启用失败") 28 | } 29 | } 30 | 31 | } 32 | 33 | fun close() { 34 | server.stop(5000, 5000) 35 | } 36 | } -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/util/ApiParamsUtils.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot.util 2 | 3 | import kotlinx.serialization.json.JsonElement 4 | import kotlinx.serialization.json.contentOrNull 5 | import kotlinx.serialization.json.jsonPrimitive 6 | 7 | typealias ApiParams = Map 8 | 9 | internal val JsonElement?.int: Int 10 | get() = this?.jsonPrimitive?.content?.toIntOrNull() ?: throw IllegalArgumentException() 11 | internal val JsonElement?.intOrNull: Int? get() = this?.jsonPrimitive?.content?.toIntOrNull() 12 | 13 | internal val JsonElement?.long: Long 14 | get() = this?.jsonPrimitive?.content?.toLongOrNull() ?: throw IllegalArgumentException() 15 | internal val JsonElement?.longOrNull: Long? get() = this?.jsonPrimitive?.content?.toLongOrNull() 16 | 17 | internal val JsonElement?.booleanOrNull: Boolean? get() = this?.jsonPrimitive?.content?.toBooleanStrictOrNull() 18 | 19 | internal val JsonElement?.string: String get() = this?.jsonPrimitive?.content ?: throw IllegalArgumentException() 20 | internal val JsonElement?.stringOrNull: String? get() = this?.jsonPrimitive?.contentOrNull 21 | 22 | internal fun String.toBooleanStrictOrNull(): Boolean? = when { 23 | this.equals("true", ignoreCase = true) -> true 24 | this.equals("false", ignoreCase = true) -> false 25 | else -> null 26 | } 27 | -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/util/VoiceUtils.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot.util 2 | 3 | import com.github.yyuueexxiinngg.onebot.logger 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.withContext 6 | import net.mamoe.mirai.contact.Contact 7 | import net.mamoe.mirai.contact.Group 8 | import net.mamoe.mirai.message.data.Audio 9 | import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource 10 | import java.io.File 11 | 12 | suspend fun getCachedRecordFile(name: String): File? = withContext(Dispatchers.IO) { 13 | if (name.endsWith(".cqrecord")) getDataFile("record", name) 14 | else getDataFile("record", "$name.cqrecord") 15 | } 16 | 17 | suspend fun tryResolveCachedRecord(name: String, contact: Contact?): Audio? { 18 | val cacheFile = getCachedRecordFile(name) 19 | if (cacheFile != null) { 20 | if (cacheFile.canRead()) { 21 | logger.info("此语音已缓存, 如需删除缓存请至 ${cacheFile.absolutePath}") 22 | return cacheFile.toExternalResource().use { res -> 23 | contact?.let { 24 | (it as Group).uploadAudio(res) 25 | } 26 | } 27 | } else { 28 | logger.error("Record $name cache file cannot read.") 29 | } 30 | } else { 31 | logger.info("Record $name cache file cannot found.") 32 | } 33 | return null 34 | } -------------------------------------------------------------------------------- /onebot-kotlin/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | id("com.github.johnrengelman.shadow") 4 | } 5 | 6 | dependencies { 7 | implementation(kotlin("stdlib-jdk8")) 8 | implementation("net.mamoe:mirai-core-api-jvm:${Versions.miraiCoreVersion}") 9 | implementation("net.mamoe:mirai-core-jvm:${Versions.miraiCoreVersion}") 10 | implementation("net.mamoe:mirai-core-utils-jvm:${Versions.miraiCoreVersion}") 11 | implementation("net.mamoe:mirai-console:${Versions.miraiConsoleVersion}") 12 | implementation("net.mamoe:mirai-console-terminal:${Versions.miraiConsoleVersion}") 13 | implementation("com.github.ajalt.clikt:clikt:${Versions.clikt}") 14 | 15 | implementation(project(":onebot-mirai")) 16 | } 17 | 18 | val jar by tasks.getting(Jar::class) { 19 | manifest { 20 | attributes["Main-Class"] = "com.github.yyuueexxiinngg.onebot.MainKt" 21 | } 22 | } 23 | 24 | tasks { 25 | val runEmbedded by creating(JavaExec::class.java) { 26 | group = "onebot-kotlin" 27 | main = "com.github.yyuueexxiinngg.onebot.MainKt" 28 | workingDir = File("../test") 29 | dependsOn(shadowJar) 30 | dependsOn(testClasses) 31 | doFirst { 32 | classpath = sourceSets["test"].runtimeClasspath 33 | standardInput = System.`in` 34 | args("--backend", "mirai") 35 | systemProperty("mirai.slider.captcha.supported", 1) 36 | } 37 | } 38 | } 39 | 40 | kotlin.sourceSets.all { 41 | languageSettings.useExperimentalAnnotation("kotlin.RequiresOptIn") 42 | } 43 | 44 | kotlin.target.compilations.all { 45 | kotlinOptions.freeCompilerArgs += "-Xjvm-default=enable" 46 | kotlinOptions.jvmTarget = "1.8" 47 | } -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/web/websocket/WebsocketActions.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot.web.websocket 2 | 3 | import com.github.yyuueexxiinngg.onebot.MiraiApi 4 | import com.github.yyuueexxiinngg.onebot.callMiraiApi 5 | import com.github.yyuueexxiinngg.onebot.data.common.ResponseDTO 6 | import com.github.yyuueexxiinngg.onebot.logger 7 | import com.github.yyuueexxiinngg.onebot.util.toJson 8 | import io.ktor.http.cio.websocket.* 9 | import kotlinx.coroutines.CoroutineScope 10 | import kotlinx.coroutines.channels.SendChannel 11 | import kotlinx.coroutines.launch 12 | import kotlinx.serialization.json.Json 13 | import kotlinx.serialization.json.jsonObject 14 | import kotlinx.serialization.json.jsonPrimitive 15 | import kotlin.coroutines.EmptyCoroutineContext 16 | 17 | suspend fun handleWebSocketActions(outgoing: SendChannel, mirai: MiraiApi, actionText: String) { 18 | try { 19 | logger.debug("WebSocket收到操作请求: $actionText") 20 | val json = Json.parseToJsonElement(actionText).jsonObject 21 | val echo = json["echo"] 22 | var action = json["action"]?.jsonPrimitive?.content 23 | val responseDTO: ResponseDTO 24 | if (action?.endsWith("_async") == true) { 25 | responseDTO = ResponseDTO.AsyncStarted() 26 | action = action.replace("_async", "") 27 | CoroutineScope(EmptyCoroutineContext).launch { 28 | callMiraiApi(action, json["params"]?.jsonObject ?: mapOf(), mirai) 29 | } 30 | } else { 31 | responseDTO = callMiraiApi(action, json["params"]?.jsonObject ?: mapOf(), mirai) 32 | } 33 | responseDTO.echo = echo 34 | val jsonToSend = responseDTO.toJson() 35 | logger.debug("WebSocket将返回结果: $jsonToSend") 36 | outgoing.send(Frame.Text(jsonToSend)) 37 | } catch (e: Exception) { 38 | logger.error(e) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/util/Json.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot.util 2 | 3 | import com.github.yyuueexxiinngg.onebot.data.common.* 4 | import kotlinx.serialization.InternalSerializationApi 5 | import kotlinx.serialization.SerializationStrategy 6 | import kotlinx.serialization.encodeToString 7 | import kotlinx.serialization.json.Json 8 | import kotlinx.serialization.modules.SerializersModule 9 | import kotlinx.serialization.modules.polymorphic 10 | import kotlinx.serialization.modules.subclass 11 | import kotlinx.serialization.serializer 12 | import kotlin.reflect.KClass 13 | 14 | inline fun T.toJson( 15 | serializer: SerializationStrategy? = null 16 | ): String = if (serializer == null) { 17 | OneBotJson.json.encodeToString(this) 18 | } else OneBotJson.json.encodeToString(serializer, this) 19 | 20 | // 序列化列表时,stringify需要使用的泛型是T,而非List 21 | // 因为使用的stringify的stringify(objs: List)重载 22 | inline fun List.toJson( 23 | serializer: SerializationStrategy>? = null 24 | ): String = if (serializer == null) OneBotJson.json.encodeToString(this) 25 | else OneBotJson.json.encodeToString(serializer, this) 26 | 27 | /** 28 | * Json解析规则,需要注册支持的多态的类 29 | */ 30 | object OneBotJson { 31 | @OptIn(InternalSerializationApi::class) 32 | val json = Json { 33 | encodeDefaults = true 34 | classDiscriminator = "ClassType" 35 | isLenient = true 36 | ignoreUnknownKeys = true 37 | 38 | @Suppress("UNCHECKED_CAST") 39 | serializersModule = SerializersModule { 40 | polymorphic(EventDTO::class) { 41 | this.subclass(GroupMessagePacketDTO::class) 42 | this.subclass(PrivateMessagePacketDTO::class) 43 | this.subclass(IgnoreEventDTO::class) 44 | } 45 | 46 | BotEventDTO::class.sealedSubclasses.forEach { 47 | val clazz = it as KClass 48 | polymorphic(EventDTO::class, clazz, clazz.serializer()) 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/data/common/ContactDTO.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Mamoe Technologies and contributors. 3 | * 4 | * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. 5 | * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link. 6 | * 7 | * https://github.com/mamoe/mirai/blob/master/LICENSE 8 | */ 9 | 10 | package com.github.yyuueexxiinngg.onebot.data.common 11 | 12 | import kotlinx.serialization.SerialName 13 | import kotlinx.serialization.Serializable 14 | import net.mamoe.mirai.contact.* 15 | 16 | @Serializable 17 | sealed class ContactDTO : DTO { 18 | abstract val user_id: Long 19 | abstract val nickname: String 20 | abstract val sex: String? 21 | abstract val age: Int? 22 | } 23 | 24 | @Serializable 25 | @SerialName("Member") 26 | data class MemberDTO( 27 | override val user_id: Long, 28 | override val nickname: String, 29 | val card: String? = null, 30 | override val sex: String? = null, 31 | override val age: Int? = null, 32 | val area: String? = null, 33 | val level: String? = null, 34 | val role: String? = null, 35 | val title: String? = null 36 | ) : ContactDTO() { 37 | constructor(member: Member) : this( 38 | member.id, 39 | member.nameCardOrNick, 40 | member.nameCardOrNick, 41 | "unknown", 42 | 0, 43 | "unknown", 44 | "unknown", 45 | if (member.permission == MemberPermission.ADMINISTRATOR) "admin" else member.permission.name.lowercase(), 46 | "unknown" 47 | ) 48 | } 49 | 50 | @Serializable 51 | @SerialName("QQ") 52 | data class QQDTO( 53 | override val user_id: Long, 54 | override val nickname: String, 55 | override val sex: String? = null, 56 | override val age: Int? = null 57 | ) : ContactDTO() { 58 | constructor(contact: User) : this( 59 | contact.id, 60 | contact.nameCardOrNick, 61 | "unknown", 62 | 0 63 | ) 64 | } 65 | 66 | @Serializable 67 | data class AnonymousMemberDTO( 68 | val id: Long, 69 | val name: String, 70 | val flag: String 71 | ) { 72 | constructor(member: AnonymousMember) : this( 73 | member.id, 74 | member.nameCard, 75 | member.anonymousId + "&${member.nameCard}" // Need member nick to mute 76 | ) 77 | } -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/PluginSettings.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | import net.mamoe.mirai.console.data.ReadOnlyPluginConfig 6 | import net.mamoe.mirai.console.data.value 7 | import net.mamoe.yamlkt.Comment 8 | 9 | object PluginSettings : ReadOnlyPluginConfig("settings") { 10 | val proxy by value("") 11 | val db by value(DBSettings()) 12 | val bots: MutableMap? by value(mutableMapOf("12345654321" to BotSettings())) 13 | 14 | @Serializable 15 | data class DBSettings( 16 | var enable: Boolean = true, 17 | // @Comment("数据库的最大容量限制,单位GB,非正数视为无限制,超出此大小后旧记录将被删除") 18 | // var maxSize: Double = 0.0 19 | ) 20 | 21 | @Serializable 22 | data class BotSettings( 23 | var cacheImage: Boolean = false, 24 | var cacheRecord: Boolean = false, 25 | var heartbeat: HeartbeatSettings = HeartbeatSettings(), 26 | var http: HTTPSettings = HTTPSettings(), 27 | @SerialName("ws_reverse") 28 | var wsReverse: MutableList = mutableListOf(WebsocketReverseClientSettings()), 29 | var ws: WebsocketServerSettings = WebsocketServerSettings() 30 | ) 31 | 32 | @Serializable 33 | data class HeartbeatSettings( 34 | var enable: Boolean = false, 35 | var interval: Long = 1500L 36 | ) 37 | 38 | @Serializable 39 | data class HTTPSettings( 40 | var enable: Boolean = false, 41 | var host: String = "0.0.0.0", 42 | var port: Int = 5700, 43 | var accessToken: String = "", 44 | var postUrl: String = "", 45 | var postMessageFormat: String = "string", 46 | var secret: String = "", 47 | @Comment("上报超时时间, 单位毫秒, 须大于0才会生效") 48 | var timeout: Long = 0L 49 | ) 50 | 51 | @Serializable 52 | data class WebsocketReverseClientSettings( 53 | var enable: Boolean = false, 54 | var postMessageFormat: String = "string", 55 | var reverseHost: String = "127.0.0.1", 56 | var reversePort: Int = 8080, 57 | var accessToken: String = "", 58 | var reversePath: String = "/ws", 59 | var reverseApiPath: String = "/api", 60 | var reverseEventPath: String = "/event", 61 | var useUniversal: Boolean = true, 62 | var useTLS: Boolean = false, 63 | var reconnectInterval: Long = 3000L, 64 | ) 65 | 66 | @Serializable 67 | data class WebsocketServerSettings( 68 | var enable: Boolean = false, 69 | var postMessageFormat: String = "string", 70 | var wsHost: String = "0.0.0.0", 71 | var wsPort: Int = 6700, 72 | var accessToken: String = "" 73 | ) 74 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /onebot-kotlin/src/main/kotlin/com/github/yyuueexxiinngg/onebot/Main.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot 2 | 3 | import kotlinx.coroutines.CancellationException 4 | import com.github.ajalt.clikt.core.CliktCommand 5 | import com.github.ajalt.clikt.parameters.arguments.argument 6 | import com.github.ajalt.clikt.parameters.arguments.multiple 7 | import com.github.ajalt.clikt.parameters.options.default 8 | import com.github.ajalt.clikt.parameters.options.flag 9 | import com.github.ajalt.clikt.parameters.options.option 10 | import com.github.ajalt.clikt.parameters.types.enum 11 | import kotlinx.coroutines.runBlocking 12 | import net.mamoe.mirai.alsoLogin 13 | import net.mamoe.mirai.console.MiraiConsole 14 | import net.mamoe.mirai.console.command.descriptor.ExperimentalCommandDescriptors 15 | import net.mamoe.mirai.console.plugin.PluginManager.INSTANCE.enable 16 | import net.mamoe.mirai.console.plugin.PluginManager.INSTANCE.load 17 | import net.mamoe.mirai.console.terminal.ConsoleTerminalExperimentalApi 18 | import net.mamoe.mirai.console.terminal.MiraiConsoleTerminalLoader 19 | import net.mamoe.mirai.console.util.ConsoleExperimentalApi 20 | 21 | object OneBotKtCli : CliktCommand(name = "onebot-kotlin") { 22 | enum class BackendType { 23 | Mirai, 24 | Telegram 25 | } 26 | 27 | private val backend: BackendType by option( 28 | help = """``` 29 | Backend client of OntBot. 30 | "Mirai" to use mirai, a Kotlin implementation of QQ protocol; 31 | ------------------------------------------ 32 | 后端. "Mirai" 为使用mirai, 一个Kotlin实现的QQ协议客户端; 33 | ```""".trimIndent(), 34 | envvar = "ONEBOT_BACKEND" 35 | ).enum().default(BackendType.Mirai) 36 | 37 | internal val account: String? by option( 38 | help = """``` 39 | Account to auto login. 40 | QQ when using mirai backend 41 | ------------------------------------ 42 | 需要自动登录的帐号 43 | 使用mirai后段时为QQ号 44 | ```""".trimIndent(), 45 | envvar = "ONEBOT_ACCOUNT" 46 | ) 47 | 48 | internal val password: String? by option( 49 | help = """``` 50 | Account password to auto login. 51 | ------------------------------------ 52 | 需要自动登录的帐号密码 53 | ```""".trimIndent(), 54 | envvar = "ONEBOT_PASSWORD" 55 | ) 56 | 57 | private val args: Boolean? by option( 58 | help = """``` 59 | Arguments pass through to backend. 60 | Usage: --args -- --help 61 | ------------------------------------ 62 | 要传递给后端的参数 63 | 用法: --args -- --help 64 | ``` """.trimIndent() 65 | ).flag() 66 | 67 | private val argsToPass by argument().multiple() 68 | 69 | override fun run() { 70 | when (backend) { 71 | BackendType.Mirai -> runMirai(argsToPass.toTypedArray()) 72 | else -> runMirai(argsToPass.toTypedArray()) 73 | } 74 | } 75 | } 76 | 77 | fun main(args: Array) { 78 | OneBotKtCli.main(args) 79 | } 80 | 81 | @OptIn(ConsoleExperimentalApi::class, ExperimentalCommandDescriptors::class, ConsoleTerminalExperimentalApi::class) 82 | fun runMirai(args: Array) { 83 | MiraiConsoleTerminalLoader.parse(args, exitProcess = true) 84 | MiraiConsoleTerminalLoader.startAsDaemon() 85 | PluginBase.load() 86 | PluginBase.enable() 87 | 88 | try { 89 | runBlocking { 90 | if (OneBotKtCli.account != null && OneBotKtCli.password != null) { 91 | MiraiConsole.addBot(OneBotKtCli.account!!.toLong(), OneBotKtCli.password!!) { 92 | fileBasedDeviceInfo() 93 | }.alsoLogin() 94 | } 95 | 96 | MiraiConsole.job.join() 97 | } 98 | } catch (e: CancellationException) { 99 | // ignored 100 | } 101 | } -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/util/RichMessageHelper.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * Mirai Native 4 | * 5 | * Copyright (C) 2020 iTX Technologies 6 | * 7 | * This program is free software: you can redistribute it and/or modify 8 | * it under the terms of the GNU Affero General Public License as 9 | * published by the Free Software Foundation, either version 3 of the 10 | * License, or (at your option) any later version. 11 | * 12 | * This program is distributed in the hope that it will be useful, 13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | * GNU Affero General Public License for more details. 16 | * 17 | * You should have received a copy of the GNU Affero General Public License 18 | * along with this program. If not, see . 19 | * 20 | * @author PeratX 21 | * @website https://github.com/iTXTech/mirai-native 22 | * 23 | */ 24 | 25 | package com.github.yyuueexxiinngg.onebot.util 26 | 27 | import net.mamoe.mirai.Bot 28 | import net.mamoe.mirai.message.data.ServiceMessage 29 | import net.mamoe.mirai.message.data.SimpleServiceMessage 30 | import net.mamoe.mirai.message.data.buildXmlMessage 31 | import net.mamoe.mirai.utils.MiraiExperimentalApi 32 | 33 | @OptIn(MiraiExperimentalApi::class) 34 | object RichMessageHelper { 35 | fun share(u: String, title: String?, content: String?, image: String?) = buildXmlMessage(60) { 36 | templateId = 12345 37 | serviceId = 1 38 | action = "web" 39 | brief = "[分享] " + (title ?: "") 40 | url = u 41 | item { 42 | layout = 2 43 | if (image != null) { 44 | picture(image) 45 | } 46 | if (title != null) { 47 | title(title) 48 | } 49 | if (content != null) { 50 | summary(content) 51 | } 52 | } 53 | } 54 | 55 | fun contactQQ(bot: Bot, id: Long): ServiceMessage { 56 | val nick = bot.getFriend(id)?.nick 57 | return xmlMessage( 58 | "" + 59 | "" + 63 | "推荐联系人
" + 64 | "" + 65 | "" + 66 | "$nick帐号:$id
" 67 | ) 68 | } 69 | 70 | fun contactGroup(bot: Bot, id: Long): SimpleServiceMessage { 71 | val group = bot.getGroup(id) 72 | // TODO: 创建人,链接 73 | val founder = "未知创建人" 74 | val url = "https://github.com/mamoe/mirai" 75 | return xmlMessage( 76 | "\n" + 77 | "推荐群
" + 79 | "" + 80 | "${group?.name}创建人:$founder
" 81 | ) 82 | } 83 | } 84 | 85 | @OptIn(MiraiExperimentalApi::class) 86 | fun xmlMessage(content: String) = SimpleServiceMessage(60, content) 87 | 88 | @OptIn(MiraiExperimentalApi::class) 89 | fun jsonMessage(content: String) = SimpleServiceMessage(1, content) 90 | -------------------------------------------------------------------------------- /onebot-mirai/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | kotlin("plugin.serialization") 4 | kotlin("kapt") 5 | id("com.github.johnrengelman.shadow") 6 | id("com.github.gmazzo.buildconfig") 7 | } 8 | 9 | dependencies { 10 | kapt("com.google.auto.service", "auto-service", Versions.autoService) 11 | 12 | compileOnly(kotlin("stdlib-jdk8")) 13 | compileOnly("net.mamoe:mirai-core-api-jvm:${Versions.miraiCoreVersion}") 14 | compileOnly("net.mamoe:mirai-core-jvm:${Versions.miraiCoreVersion}") 15 | compileOnly("net.mamoe:mirai-console:${Versions.miraiConsoleVersion}") 16 | compileOnly("net.mamoe:mirai-console-terminal:${Versions.miraiConsoleVersion}") 17 | compileOnly(kotlin("serialization", Versions.kotlinVersion)) 18 | compileOnly("com.google.auto.service", "auto-service-annotations", Versions.autoService) 19 | 20 | implementation("net.mamoe.yamlkt:yamlkt:${Versions.yamlkt}") 21 | implementation(kotlin("reflect", Versions.kotlinVersion)) 22 | implementation(kotlinx("serialization-cbor", Versions.kotlinSerializationVersion)) 23 | implementation(kotlinx("serialization-json", Versions.kotlinSerializationVersion)) 24 | implementation(ktor("server-cio")) 25 | implementation(ktor("websockets")) 26 | implementation(ktor("client-okhttp")) 27 | implementation(ktor("client-websockets")) 28 | implementation("ch.qos.logback:logback-classic:${Versions.logback}") 29 | implementation("com.google.code.gson:gson:${Versions.gson}") 30 | implementation("io.github.pcmind:leveldb:${Versions.leveldb}") 31 | implementation("org.iq80.snappy:snappy:${Versions.snappy}") 32 | // implementation("io.github.mzdluo123:silk4j:${Versions.silk4j}") // mamoe/mirai#1249 eta wen 33 | 34 | testImplementation(kotlin("stdlib-jdk8")) 35 | testImplementation("net.mamoe:mirai-core-api-jvm:${Versions.miraiCoreVersion}") 36 | testImplementation("net.mamoe:mirai-core-jvm:${Versions.miraiCoreVersion}") 37 | testImplementation("net.mamoe:mirai-console:${Versions.miraiConsoleVersion}") 38 | testImplementation("net.mamoe:mirai-console-terminal:${Versions.miraiConsoleVersion}") 39 | } 40 | 41 | tasks { 42 | buildConfig { 43 | packageName("com.github.yyuueexxiinngg.onebot") 44 | val commitHash = "git rev-parse --short HEAD".runCommand(projectDir) 45 | buildConfigField("String", "VERSION", "\"${Versions.projectVersion}\"") 46 | if (commitHash != null) { 47 | buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"") 48 | } 49 | } 50 | 51 | shadowJar { 52 | dependsOn(generateBuildConfig) 53 | } 54 | 55 | val runMiraiConsole by creating(JavaExec::class.java) { 56 | group = "mirai" 57 | main = "mirai.RunMirai" 58 | dependsOn(shadowJar) 59 | dependsOn(testClasses) 60 | 61 | val testConsoleDir = "../test" 62 | 63 | doFirst { 64 | fun removeOldVersions() { 65 | File("$testConsoleDir/plugins/").walk() 66 | .filter { it.name.matches(Regex("""${project.name}-.*-all.jar""")) } 67 | .forEach { 68 | it.delete() 69 | println("deleting old files: ${it.name}") 70 | } 71 | } 72 | 73 | fun copyBuildOutput() { 74 | File("build/libs/").walk() 75 | .filter { it.name.contains("-all") } 76 | .maxBy { it.lastModified() } 77 | ?.let { 78 | println("Coping ${it.name}") 79 | it.inputStream().use { input -> 80 | File("$testConsoleDir/plugins/${it.name}").apply { createNewFile() } 81 | .outputStream().use { output -> output.write(input.readBytes()) } 82 | } 83 | println("Copied ${it.name}") 84 | } 85 | } 86 | 87 | workingDir = File(testConsoleDir) 88 | workingDir.mkdir() 89 | File(workingDir, "plugins").mkdir() 90 | removeOldVersions() 91 | copyBuildOutput() 92 | 93 | classpath = sourceSets["test"].runtimeClasspath 94 | standardInput = System.`in` 95 | args(Versions.miraiCoreVersion, Versions.miraiConsoleVersion) 96 | } 97 | } 98 | } 99 | 100 | java { 101 | sourceCompatibility = JavaVersion.VERSION_1_8 102 | targetCompatibility = JavaVersion.VERSION_1_8 103 | } 104 | 105 | kotlin.sourceSets.all { 106 | languageSettings.useExperimentalAnnotation("kotlin.RequiresOptIn") 107 | } 108 | 109 | kotlin.target.compilations.all { 110 | kotlinOptions.freeCompilerArgs += "-Xjvm-default=enable" 111 | kotlinOptions.jvmTarget = "1.8" 112 | } -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/util/HttpClient.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot.util 2 | 3 | import com.github.yyuueexxiinngg.onebot.PluginSettings 4 | import com.github.yyuueexxiinngg.onebot.logger 5 | import io.ktor.client.engine.* 6 | import io.ktor.client.engine.okhttp.* 7 | import io.ktor.client.features.* 8 | import io.ktor.client.request.* 9 | import io.ktor.http.* 10 | import io.ktor.network.sockets.* 11 | import java.io.InputStream 12 | 13 | class HttpClient { 14 | companion object { 15 | private val http = io.ktor.client.HttpClient(OkHttp) { 16 | install(HttpTimeout) 17 | engine { 18 | config { 19 | retryOnConnectionFailure(true) 20 | } 21 | } 22 | } 23 | var httpProxied: io.ktor.client.HttpClient? = null 24 | private val ua = 25 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36" 26 | 27 | suspend fun getBytes(url: String, timeout: Long = 0L, useProxy: Boolean = false): ByteArray? { 28 | return try { 29 | (if (useProxy && httpProxied != null) httpProxied else http)?.request { 30 | url(url) 31 | headers { 32 | append("User-Agent", ua) 33 | } 34 | if (timeout > 0L) { 35 | timeout { 36 | socketTimeoutMillis = timeout 37 | } 38 | } 39 | method = HttpMethod.Get 40 | } 41 | } catch (e: Exception) { 42 | when (e) { 43 | is SocketTimeoutException -> logger.warning("Timeout when getting $url, timeout was set to $timeout milliseconds") 44 | else -> logger.warning("Error when getting $url, ${e.message}") 45 | } 46 | null 47 | } 48 | } 49 | 50 | suspend fun getInputStream(url: String): InputStream { 51 | return http.request { 52 | url(url) 53 | headers { 54 | append("User-Agent", ua) 55 | } 56 | method = HttpMethod.Get 57 | } 58 | } 59 | 60 | @Suppress("DuplicatedCode") 61 | fun initHTTPClientProxy() { 62 | if (PluginSettings.proxy != "") { 63 | val parts = PluginSettings.proxy.split("=", limit = 2) 64 | if (parts.size == 2) { 65 | when (parts[0].trim()) { 66 | "http" -> { 67 | logger.debug("创建HTTP Proxied HTTP客户端中: ${parts[1].trim()}") 68 | val httpProxy = ProxyBuilder.http(parts[1].trim()) 69 | if (httpProxied != null) { 70 | httpProxied?.close() 71 | httpProxied = null 72 | } 73 | httpProxied = io.ktor.client.HttpClient(OkHttp) { 74 | install(HttpTimeout) 75 | engine { 76 | proxy = httpProxy 77 | config { 78 | retryOnConnectionFailure(true) 79 | } 80 | } 81 | } 82 | } 83 | "sock" -> { 84 | logger.debug("创建Sock Proxied HTTP客户端中: ${parts[1].trim()}") 85 | val proxyParts = parts[1].trim().split(":") 86 | if (proxyParts.size == 2) { 87 | val sockProxy = ProxyBuilder.socks(proxyParts[0].trim(), proxyParts[1].trim().toInt()) 88 | if (httpProxied != null) { 89 | httpProxied?.close() 90 | httpProxied = null 91 | } 92 | httpProxied = io.ktor.client.HttpClient(OkHttp) { 93 | install(HttpTimeout) 94 | engine { 95 | proxy = sockProxy 96 | config { 97 | retryOnConnectionFailure(true) 98 | } 99 | } 100 | } 101 | } 102 | } 103 | } 104 | } 105 | } 106 | } 107 | } 108 | } -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/Session.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot 2 | 3 | import com.github.yyuueexxiinngg.onebot.data.common.IgnoreEventDTO 4 | import com.github.yyuueexxiinngg.onebot.data.common.toDTO 5 | import com.github.yyuueexxiinngg.onebot.util.EventFilter 6 | import com.github.yyuueexxiinngg.onebot.util.toJson 7 | import com.github.yyuueexxiinngg.onebot.web.http.HttpApiServer 8 | import com.github.yyuueexxiinngg.onebot.web.http.ReportService 9 | import com.github.yyuueexxiinngg.onebot.web.websocket.WebSocketReverseClient 10 | import com.github.yyuueexxiinngg.onebot.web.websocket.WebSocketServer 11 | import kotlinx.coroutines.CoroutineScope 12 | import kotlinx.coroutines.Job 13 | import kotlinx.coroutines.SupervisorJob 14 | import kotlinx.coroutines.runBlocking 15 | import net.mamoe.mirai.Bot 16 | import net.mamoe.mirai.event.events.BotEvent 17 | import kotlin.coroutines.CoroutineContext 18 | import kotlin.coroutines.EmptyCoroutineContext 19 | 20 | internal typealias BotEventListener = suspend (String) -> Unit 21 | 22 | internal object SessionManager { 23 | 24 | val allSession: MutableMap = mutableMapOf() 25 | 26 | operator fun get(botId: Long) = allSession[botId] 27 | 28 | fun containSession(botId: Long): Boolean = allSession.containsKey(botId) 29 | 30 | fun closeSession(botId: Long) = allSession.remove(botId)?.also { it.close() } 31 | 32 | fun closeSession(session: Session) = closeSession(session.botId) 33 | 34 | fun createBotSession(bot: Bot, settings: PluginSettings.BotSettings): BotSession = 35 | BotSession(bot, settings, EmptyCoroutineContext).also { session -> allSession[bot.id] = session } 36 | } 37 | 38 | /** 39 | * @author NaturalHG 40 | * 这个用于管理不同Client与Mirai HTTP的会话 41 | * 42 | * [Session]均为内部操作用类 43 | * 需使用[SessionManager] 44 | */ 45 | abstract class Session internal constructor( 46 | coroutineContext: CoroutineContext, 47 | open val botId: Long 48 | ) : CoroutineScope { 49 | val supervisorJob = SupervisorJob(coroutineContext[Job]) 50 | final override val coroutineContext: CoroutineContext = supervisorJob + coroutineContext 51 | 52 | internal open fun close() { 53 | supervisorJob.complete() 54 | } 55 | } 56 | 57 | class BotSession internal constructor( 58 | val bot: Bot, 59 | val settings: PluginSettings.BotSettings, 60 | coroutineContext: CoroutineContext 61 | ) : 62 | Session(coroutineContext, bot.id) { 63 | private val eventSubscriptionString = mutableListOf() 64 | private val eventSubscriptionArray = mutableListOf() 65 | private var hasStringFormatSubscription = false 66 | private var hasArrayFormatSubscription = false 67 | 68 | val apiImpl = MiraiApi(bot) 69 | private val httpApiServer = HttpApiServer(this) 70 | private val websocketClient = WebSocketReverseClient(this) 71 | private val websocketServer = WebSocketServer(this) 72 | private val httpReportService = ReportService(this) 73 | 74 | init { 75 | if (settings.cacheImage) logger.info("Bot: ${bot.id} 已开启接收图片缓存, 将会缓存收取到的所有图片") 76 | else logger.info("Bot: ${bot.id} 未开启接收图片缓存, 将不会缓存收取到的所有图片, 如需开启, 请在当前Bot配置中添加cacheImage=true") 77 | 78 | if (settings.cacheRecord) logger.info("Bot: ${bot.id} 已开启接收语音缓存, 将会缓存收取到的所有语音") 79 | else logger.info("Bot: ${bot.id} 未开启接收语音缓存, 将不会缓存收取到的所有语音, 如需开启, 请在当前Bot配置中添加cacheRecord=true") 80 | 81 | if (settings.heartbeat.enable) logger.info("Bot: ${bot.id} 已开启心跳机制, 设定的心跳发送频率为 ${settings.heartbeat.interval} 毫秒") 82 | } 83 | 84 | override fun close() { 85 | runBlocking { 86 | websocketClient.close() 87 | websocketServer.close() 88 | httpApiServer.close() 89 | httpReportService.close() 90 | } 91 | super.close() 92 | } 93 | 94 | suspend fun triggerEvent(event: BotEvent) { 95 | // Boolean checks should be faster then List size checks I suppose. 96 | if (this.hasStringFormatSubscription) { 97 | triggerEventInternal(event, true) 98 | } 99 | 100 | if (this.hasArrayFormatSubscription) { 101 | triggerEventInternal(event, false) 102 | } 103 | } 104 | 105 | private suspend fun triggerEventInternal(event: BotEvent, isStringFormat: Boolean = false) { 106 | event.toDTO(isRawMessage = isStringFormat).takeIf { it !is IgnoreEventDTO }?.let { dto -> 107 | val jsonToSend = dto.toJson() 108 | PluginBase.logger.debug("将发送事件: $jsonToSend") 109 | if (!EventFilter.eval(jsonToSend)) { 110 | PluginBase.logger.debug("事件被Event Filter命中, 取消发送") 111 | } else { 112 | if (isStringFormat) { 113 | this.eventSubscriptionString.forEach { it(jsonToSend) } 114 | } else { 115 | this.eventSubscriptionArray.forEach { it(jsonToSend) } 116 | } 117 | } 118 | } 119 | } 120 | 121 | fun subscribeEvent(listener: BotEventListener, isRawMessage: Boolean): BotEventListener = 122 | listener.also { 123 | if (isRawMessage) { 124 | eventSubscriptionString.add(it) 125 | hasStringFormatSubscription = true 126 | } else { 127 | eventSubscriptionArray.add(it) 128 | hasArrayFormatSubscription = true 129 | } 130 | } 131 | 132 | fun unsubscribeEvent(listener: BotEventListener, isRawMessage: Boolean) { 133 | if (isRawMessage) { 134 | eventSubscriptionString.remove(listener) 135 | if (eventSubscriptionString.isEmpty()) hasStringFormatSubscription = false 136 | } else { 137 | eventSubscriptionArray.remove(listener) 138 | if (eventSubscriptionArray.isEmpty()) hasArrayFormatSubscription = false 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/util/ImgUtils.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot.util 2 | 3 | import com.github.yyuueexxiinngg.onebot.PluginBase 4 | import com.github.yyuueexxiinngg.onebot.logger 5 | import io.ktor.client.* 6 | import io.ktor.client.request.* 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.withContext 9 | import net.mamoe.mirai.contact.Contact 10 | import net.mamoe.mirai.contact.Group 11 | import net.mamoe.mirai.contact.User 12 | import net.mamoe.mirai.message.data.Image 13 | import java.io.File 14 | import java.nio.Buffer 15 | import java.nio.ByteBuffer 16 | import java.util.* 17 | 18 | data class CachedImage( 19 | val file: File, 20 | val fileName: String, 21 | val path: String, 22 | val md5: String, 23 | val size: Int, 24 | val url: String, 25 | val addTime: Long, 26 | val imageType: String?, 27 | ) 28 | 29 | internal fun getImageType(image: Image): String { 30 | val parts = image.imageId.split(".", limit = 2) 31 | return if (parts.size == 2) { 32 | parts[1] 33 | } else { 34 | "unknown" 35 | } 36 | } 37 | 38 | fun constructCacheImageMeta(md5: String, size: Int?, url: String?, imageType: String?): String { 39 | return """ 40 | [image] 41 | md5=${md5} 42 | size=${size ?: 0} 43 | url=${url ?: "https://c2cpicdw.qpic.cn/offpic_new/0/0-00-${md5}/0?term=2"} 44 | addtime=${currentTimeMillis()} 45 | type=${imageType ?: "unknown"} 46 | """.trimIndent() 47 | } 48 | 49 | internal fun getImageType(bytes: ByteArray): String { 50 | return with(bytes.copyOfRange(0, 8).toUHexString("")) { 51 | when { 52 | startsWith("FFD8") -> "jpg" 53 | startsWith("89504E47") -> "png" 54 | startsWith("47494638") -> "gif" 55 | startsWith("424D") -> "bmp" 56 | startsWith("52494646") -> "webp" 57 | else -> "unknown" 58 | } 59 | } 60 | } 61 | 62 | fun md5ToImageId(md5: String, contact: Contact): String { 63 | return when (contact) { 64 | is Group -> "{${md5.substring(0, 8)}-" + 65 | "${md5.substring(8, 12)}-" + 66 | "${md5.substring(12, 16)}-" + 67 | "${md5.substring(16, 20)}-" + 68 | "${md5.substring(20)}}.mirai" 69 | is User -> "/0-00-$md5" 70 | else -> "" 71 | } 72 | } 73 | 74 | suspend fun tryResolveCachedImage(name: String, contact: Contact?): Image? { 75 | var image: Image? = null 76 | val cachedImage = getCachedImageFile(name) 77 | 78 | 79 | if (cachedImage != null) { 80 | // If add time till now more than one day, check if the image exists 81 | if (contact != null) { 82 | if (currentTimeMillis() - cachedImage.addTime >= 1000 * 60 * 60 * 24) { 83 | runCatching { 84 | HttpClient {}.head(cachedImage.url) 85 | }.onFailure { 86 | //Not existed, delete file and return null 87 | logger.error("Failed to fetch cache image", it) 88 | cachedImage.file.delete() 89 | return null 90 | }.onSuccess { 91 | //Existed and update cache file 92 | val imgContent = constructCacheImageMeta( 93 | cachedImage.md5, 94 | cachedImage.size, 95 | cachedImage.url, 96 | cachedImage.imageType 97 | ) 98 | PluginBase.saveImageAsync("$name.cqimg", imgContent).start() 99 | } 100 | } 101 | //Only use id when existing 102 | image = Image.fromId(md5ToImageId(cachedImage.md5, contact)) 103 | } 104 | } 105 | return image 106 | } 107 | 108 | suspend fun getCachedImageFile(name: String): CachedImage? = withContext(Dispatchers.IO) { 109 | val cacheFile = 110 | with(name) { 111 | when { 112 | endsWith(".cqimg") -> getDataFile("image", name) 113 | endsWith(".image") -> getDataFile("image", name.lowercase(Locale.getDefault())) 114 | else -> getDataFile("image", "$name.cqimg") ?: getDataFile( 115 | "image", 116 | "${name.lowercase(Locale.getDefault())}.image" 117 | ) 118 | } 119 | } 120 | 121 | if (cacheFile != null) { 122 | if (cacheFile.canRead()) { 123 | logger.info("此链接图片已缓存, 如需删除缓存请至 ${cacheFile.absolutePath}") 124 | var md5 = "" 125 | var size = 0 126 | var url = "" 127 | var addTime = 0L 128 | var imageType: String? = null 129 | 130 | when (cacheFile.extension) { 131 | "cqimg" -> { 132 | val cacheMediaContent = cacheFile.readLines() 133 | cacheMediaContent.forEach { 134 | val parts = it.trim().split("=", limit = 2) 135 | if (parts.size == 2) { 136 | when (parts[0]) { 137 | "md5" -> md5 = parts[1] 138 | "size" -> size = parts[1].toIntOrNull() ?: 0 139 | "url" -> url = parts[1] 140 | "addtime" -> addTime = parts[1].toLongOrNull() ?: 0L 141 | "type" -> imageType = parts[1] 142 | } 143 | } 144 | } 145 | } 146 | 147 | "image" -> { 148 | val bytes = cacheFile.readBytes() 149 | md5 = bytes.copyOf(16).toUHexString("") 150 | size = bytes.copyOfRange(16, 20).toUnsignedInt().toInt() 151 | url = "https://c2cpicdw.qpic.cn/offpic_new//0/0-00-$md5/0?term=2" 152 | } 153 | } 154 | 155 | if (md5 != "" && size != 0) { 156 | return@withContext CachedImage( 157 | cacheFile, 158 | name, 159 | cacheFile.absolutePath, 160 | md5, 161 | size, 162 | url, 163 | addTime, 164 | imageType 165 | ) 166 | } else { // If cache file corrupted 167 | cacheFile.delete() 168 | } 169 | } else { 170 | logger.error("Image $name cache file cannot read.") 171 | } 172 | } else { 173 | logger.info("Image $name cache file cannot be found.") 174 | } 175 | null 176 | } 177 | 178 | private fun ByteArray.toUnsignedInt(): Long { 179 | val buffer: ByteBuffer = ByteBuffer.allocate(8).put(byteArrayOf(0, 0, 0, 0)).put(this) 180 | (buffer as Buffer).position(0) 181 | return buffer.long 182 | } -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/web/websocket/WebsocketServer.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot.web.websocket 2 | 3 | import com.github.yyuueexxiinngg.onebot.BotSession 4 | import com.github.yyuueexxiinngg.onebot.PluginSettings 5 | import com.github.yyuueexxiinngg.onebot.data.common.HeartbeatMetaEventDTO 6 | import com.github.yyuueexxiinngg.onebot.data.common.PluginStatusData 7 | import com.github.yyuueexxiinngg.onebot.logger 8 | import com.github.yyuueexxiinngg.onebot.util.currentTimeSeconds 9 | import com.github.yyuueexxiinngg.onebot.util.toJson 10 | import com.github.yyuueexxiinngg.onebot.web.HeartbeatScope 11 | import io.ktor.application.* 12 | import io.ktor.features.* 13 | import io.ktor.http.cio.websocket.* 14 | import io.ktor.routing.* 15 | import io.ktor.server.cio.* 16 | import io.ktor.server.engine.* 17 | import io.ktor.websocket.* 18 | import kotlinx.coroutines.* 19 | import kotlinx.coroutines.channels.SendChannel 20 | import kotlinx.coroutines.channels.consumeEach 21 | import kotlin.coroutines.CoroutineContext 22 | import kotlin.coroutines.EmptyCoroutineContext 23 | 24 | class WebsocketServerScope(coroutineContext: CoroutineContext) : CoroutineScope { 25 | override val coroutineContext: CoroutineContext = coroutineContext + CoroutineExceptionHandler { _, throwable -> 26 | logger.error("Exception in WebsocketServer", throwable) 27 | } + SupervisorJob() 28 | } 29 | 30 | class WebSocketServer( 31 | private val session: BotSession 32 | ) { 33 | private lateinit var server: ApplicationEngine 34 | 35 | init { 36 | val settings = session.settings.ws 37 | logger.info("Bot: ${session.bot.id} 正向Websocket服务端是否配置开启: ${settings.enable}") 38 | if (settings.enable) { 39 | try { 40 | server = embeddedServer(CIO, environment = applicationEngineEnvironment { 41 | this.module { websocketServer(session, settings) } 42 | connector { 43 | this.host = settings.wsHost 44 | this.port = settings.wsPort 45 | } 46 | }) 47 | server.start(false) 48 | } catch (e: Exception) { 49 | logger.error("Bot:${session.bot.id} Websocket服务端模块启用失败") 50 | } 51 | } 52 | 53 | } 54 | 55 | fun close() { 56 | server.stop(5000, 5000) 57 | } 58 | 59 | } 60 | 61 | @OptIn(ExperimentalCoroutinesApi::class) 62 | @Suppress("DuplicatedCode") 63 | fun Application.websocketServer(session: BotSession, settings: PluginSettings.WebsocketServerSettings) { 64 | val scope = WebsocketServerScope(EmptyCoroutineContext) 65 | logger.debug("Bot: ${session.bot.id} 尝试开启正向Websocket服务端于端口: ${settings.wsPort}") 66 | install(DefaultHeaders) 67 | install(WebSockets) 68 | routing { 69 | logger.debug("Bot: ${session.bot.id} 正向Websocket服务端开始创建路由") 70 | val isRawMessage = settings.postMessageFormat != "array" 71 | websocket("/event", session, settings) { _ -> 72 | logger.debug("Bot: ${session.bot.id} 正向Websocket服务端 /event 开始监听事件") 73 | val listener = session.subscribeEvent( 74 | { 75 | send(Frame.Text(it)) 76 | }, 77 | isRawMessage 78 | ) 79 | 80 | val heartbeatJob = if (session.settings.heartbeat.enable) emitHeartbeat(session, outgoing) else null 81 | 82 | try { 83 | incoming.consumeEach { logger.warning("WS Server Event 路由只负责发送事件, 不响应收到的请求") } 84 | } finally { 85 | logger.info("Bot: ${session.bot.id} 正向Websocket服务端 /event 连接被关闭") 86 | session.unsubscribeEvent(listener, isRawMessage) 87 | heartbeatJob?.cancel() 88 | } 89 | } 90 | websocket("/api", session, settings) { 91 | try { 92 | logger.debug("Bot: ${session.bot.id} 正向Websocket服务端 /api 开始处理API请求") 93 | incoming.consumeEach { 94 | if (it is Frame.Text) { 95 | scope.launch { 96 | handleWebSocketActions(outgoing, session.apiImpl, it.readText()) 97 | } 98 | } 99 | } 100 | } finally { 101 | logger.info("Bot: ${session.bot.id} 正向Websocket服务端 /api 连接被关闭") 102 | } 103 | } 104 | websocket("/", session, settings) { _ -> 105 | logger.debug("Bot: ${session.bot.id} 正向Websocket服务端 / 开始监听事件") 106 | val listener = session.subscribeEvent( 107 | { 108 | send(Frame.Text(it)) 109 | }, 110 | isRawMessage 111 | ) 112 | 113 | val heartbeatJob = if (session.settings.heartbeat.enable) emitHeartbeat(session, outgoing) else null 114 | 115 | try { 116 | logger.debug("Bot: ${session.bot.id} 正向Websocket服务端 / 开始处理API请求") 117 | incoming.consumeEach { 118 | if (it is Frame.Text) { 119 | scope.launch { 120 | handleWebSocketActions(outgoing, session.apiImpl, it.readText()) 121 | } 122 | } 123 | } 124 | } finally { 125 | logger.debug("Bot: ${session.bot.id} 正向Websocket服务端 / 连接被关闭") 126 | session.unsubscribeEvent(listener, isRawMessage) 127 | heartbeatJob?.cancel() 128 | } 129 | } 130 | } 131 | } 132 | 133 | private suspend fun emitHeartbeat(session: BotSession, outgoing: SendChannel): Job { 134 | return HeartbeatScope(EmptyCoroutineContext).launch { 135 | while (true) { 136 | outgoing.send( 137 | Frame.Text( 138 | HeartbeatMetaEventDTO( 139 | session.botId, 140 | currentTimeSeconds(), 141 | PluginStatusData( 142 | good = session.bot.isOnline, 143 | online = session.bot.isOnline 144 | ), 145 | session.settings.heartbeat.interval 146 | ).toJson() 147 | ) 148 | ) 149 | delay(session.settings.heartbeat.interval) 150 | } 151 | } 152 | } 153 | 154 | 155 | private inline fun Route.websocket( 156 | path: String, 157 | session: BotSession, 158 | settings: PluginSettings.WebsocketServerSettings, 159 | crossinline body: suspend DefaultWebSocketServerSession.(BotSession) -> Unit 160 | ) { 161 | webSocket(path) { 162 | if (settings.accessToken != "") { 163 | val accessToken = 164 | call.parameters["access_token"] ?: call.request.headers["Authorization"]?.let { 165 | Regex("""(?:[Tt]oken|Bearer)\s+(.*)""").find(it)?.groupValues?.get(1) 166 | } 167 | if (accessToken != settings.accessToken) { 168 | close(CloseReason(CloseReason.Codes.NORMAL, "accessToken不正确")) 169 | return@webSocket 170 | } 171 | } 172 | 173 | body(session) 174 | } 175 | } 176 | 177 | -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/web/http/ReportService.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot.web.http 2 | 3 | import com.github.yyuueexxiinngg.onebot.BotEventListener 4 | import com.github.yyuueexxiinngg.onebot.BotSession 5 | import com.github.yyuueexxiinngg.onebot.MiraiApi 6 | import com.github.yyuueexxiinngg.onebot.data.common.HeartbeatMetaEventDTO 7 | import com.github.yyuueexxiinngg.onebot.data.common.LifecycleMetaEventDTO 8 | import com.github.yyuueexxiinngg.onebot.data.common.PluginStatusData 9 | import com.github.yyuueexxiinngg.onebot.logger 10 | import com.github.yyuueexxiinngg.onebot.util.currentTimeSeconds 11 | import com.github.yyuueexxiinngg.onebot.util.toJson 12 | import com.github.yyuueexxiinngg.onebot.web.HeartbeatScope 13 | import io.ktor.client.* 14 | import io.ktor.client.engine.okhttp.* 15 | import io.ktor.client.features.* 16 | import io.ktor.client.request.* 17 | import io.ktor.http.* 18 | import io.ktor.http.content.* 19 | import kotlinx.coroutines.* 20 | import kotlinx.serialization.SerializationException 21 | import kotlinx.serialization.json.Json 22 | import kotlinx.serialization.json.jsonObject 23 | import java.net.SocketTimeoutException 24 | import javax.crypto.Mac 25 | import javax.crypto.spec.SecretKeySpec 26 | import kotlin.coroutines.CoroutineContext 27 | import kotlin.coroutines.EmptyCoroutineContext 28 | 29 | class ReportServiceScope(coroutineContext: CoroutineContext) : CoroutineScope { 30 | override val coroutineContext: CoroutineContext = coroutineContext + CoroutineExceptionHandler { _, throwable -> 31 | logger.error("Exception in ReportService", throwable) 32 | } + SupervisorJob() 33 | } 34 | 35 | 36 | class ReportService( 37 | private val session: BotSession 38 | ) { 39 | 40 | private val http = HttpClient(OkHttp) { 41 | engine { 42 | config { 43 | retryOnConnectionFailure(true) 44 | } 45 | } 46 | install(HttpTimeout) 47 | } 48 | 49 | private var sha1Util: Mac? = null 50 | 51 | private var subscription: Pair? = null 52 | 53 | private var heartbeatJob: Job? = null 54 | 55 | private val scope = ReportServiceScope(EmptyCoroutineContext) 56 | 57 | private val settings = session.settings.http 58 | 59 | init { 60 | scope.launch { 61 | startReportService() 62 | } 63 | } 64 | 65 | private suspend fun startReportService() { 66 | if (settings.postUrl != "") { 67 | if (settings.secret != "") { 68 | val mac = Mac.getInstance("HmacSHA1") 69 | val secret = SecretKeySpec(settings.secret.toByteArray(), "HmacSHA1") 70 | mac.init(secret) 71 | sha1Util = mac 72 | } 73 | 74 | report( 75 | session.apiImpl, 76 | settings.postUrl, 77 | session.bot.id, 78 | LifecycleMetaEventDTO(session.botId, "enable", currentTimeSeconds()).toJson(), 79 | settings.secret, 80 | false 81 | ) 82 | 83 | subscription = 84 | Pair( 85 | session.subscribeEvent( 86 | { jsonToSend -> 87 | scope.launch(Dispatchers.IO) { 88 | report( 89 | session.apiImpl, 90 | settings.postUrl, 91 | session.bot.id, 92 | jsonToSend, 93 | settings.secret, 94 | true 95 | ) 96 | } 97 | }, 98 | settings.postMessageFormat == "string" 99 | ), 100 | settings.postMessageFormat == "string" 101 | ) 102 | 103 | if (session.settings.heartbeat.enable) { 104 | heartbeatJob = HeartbeatScope(EmptyCoroutineContext).launch { 105 | while (true) { 106 | report( 107 | session.apiImpl, 108 | settings.postUrl, 109 | session.bot.id, 110 | HeartbeatMetaEventDTO( 111 | session.botId, 112 | currentTimeSeconds(), 113 | PluginStatusData( 114 | good = session.bot.isOnline, 115 | online = session.bot.isOnline 116 | ), 117 | session.settings.heartbeat.interval 118 | ).toJson(), 119 | settings.secret, 120 | false 121 | ) 122 | delay(session.settings.heartbeat.interval) 123 | } 124 | } 125 | } 126 | } 127 | } 128 | 129 | private suspend fun report( 130 | miraiApi: MiraiApi, 131 | url: String, 132 | botId: Long, 133 | json: String, 134 | secret: String, 135 | shouldHandleOperation: Boolean 136 | ) { 137 | try { 138 | val res = http.request { 139 | url(url) 140 | headers { 141 | append("User-Agent", "CQHttp/4.15.0") 142 | append("X-Self-ID", botId.toString()) 143 | secret.takeIf { it != "" }?.apply { 144 | append("X-Signature", getSha1Hash(json)) 145 | } 146 | } 147 | if (settings.timeout > 0) { 148 | timeout { socketTimeoutMillis = settings.timeout } 149 | } 150 | method = HttpMethod.Post 151 | body = TextContent(json, ContentType.Application.Json.withParameter("charset", "utf-8")) 152 | } 153 | if (res != "") logger.debug("收到上报响应 $res") 154 | if (shouldHandleOperation && res != null && res != "") { 155 | try { 156 | val respJson = Json.parseToJsonElement(res).jsonObject 157 | val sentJson = Json.parseToJsonElement(json).jsonObject 158 | val params = hashMapOf("context" to sentJson, "operation" to respJson) 159 | miraiApi.handleQuickOperation(params) 160 | } catch (e: SerializationException) { 161 | logger.error("解析HTTP上报返回数据成json失败") 162 | } 163 | } 164 | } catch (e: Exception) { 165 | if (e is SocketTimeoutException) { 166 | logger.warning("HTTP上报超时") 167 | } else { 168 | logger.error(e) 169 | } 170 | } 171 | } 172 | 173 | private fun getSha1Hash(content: String): String { 174 | sha1Util?.apply { 175 | return "sha1=" + this.doFinal(content.toByteArray()).fold("", { str, it -> str + "%02x".format(it) }) 176 | } 177 | return "" 178 | } 179 | 180 | suspend fun close() { 181 | if (settings.postUrl != "") { 182 | report( 183 | session.apiImpl, 184 | settings.postUrl, 185 | session.bot.id, 186 | LifecycleMetaEventDTO(session.botId, "disable", currentTimeSeconds()).toJson(), 187 | settings.secret, 188 | false 189 | ) 190 | } 191 | http.close() 192 | heartbeatJob?.cancel() 193 | subscription?.let { session.unsubscribeEvent(it.first, it.second) } 194 | } 195 | } -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/util/Music.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * Mirai Native 4 | * 5 | * Copyright (C) 2020 iTX Technologies 6 | * 7 | * This program is free software: you can redistribute it and/or modify 8 | * it under the terms of the GNU Affero General Public License as 9 | * published by the Free Software Foundation, either version 3 of the 10 | * License, or (at your option) any later version. 11 | * 12 | * This program is distributed in the hope that it will be useful, 13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | * GNU Affero General Public License for more details. 16 | * 17 | * You should have received a copy of the GNU Affero General Public License 18 | * along with this program. If not, see . 19 | * 20 | * @author PeratX 21 | * @website https://github.com/iTXTech/mirai-native 22 | * 23 | */ 24 | 25 | package com.github.yyuueexxiinngg.onebot.util 26 | 27 | import io.ktor.client.* 28 | import io.ktor.client.engine.okhttp.* 29 | import io.ktor.client.request.* 30 | import kotlinx.serialization.json.* 31 | import net.mamoe.mirai.message.data.Message 32 | import net.mamoe.mirai.message.data.MusicKind 33 | import net.mamoe.mirai.message.data.MusicShare 34 | import net.mamoe.mirai.message.data.SimpleServiceMessage 35 | import net.mamoe.mirai.utils.MiraiExperimentalApi 36 | 37 | abstract class MusicProvider { 38 | val http = HttpClient(OkHttp) { 39 | engine { 40 | config { 41 | retryOnConnectionFailure(true) 42 | } 43 | } 44 | } 45 | 46 | abstract suspend fun send(id: String): Message 47 | } 48 | 49 | object Music { 50 | @OptIn(MiraiExperimentalApi::class) 51 | fun custom(url: String, audio: String, title: String, content: String?, image: String?): Message { 52 | return xmlMessage( 53 | "" + 54 | "" + 57 | "" 62 | ) 63 | } 64 | } 65 | 66 | object QQMusic : MusicProvider() { 67 | suspend fun search(name: String, page: Int, cnt: Int): JsonElement { 68 | val result = 69 | http.get("https://c.y.qq.com/soso/fcgi-bin/client_search_cp?aggr=1&cr=1&flag_qc=0&p=$page&n=$cnt&w=$name") 70 | return Json.parseToJsonElement(result.substring(8, result.length - 1)) 71 | } 72 | 73 | suspend fun getPlayUrl(mid: String): String { 74 | val result = http.get( 75 | "https://c.y.qq.com/base/fcgi-bin/fcg_music_express_mobile3.fcg?&jsonpCallback=MusicJsonCallback&cid=205361747&songmid=" + 76 | mid + "&filename=C400" + mid + ".m4a&guid=7549058080" 77 | ) 78 | val json = 79 | Json.parseToJsonElement(result).jsonObject.getValue("data").jsonObject.getValue("items").jsonArray[0].jsonObject 80 | if (json["subcode"]?.jsonPrimitive?.int == 0) { 81 | return "http://aqqmusic.tc.qq.com/amobile.music.tc.qq.com/C400$mid.m4a?guid=7549058080&vkey=${json["vkey"]!!.jsonPrimitive.content}&uin=0&fromtag=38" 82 | } 83 | return "" 84 | } 85 | 86 | suspend fun getSongInfo(id: String = "", mid: String = ""): JsonObject { 87 | val result = http.get( 88 | "https://u.y.qq.com/cgi-bin/musicu.fcg?format=json&inCharset=utf8&outCharset=utf-8¬ice=0&" + 89 | "platform=yqq.json&needNewCode=0&data=" + 90 | "{%22comm%22:{%22ct%22:24,%22cv%22:0},%22songinfo%22:{%22method%22:%22get_song_detail_yqq%22,%22param%22:" + 91 | "{%22song_type%22:0,%22song_mid%22:%22$mid%22,%22song_id%22:$id},%22module%22:%22music.pf_song_detail_svr%22}}" 92 | ) 93 | return Json.parseToJsonElement(result).jsonObject.getValue("songinfo").jsonObject.getValue("data").jsonObject 94 | } 95 | 96 | fun toXmlMessage( 97 | song: String, 98 | singer: String, 99 | songId: String, 100 | albumId: String, 101 | playUrl: String 102 | ): Message { 103 | return xmlMessage( 104 | "" + 105 | "" + 108 | "" 113 | ) 114 | } 115 | 116 | override suspend fun send(id: String): Message { 117 | val info = getSongInfo(id) 118 | val trackInfo = info.getValue("track_info").jsonObject 119 | val url = getPlayUrl(trackInfo.getValue("file").jsonObject["media_mid"]!!.jsonPrimitive.content) 120 | return MusicShare( 121 | MusicKind.QQMusic, 122 | trackInfo["name"]!!.jsonPrimitive.content, 123 | trackInfo.getValue("singer").jsonArray[0].jsonObject["name"]!!.jsonPrimitive.content, 124 | "https://i.y.qq.com/v8/playsong.html?_wv=1&songid=$id&souce=qqshare&source=qqshare&ADTAG=qqshare", 125 | "http://imgcache.qq.com/music/photo/album_500/${id.substring(id.length - 2)}/500_albumpic_${id}_0.jpg", 126 | url 127 | ) 128 | } 129 | } 130 | 131 | object NeteaseMusic : MusicProvider() { 132 | suspend fun getSongInfo(id: String = ""): JsonObject { 133 | val result = http.get("http://music.163.com/api/song/detail/?id=$id&ids=%5B$id%5D") 134 | return Json.parseToJsonElement(result).jsonObject.getValue("songs").jsonArray[0].jsonObject 135 | } 136 | 137 | fun toXmlMessage(song: String, singer: String, songId: String, coverUrl: String): SimpleServiceMessage { 138 | return xmlMessage( 139 | "" + 140 | "" + 143 | "" 148 | ) 149 | } 150 | 151 | override suspend fun send(id: String): Message { 152 | val info = getSongInfo(id) 153 | val song = info.getValue("name").jsonPrimitive.content 154 | val artists = info.getValue("artists").jsonArray 155 | val albumInfo = info.getValue("album").jsonObject 156 | 157 | return MusicShare( 158 | MusicKind.NeteaseCloudMusic, 159 | song, 160 | artists[0].jsonObject.getValue("name").jsonPrimitive.content, 161 | "http://music.163.com/m/song/$id", 162 | "http://imgcache.qq.com/music/photo/album_500/${id.substring(id.length - 2)}/500_albumpic_${id}_0.jpg", 163 | albumInfo.getValue("picUrl").jsonPrimitive.content, 164 | "[分享] $song" 165 | ) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/util/EventFilter.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot.util 2 | 3 | import com.github.yyuueexxiinngg.onebot.PluginBase 4 | import com.github.yyuueexxiinngg.onebot.logger 5 | import kotlinx.serialization.json.* 6 | import java.io.File 7 | 8 | class EventFilter { 9 | companion object { 10 | private var filter: Filter? = null 11 | 12 | fun init() { 13 | logger.debug("Initializing event filter...") 14 | val filterConfigFile = File(PluginBase.configFolder, "filter.json") 15 | if (filterConfigFile.exists()) { 16 | logger.info("Found filter.json, trying to initialize event filter...") 17 | try { 18 | val filterConfig = Json.parseToJsonElement(filterConfigFile.readText()).jsonObject 19 | filter = constructOperator("and", filterConfig) 20 | } catch (e: Exception) { 21 | logger.warning(e) 22 | logger.warning("Error when initializing event filter, event filter will not take effect.") 23 | } 24 | } 25 | } 26 | 27 | fun eval(payload: String): Boolean { 28 | val json = Json.parseToJsonElement(payload) 29 | return filter?.eval(json) ?: true 30 | } 31 | 32 | interface Filter { 33 | fun eval(payload: JsonElement): Boolean 34 | } 35 | 36 | class FilterSyntaxError(message: String) : Exception(message) 37 | 38 | class OperationNode(val key: String, val filter: Filter) 39 | 40 | class NotOperator(argument: JsonElement) : Filter { 41 | private var _operand: Filter 42 | 43 | init { 44 | if (argument !is JsonObject) { 45 | throw FilterSyntaxError("the argument of 'not' operator must be an object") 46 | } 47 | _operand = constructOperator("and", argument) 48 | } 49 | 50 | override fun eval(payload: JsonElement): Boolean { 51 | return !_operand.eval(payload) 52 | } 53 | } 54 | 55 | class AndOperator(argument: JsonElement) : Filter { 56 | private var _operands: MutableList = mutableListOf() 57 | 58 | init { 59 | if (argument !is JsonObject) { 60 | throw FilterSyntaxError("the argument of 'and' operator must be an object") 61 | } 62 | 63 | argument.jsonObject.forEach { 64 | val key = it.key 65 | val value = it.value 66 | 67 | if (key.isEmpty()) return@forEach 68 | 69 | when { 70 | key.startsWith(".") -> { 71 | // is an operator 72 | // ".foo": { 73 | // "bar": "baz" 74 | // } 75 | _operands.add(OperationNode("", constructOperator(key.substring(1), value))) 76 | } 77 | value is JsonObject -> { 78 | // is an normal key with an object as the value 79 | // "foo": { 80 | // ".bar": "baz" 81 | // } 82 | _operands.add(OperationNode(key, constructOperator("and", value))) 83 | } 84 | else -> { 85 | // is an normal key with a non-object as the value 86 | // "foo": "bar" 87 | _operands.add(OperationNode(key, constructOperator("eq", value))) 88 | } 89 | } 90 | } 91 | } 92 | 93 | override fun eval(payload: JsonElement): Boolean { 94 | var res = true 95 | 96 | _operands.forEach { 97 | res = if (it.key.isEmpty()) { 98 | res && it.filter.eval(payload) 99 | } else { 100 | try { 101 | val subPayload = payload.jsonObject[it.key]!! 102 | res && it.filter.eval(subPayload) 103 | } catch (e: Exception) { 104 | false 105 | } 106 | } 107 | 108 | if (!res) { 109 | return res 110 | } 111 | } 112 | 113 | return res 114 | } 115 | 116 | } 117 | 118 | class OrOperator(argument: JsonElement) : Filter { 119 | private var _operands: MutableList = mutableListOf() 120 | 121 | init { 122 | if (argument !is JsonArray) { 123 | throw FilterSyntaxError("the argument of 'or' operator must be an array") 124 | } 125 | argument.jsonArray.forEach { 126 | _operands.add(constructOperator("and", it)) 127 | } 128 | } 129 | 130 | override fun eval(payload: JsonElement): Boolean { 131 | var res = false 132 | _operands.forEach { 133 | res = res || it.eval(payload) 134 | 135 | if (res) return res 136 | } 137 | 138 | return res 139 | } 140 | 141 | } 142 | 143 | class EqualOperator(private val argument: JsonElement) : Filter { 144 | override fun eval(payload: JsonElement): Boolean { 145 | return argument == payload 146 | } 147 | } 148 | 149 | class NotEqualOperator(private val argument: JsonElement) : Filter { 150 | override fun eval(payload: JsonElement): Boolean { 151 | return argument != payload 152 | } 153 | } 154 | 155 | class InOperator(private val argument: JsonElement) : Filter { 156 | init { 157 | if (!(argument is JsonPrimitive || argument is JsonArray)) { 158 | throw FilterSyntaxError("the argument of 'in' operator must be a string or an array") 159 | } 160 | } 161 | 162 | override fun eval(payload: JsonElement): Boolean { 163 | if (argument is JsonPrimitive) { 164 | return payload is JsonPrimitive && payload.isString && payload.content.contains(argument.content) 165 | } 166 | 167 | if (argument is JsonArray) { 168 | return argument.find { it.jsonPrimitive.content == payload.jsonPrimitive.content } != null 169 | } 170 | 171 | return false 172 | } 173 | } 174 | 175 | class ContainsOperator(private val argument: JsonElement) : Filter { 176 | init { 177 | if (!(argument is JsonPrimitive && argument.isString)) { 178 | throw FilterSyntaxError("the argument of 'contains' operator must be a string") 179 | } 180 | } 181 | 182 | override fun eval(payload: JsonElement): Boolean { 183 | return if (!(payload is JsonPrimitive && payload.isString)) { 184 | false 185 | } else { 186 | payload.content.contains(argument.jsonPrimitive.content) 187 | } 188 | } 189 | } 190 | 191 | class RegexOperator(private val argument: JsonElement) : Filter { 192 | init { 193 | if (!(argument is JsonPrimitive && argument.isString)) { 194 | throw FilterSyntaxError("the argument of 'regex' operator must be a string") 195 | } 196 | } 197 | 198 | override fun eval(payload: JsonElement): Boolean { 199 | return if (!(payload is JsonPrimitive && payload.isString)) { 200 | false 201 | } else { 202 | Regex(argument.jsonPrimitive.content).find(payload.content) != null 203 | } 204 | } 205 | } 206 | 207 | private fun constructOperator(opName: String, argument: JsonElement): Filter { 208 | return when (opName) { 209 | "not" -> NotOperator(argument) 210 | "and" -> AndOperator(argument) 211 | "or" -> OrOperator(argument) 212 | "eq" -> EqualOperator(argument) 213 | "neq" -> NotEqualOperator(argument) 214 | "in" -> InOperator(argument) 215 | "contains" -> ContainsOperator(argument) 216 | "regex" -> RegexOperator(argument) 217 | else -> throw FilterSyntaxError("the operator `$opName` is not supported") 218 | } 219 | } 220 | } 221 | } -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/PluginBase.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot 2 | 3 | import com.github.yyuueexxiinngg.onebot.SessionManager.allSession 4 | import com.github.yyuueexxiinngg.onebot.SessionManager.closeSession 5 | import com.github.yyuueexxiinngg.onebot.util.* 6 | import com.github.yyuueexxiinngg.onebot.util.HttpClient.Companion.initHTTPClientProxy 7 | import com.google.auto.service.AutoService 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.async 10 | import kotlinx.coroutines.withContext 11 | import net.mamoe.mirai.Bot 12 | import net.mamoe.mirai.LowLevelApi 13 | import net.mamoe.mirai.console.plugin.jvm.JvmPlugin 14 | import net.mamoe.mirai.console.plugin.jvm.JvmPluginDescription 15 | import net.mamoe.mirai.console.plugin.jvm.KotlinPlugin 16 | import net.mamoe.mirai.contact.Friend 17 | import net.mamoe.mirai.contact.Group 18 | import net.mamoe.mirai.contact.Member 19 | import net.mamoe.mirai.event.Listener 20 | import net.mamoe.mirai.event.events.* 21 | import net.mamoe.mirai.event.globalEventChannel 22 | import net.mamoe.mirai.message.data.Image 23 | import net.mamoe.mirai.message.data.Image.Key.queryUrl 24 | import net.mamoe.mirai.message.data.OnlineAudio 25 | import net.mamoe.mirai.message.data.source 26 | import net.mamoe.mirai.utils.MiraiExperimentalApi 27 | import org.iq80.leveldb.DB 28 | import org.iq80.leveldb.Options 29 | import org.iq80.leveldb.impl.Iq80DBFactory 30 | import java.io.File 31 | 32 | val logger = PluginBase.logger 33 | 34 | @AutoService(JvmPlugin::class) 35 | object PluginBase : KotlinPlugin( 36 | JvmPluginDescription( 37 | id = "com.github.yyuueexxiinngg.onebot", 38 | version = BuildConfig.VERSION, 39 | ) { 40 | name("OneBot") 41 | author("yyuueexxiinngg") 42 | info("OneBot Standard Kotlin implementation. ") 43 | } 44 | ) { 45 | private var initialSubscription: Listener? = null 46 | internal var db: DB? = null 47 | 48 | 49 | @OptIn(LowLevelApi::class, MiraiExperimentalApi::class) 50 | override fun onEnable() { 51 | PluginSettings.reload() 52 | 53 | if (PluginSettings.db.enable) { 54 | val options = Options().createIfMissing(true) 55 | db = Iq80DBFactory().open(File("$dataFolderPath/db"), options) 56 | } 57 | 58 | logger.info("Plugin loaded! ${BuildConfig.VERSION}") 59 | logger.info("插件当前Commit 版本: ${BuildConfig.COMMIT_HASH}") 60 | EventFilter.init() 61 | logger.debug("开发交流群: 1143274864") 62 | initHTTPClientProxy() 63 | Bot.instances.forEach { 64 | if (!allSession.containsKey(it.id)) { 65 | if (PluginSettings.bots?.containsKey(it.id.toString()) == true) { 66 | PluginSettings.bots!![it.id.toString()]?.let { settings -> 67 | SessionManager.createBotSession( 68 | it, 69 | settings 70 | ) 71 | } 72 | } else { 73 | logger.debug("${it.id}未对OneBot进行配置") 74 | } 75 | } else { 76 | logger.debug("${it.id}已存在") 77 | } 78 | } 79 | 80 | initialSubscription = 81 | globalEventChannel().subscribeAlways( 82 | BotEvent::class 83 | ) { 84 | allSession[bot.id]?.let { 85 | (it as BotSession).triggerEvent(this) 86 | } 87 | 88 | when (this) { 89 | is BotOnlineEvent -> { 90 | if (!allSession.containsKey(bot.id)) { 91 | if (PluginSettings.bots?.containsKey(bot.id.toString()) == true) { 92 | PluginSettings.bots!![bot.id.toString()]?.let { settings -> 93 | SessionManager.createBotSession( 94 | bot, 95 | settings 96 | ) 97 | } 98 | } else { 99 | logger.debug("${bot.id}未对OneBot进行配置") 100 | } 101 | } 102 | } 103 | is NewFriendRequestEvent -> { 104 | allSession[bot.id]?.let { 105 | (it as BotSession).apiImpl.cacheRequestQueue.add(this) 106 | } 107 | } 108 | is MemberJoinRequestEvent -> { 109 | allSession[bot.id]?.let { 110 | (it as BotSession).apiImpl.cacheRequestQueue.add(this) 111 | } 112 | } 113 | is BotInvitedJoinGroupRequestEvent -> { 114 | allSession[bot.id]?.let { 115 | (it as BotSession).apiImpl.cacheRequestQueue.add(this) 116 | } 117 | } 118 | is MessageEvent -> { 119 | allSession[bot.id]?.let { s -> 120 | saveMessageToDB() 121 | val session = s as BotSession 122 | 123 | if (this is GroupMessageEvent) { 124 | session.apiImpl.cachedSourceQueue.add(message.source) 125 | } 126 | 127 | if (this is GroupTempMessageEvent) { 128 | session.apiImpl.cachedTempContact[this.sender.id] = this.group.id 129 | } 130 | 131 | if (session.settings.cacheImage) { 132 | message.filterIsInstance().forEach { image -> 133 | val delegate = image::class.members.find { it.name == "delegate" }?.call(image) 134 | var imageSize = 0 135 | val imageMD5: String = 136 | (delegate?.let { _delegate -> _delegate::class.members.find { it.name == "picMd5" } } 137 | ?.call(delegate) as ByteArray?)?.toUHexString("") ?: "" 138 | 139 | when (subject) { 140 | is Member, is Friend -> { 141 | val imageHeight = 142 | delegate?.let { _delegate -> _delegate::class.members.find { it.name == "picHeight" } } 143 | ?.call(delegate) as Int? 144 | val imageWidth = 145 | delegate?.let { _delegate -> _delegate::class.members.find { it.name == "picWidth" } } 146 | ?.call(delegate) as Int? 147 | 148 | if (imageHeight != null && imageWidth != null) { 149 | imageSize = imageHeight * imageWidth 150 | } 151 | } 152 | is Group -> { 153 | imageSize = 154 | (delegate?.let { _delegate -> _delegate::class.members.find { it.name == "size" } } 155 | ?.call(delegate) as Int?) ?: 0 156 | } 157 | } 158 | 159 | val imgMetaContent = constructCacheImageMeta( 160 | imageMD5, 161 | imageSize, 162 | image.queryUrl(), 163 | getImageType(image) 164 | ) 165 | 166 | saveImageAsync("${image.imageId}.cqimg", imgMetaContent).start() 167 | } 168 | } 169 | 170 | if (session.settings.cacheRecord) { 171 | message.filterIsInstance().forEach { voice -> 172 | val voiceUrl = voice.urlForDownload 173 | val voiceBytes = voiceUrl.let { HttpClient.getBytes(it) } 174 | if (voiceBytes != null) { 175 | saveRecordAsync( 176 | "${voice.fileMd5.toUHexString("")}.cqrecord", 177 | voiceBytes 178 | ).start() 179 | } 180 | } 181 | } 182 | } 183 | } 184 | } 185 | } 186 | } 187 | 188 | override fun onDisable() { 189 | initialSubscription?.complete() 190 | allSession.forEach { (sessionId, _) -> closeSession(sessionId) } 191 | logger.info("OneBot 已关闭") 192 | } 193 | 194 | private val imageFold: File by lazy { 195 | File(dataFolder, "image").apply { mkdirs() } 196 | } 197 | private val recordFold: File by lazy { 198 | File(dataFolder, "record").apply { mkdirs() } 199 | } 200 | 201 | internal fun image(imageName: String) = File(imageFold, imageName) 202 | internal fun record(recordName: String) = File(recordFold, recordName) 203 | 204 | fun saveRecordAsync(name: String, data: ByteArray) = 205 | async { 206 | record(name).apply { writeBytes(data) } 207 | } 208 | 209 | fun saveImageAsync(name: String, data: ByteArray) = 210 | async { 211 | image(name).apply { writeBytes(data) } 212 | } 213 | 214 | suspend fun saveImage(name: String, data: ByteArray) = withContext(Dispatchers.IO) { 215 | image(name).apply { writeBytes(data) } 216 | } 217 | 218 | fun saveImageAsync(name: String, data: String) = 219 | async { 220 | image(name).apply { writeText(data) } 221 | } 222 | } -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/data/common/MessageDTO.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Mamoe Technologies and contributors. 3 | * 4 | * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. 5 | * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link. 6 | * 7 | * https://github.com/mamoe/mirai/blob/master/LICENSE 8 | */ 9 | 10 | package com.github.yyuueexxiinngg.onebot.data.common 11 | 12 | import com.github.yyuueexxiinngg.onebot.logger 13 | import com.github.yyuueexxiinngg.onebot.util.currentTimeSeconds 14 | import com.github.yyuueexxiinngg.onebot.util.toCQString 15 | import com.github.yyuueexxiinngg.onebot.util.toMessageId 16 | import kotlinx.serialization.KSerializer 17 | import kotlinx.serialization.SerialName 18 | import kotlinx.serialization.Serializable 19 | import kotlinx.serialization.builtins.ListSerializer 20 | import kotlinx.serialization.builtins.serializer 21 | import kotlinx.serialization.descriptors.SerialDescriptor 22 | import kotlinx.serialization.encoding.Decoder 23 | import kotlinx.serialization.encoding.Encoder 24 | import net.mamoe.mirai.contact.AnonymousMember 25 | import net.mamoe.mirai.event.events.FriendMessageEvent 26 | import net.mamoe.mirai.event.events.GroupMessageEvent 27 | import net.mamoe.mirai.event.events.GroupTempMessageEvent 28 | import net.mamoe.mirai.event.events.MessageEvent 29 | import net.mamoe.mirai.message.data.* 30 | import net.mamoe.mirai.message.data.Image.Key.queryUrl 31 | import net.mamoe.mirai.utils.MiraiInternalApi 32 | 33 | /* 34 | * DTO data class 35 | * */ 36 | 37 | // MessagePacket 38 | @Serializable 39 | @SerialName("GroupMessage") 40 | data class GroupMessagePacketDTO( 41 | override var self_id: Long, 42 | val sub_type: String, // normal、anonymous、notice 43 | val message_id: Int, 44 | val group_id: Long, 45 | val user_id: Long, 46 | val anonymous: AnonymousMemberDTO?, 47 | var message: MessageChainOrStringDTO, // Can be messageChainDTO or string depending on config 48 | val raw_message: String, 49 | val font: Int, 50 | val sender: MemberDTO, 51 | override var time: Long 52 | ) : EventDTO() { 53 | override var post_type: String = "message" 54 | val message_type: String = "group" 55 | } 56 | 57 | @Serializable 58 | @SerialName("PrivateMessage") 59 | data class PrivateMessagePacketDTO( 60 | override var self_id: Long, 61 | val sub_type: String, // friend、group、discuss、other 62 | val message_id: Int, 63 | val user_id: Long, 64 | val message: MessageChainOrStringDTO, // Can be messageChainDTO or string depending on config 65 | val raw_message: String, 66 | val font: Int, 67 | val sender: QQDTO, 68 | override var time: Long 69 | ) : EventDTO() { 70 | override var post_type: String = "message" 71 | val message_type: String = "private" 72 | } 73 | 74 | // Message DTO 75 | @Serializable 76 | @SerialName("Plain") 77 | data class PlainDTO(val data: PlainData, val type: String = "text") : MessageDTO() 78 | 79 | @Serializable 80 | data class PlainData(val text: String) 81 | 82 | 83 | @Serializable 84 | @SerialName("At") 85 | data class AtDTO(val data: AtData, val type: String = "at") : MessageDTO() 86 | 87 | @Serializable 88 | data class AtData(val qq: String) 89 | 90 | 91 | @Serializable 92 | @SerialName("Face") 93 | data class FaceDTO(val data: FaceData, val type: String = "face") : MessageDTO() 94 | 95 | @Serializable 96 | data class FaceData(val id: String = "-1") 97 | 98 | @Serializable 99 | @SerialName("Image") 100 | data class ImageDTO(val data: ImageData, val type: String = "image") : MessageDTO() 101 | 102 | @Serializable 103 | data class ImageData( 104 | val file: String? = null, 105 | val url: String? = null, 106 | val type: String? = null 107 | ) 108 | 109 | @Serializable 110 | @SerialName("Poke") 111 | data class PokeMessageDTO(val data: PokeData, val type: String = "poke") : MessageDTO() 112 | 113 | @Serializable 114 | data class PokeData(val name: String) 115 | 116 | @Serializable 117 | @SerialName("Unknown") 118 | object UnknownMessageDTO : MessageDTO() 119 | 120 | // Only used when deserialize 121 | @Serializable 122 | @SerialName("AtAll") 123 | data class AtAllDTO(val target: Long = 0) : MessageDTO() // target为保留字段 124 | 125 | @Serializable 126 | @SerialName("Xml") 127 | data class XmlDTO(val data: XmlData, val type: String = "xml") : MessageDTO() 128 | 129 | @Serializable 130 | data class XmlData(val data: String) 131 | 132 | @Serializable 133 | @SerialName("App") 134 | data class AppDTO(val data: AppData, val type: String = "json") : MessageDTO() 135 | 136 | @Serializable 137 | data class AppData(val data: String) 138 | 139 | @Serializable 140 | @SerialName("Json") 141 | data class JsonDTO(val data: JsonData, val type: String = "json") : MessageDTO() 142 | 143 | @Serializable 144 | data class JsonData(val data: String) 145 | 146 | @Serializable 147 | @SerialName("Reply") 148 | data class ReplyDTO(val data: ReplyData, val type: String = "reply") : MessageDTO() 149 | 150 | @Serializable 151 | data class ReplyData(val id: Int) 152 | 153 | /*@Serializable 154 | @SerialName("Source") 155 | data class MessageSourceDTO(val id: Int, val time: Int) : MessageDTO()*/ 156 | 157 | /** 158 | * Hacky way to get message chain can be both String or List 159 | */ 160 | @Serializable(with = MessageChainOrStringDTO.Companion::class) 161 | sealed class MessageChainOrStringDTO { 162 | companion object : KSerializer { 163 | override val descriptor: SerialDescriptor 164 | get() = String.serializer().descriptor 165 | 166 | override fun deserialize(decoder: Decoder): MessageChainOrStringDTO { 167 | error("Not implemented") 168 | } 169 | 170 | override fun serialize(encoder: Encoder, value: MessageChainOrStringDTO) { 171 | when (value) { 172 | is WrappedMessageChainString -> { 173 | String.serializer().serialize(encoder, value.value) 174 | } 175 | is WrappedMessageChainList -> { 176 | WrappedMessageChainList.serializer().serialize(encoder, value) 177 | } 178 | } 179 | } 180 | } 181 | } 182 | 183 | @Serializable(with = WrappedMessageChainString.Companion::class) 184 | data class WrappedMessageChainString( 185 | var value: String 186 | ) : MessageChainOrStringDTO() { 187 | companion object : KSerializer { 188 | override val descriptor: SerialDescriptor 189 | get() = String.serializer().descriptor 190 | 191 | override fun deserialize(decoder: Decoder): WrappedMessageChainString { 192 | return WrappedMessageChainString(String.serializer().deserialize(decoder)) 193 | } 194 | 195 | override fun serialize(encoder: Encoder, value: WrappedMessageChainString) { 196 | return String.serializer().serialize(encoder, value.value) 197 | } 198 | } 199 | } 200 | 201 | @Serializable(with = WrappedMessageChainList.Companion::class) 202 | data class WrappedMessageChainList( 203 | var value: List 204 | ) : MessageChainOrStringDTO() { 205 | companion object : KSerializer { 206 | override val descriptor: SerialDescriptor 207 | get() = String.serializer().descriptor 208 | 209 | override fun deserialize(decoder: Decoder): WrappedMessageChainList { 210 | error("Not implemented") 211 | } 212 | 213 | override fun serialize(encoder: Encoder, value: WrappedMessageChainList) { 214 | return ListSerializer(MessageDTO.serializer()).serialize(encoder, value.value) 215 | } 216 | } 217 | } 218 | 219 | @Serializable 220 | sealed class MessageDTO : DTO 221 | 222 | /* 223 | Extend function 224 | */ 225 | suspend fun MessageEvent.toDTO(isRawMessage: Boolean = false): EventDTO { 226 | val rawMessage = WrappedMessageChainString("") 227 | message.forEach { rawMessage.value += it.toCQString() } 228 | return when (this) { 229 | is GroupMessageEvent -> GroupMessagePacketDTO( 230 | self_id = bot.id, 231 | sub_type = if (sender is AnonymousMember) "anonymous" else "normal", 232 | message_id = message.internalId.toMessageId(bot.id, source.fromId), 233 | group_id = group.id, 234 | user_id = sender.id, 235 | anonymous = if (sender is AnonymousMember) AnonymousMemberDTO(sender as AnonymousMember) else null, 236 | message = if (isRawMessage) rawMessage else message.toMessageChainDTO { it != UnknownMessageDTO }, 237 | raw_message = rawMessage.value, 238 | font = 0, 239 | sender = MemberDTO(sender), 240 | time = currentTimeSeconds() 241 | ) 242 | is FriendMessageEvent -> PrivateMessagePacketDTO( 243 | self_id = bot.id, 244 | sub_type = "friend", 245 | message_id = message.internalId.toMessageId(bot.id, source.fromId), 246 | user_id = sender.id, 247 | message = if (isRawMessage) rawMessage else message.toMessageChainDTO { it != UnknownMessageDTO }, 248 | raw_message = rawMessage.value, 249 | font = 0, 250 | sender = QQDTO(sender), 251 | time = currentTimeSeconds() 252 | ) 253 | is GroupTempMessageEvent -> PrivateMessagePacketDTO( 254 | self_id = bot.id, 255 | sub_type = "group", 256 | message_id = message.internalId.toMessageId(bot.id, source.fromId), 257 | user_id = sender.id, 258 | message = if (isRawMessage) rawMessage else message.toMessageChainDTO { it != UnknownMessageDTO }, 259 | raw_message = rawMessage.value, 260 | font = 0, 261 | sender = QQDTO(sender), 262 | time = currentTimeSeconds() 263 | ) 264 | else -> IgnoreEventDTO(sender.id) 265 | } 266 | } 267 | 268 | suspend inline fun MessageChain.toMessageChainDTO(filter: (MessageDTO) -> Boolean): WrappedMessageChainList { 269 | val messages = this 270 | return WrappedMessageChainList(mutableListOf().apply { 271 | messages.forEach { content -> content.toDTO().takeIf { filter(it) }?.let(::add) } 272 | }) 273 | } 274 | 275 | @OptIn(MiraiInternalApi::class) 276 | suspend fun Message.toDTO() = when (this) { 277 | is At -> AtDTO(AtData(target.toString())) 278 | is AtAll -> AtDTO(AtData("0")) 279 | is Face -> FaceDTO(FaceData(id.toString())) 280 | is PlainText -> PlainDTO(PlainData(content)) 281 | is Image -> ImageDTO(ImageData(imageId, queryUrl())) 282 | is FlashImage -> ImageDTO(ImageData(image.imageId, image.queryUrl(), "flash")) 283 | is ServiceMessage -> 284 | with(content) { 285 | when { 286 | contains("xml version") -> XmlDTO(XmlData(content)) 287 | else -> JsonDTO(JsonData(content)) 288 | } 289 | } 290 | is LightApp -> AppDTO(AppData(content)) 291 | is QuoteReply -> ReplyDTO(ReplyData(source.internalIds.toMessageId(source.botId, source.fromId))) 292 | is PokeMessage -> PokeMessageDTO(PokeData(name)) 293 | is MessageSource -> UnknownMessageDTO 294 | else -> { 295 | logger.debug("收到未支持消息: $this") 296 | UnknownMessageDTO 297 | } 298 | } 299 | /* 300 | @OptIn(MiraiInternalApi::class, MiraiExperimentalApi::class) 301 | suspend fun MessageDTO.toMessage(contact: Contact) = when (this) { 302 | is CQAtDTO -> (contact as Group)[data.qq]?.let { At(it) } 303 | is AtAllDTO -> AtAll 304 | is CQFaceDTO -> when { 305 | data.id >= 0 -> Face(data.id) 306 | else -> Face(255) 307 | } 308 | is CQPlainDTO -> PlainText(data.text) 309 | is CQImageDTO -> when { 310 | !data.file.isNullOrBlank() -> Image(data.file) 311 | !data.url.isNullOrBlank() -> withContext(Dispatchers.IO) { URL(data.url).openStream().uploadAsImage(contact) } 312 | else -> null 313 | } 314 | is XmlDTO -> SimpleServiceMessage(60, data.data) 315 | is JsonDTO -> SimpleServiceMessage(1, data.data) 316 | is AppDTO -> LightApp(data.data) 317 | is CQPokeMessageDTO -> PokeMap[data.name] 318 | // ignore 319 | is UnknownMessageDTO 320 | -> null 321 | }*/ 322 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## `0.3.5` 2 | 3 | - 适配`mirai` 2.9.2 4 | - 修复`mirai` 2.4.0后图片缓存导致的错误 (by @Metric-Void) 5 | - `get_stranger_info`现支持获取陌生人名片 6 | - 修复错误的array消息上报中错误的数据类型 #127 7 | - 修复临时会话消息错误上报为群消息 #133 8 | - 修复`set_group_ban`无法接触禁言的问题 #149 9 | - 支持接收群组匿名消息 10 | - 支持禁言匿名群成员 `set_group_anonymous_ban` 11 | - 支持获取消息 `get_msg` 12 | - 支持`mirai`2.1.0 音乐分享卡片消息 13 | - 支持音乐自定义分享 #149 14 | - 支持接收群成员荣誉变更事件 (`mriai`2.1.0暂仅支持龙王事件) 15 | 16 | ## `0.3.4` *2021/01/13* 17 | 18 | - 适配`mirai` 2.0-RC 19 | - `get_friend_list`现支持返回好友备注 20 | - `get_stranger_info`极有限支持, 仅返回用户昵称, 且仅支持获取好友或存在于`Bot`所在某群中的成员 [相关Issue](https://github.com/mamoe/mirai/issues/234) 21 | - OneBot Kotlin: 系统变量更名 #117 22 | - `onebot.backend`->`ONEBOT_BACKEND` 23 | - `onebot.account`->`ONEBOT_ACCOUNT` 24 | - `onebot.password`->`ONEBOT_PASSWORD` 25 | 26 | ## `0.3.3` *2020/11/28* 27 | 28 | - **修复反向Websocket客户端概率出现未清除会话导致无法重连的问题** #81 29 | - 再次修复`xml`以及`json`消息的字段不正确 #112 30 | - 支持接收及发送闪照 #114 31 | - 支持通过`json`发送程序分享富文本消息, 类似 ```{\"app\":\"com.tencent.weather\", ....``` 32 | - 支持接收群组及好友消息撤回事件 *(获取消息接口尚未支持)* 33 | - HTTP上报服务支持超时, `http`配置项中增加`timeout`配置 #113 34 | - 富文本消息段类型跟随OneBot标准使用`json`, `xml`, 弃用`rich` 35 | 36 | ## `0.3.2` *2020/11/23* 37 | ### **修复因合并`embedded`分支而在`0.3.1`中引入的`Array`格式消息上报序列化格式错误** 38 | 39 | 此错误导致`Array`格式上报的使用者无法正常解析收取到的消息, **`String`格式上报的使用者不受影响** 40 | 41 | 修复`xml`以及`json`消息的字段不正确 42 | 43 | ## `0.3.1` *2020/11/22* 44 | - 优化事件处理机制 #109 45 | - 更新依赖`mirai-console`至`1.0.0`, [更新日志](https://github.com/mamoe/mirai-console/releases/tag/1.0.0), **现在聊天中`/help` 46 | 命令不会与`console`内建命令冲突了** #110 47 | - 新版`console`内置了简单修改日志打印等级的配置, 因此弃用自定义`Logger` 48 | - `OneBot`配置项中`debug`项作废, 修改此项不会产生任何作用 49 | - 开启Debug打印的配置请修改`console`本身的配置, 位于`config/Console/Logger.yml` 50 | - 可将`defaultPriority: INFO`修改为`defaultPriority: DEBUG`或以上开启所有**mirai及所有插件**的Debug日志输出 51 | - **或在`loggers`项下新增`OneBot: DEBUG`或以上单独开启本插件的Debug日志输出** 52 | 53 | ## `0.3.0` *2020/11/16* 54 | - 项目更名: 55 | - 插件版更名为`OneBot Mirai`, `mirai-console`中名为`OneBot` 56 | - Embedded版更名为`OneBot Kotlin` 57 | - **适配`mirai-console 1.0`** #93 #99 #106 58 | - 新增: [事件过滤器](https://github.com/howmanybots/onebot/blob/master/legacy/EventFilter.md) 支持, 与原版行为不一致的地方: 59 | - 未增加`event_filter`配置项, 将`filter.json`放置在`config/OneBot/filter.json`既视为启用事件过滤 60 | - 若文件不存在, 或过滤规则语法错误, 则不会对事件进行过滤 61 | - 修复: 撤回他人消息出错 #55 #98 62 | - 修复: `send_msg` API中群聊与私聊逻辑判断问题 #105 63 | - 优化: 初次启动时自动生成样本配置文件 64 | - 更新依赖`mirai-core`至`1.3.3`, 插件版添加获取群荣誉API `get_group_honor_info`支持 65 | 66 | ### OneBot Kotlin - [分支](https://github.com/yyuueexxiinngg/cqhttp-mirai/tree/embedded) 67 | 68 | - 包含上述所有更新 69 | - **配置文件位置同步变更至`config/OneBot/settings.yml`** 70 | - 更新依赖`mirai-console`至`1.0-RC-1` 71 | - 替换前端为`mirai-console-terminal` 72 | - **同步`miraiOK`删除对`config.txt`的支持, 自动登录请修改`config/Console/AutoLogin.yml`使用`mirai-console`内建支持** 73 | - **用以自动登录的环境变量更名**: 74 | - `cqhttp.account` -> `onebot.account` 75 | - `cqhttp.password` -> `onebot.password` 76 | - 使用`--args -- --xx`传入参数至`mirai-console`, 如`--args -- --help`将`--help`传入获取`mirai-console`提供的帮助信息 77 | 78 | #### 注意事项: 79 | `mirai-console 1.0`后配置文件路径有所变化, 现在配置文件位于`config/OneBot/settings.yml` 80 | 81 | 插件数据文件夹位置 *(image, record等)* 同样有所变化, 现在位于`data/OneBot`文件夹下 82 | 83 | 并且配置项中将原来的各账号移动至`bots`配置项下, 现在格式如下 84 | ```yaml 85 | debug: true 86 | bots: 87 | 1234567890: 88 | ws_reverse: 89 | 0987654321: 90 | ws_reverse: 91 | ``` 92 | 93 | ## `0.2.3` *2020/08/27* 94 | 95 | - **修复: 反向WS客户端非`Universal`模式下`event`路由不保持长连接的问题**, 此BUG**导致所有非`Universal`模式接入的框架无法使用**( 96 | 如[cqhttp.Cyan](https://github.com/frank-bots/cqhttp.Cyan)) #69 97 | - **修复: 反向WS客户端添加`TLS`支持**, 需在配置文件`ws_reverse`中新增项`useTLS: true`以使用`TLS`建立连接, 配置文件详见README.md #42 98 | - 修复: HTTP上报服务启动时发送的`meta_event`未签名, 此BUG导致一些框架(如[PicqBotX](https://github.com/HyDevelop/PicqBotX))无法正常使用 #65 99 | - 修复: 心跳服务发送数据类型错误, 此BUG导致一些框架无法正常接收心跳数据包, (如[PicqBotX](https://github.com/HyDevelop/PicqBotX) 100 | , [cqhttp.Cyan](https://github.com/frank-bots/cqhttp.Cyan)) #70 101 | - **修复: 从链接发送语音时语音不完整的问题** #59 102 | - 修复: `get_version_info ` 103 | API返回值现在符合[OneBot标准](https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#get_version_info-%E8%8E%B7%E5%8F%96%E7%89%88%E6%9C%AC%E4%BF%A1%E6%81%AF)了 104 | #67 105 | - 其中`app_version`为当前版本, `app_build_version`为当前`Commit`版本 106 | - 修复: `set_group_name` 107 | API参数现在符合[OneBot标准](https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#set_group_name-%E8%AE%BE%E7%BD%AE%E7%BE%A4%E5%90%8D)了 108 | - 新增: 通过链接下载媒体时支持`timeout`配置 #61 举例: [CQ:image,cache=0,**timeout=5**,url=xxxxxx] 109 | - 新增: **通过链接下载媒体时支持`proxy`配置来通过代理下载**, 举例: [CQ:image,cache=0,**proxy=1**,url=xxxxxx], 需在配置文件中新增`proxy`项, 支持`HTTP`及`Sock` 110 | 代理, 配置文件详见README.md 111 | - 新增: 现在支持发送网易云音乐卡片了, 感谢 @F0ur 对[go-cqhttp](https://github.com/Mrs4s/go-cqhttp/)做出的贡献 112 | - 新增: `get_group_member_info` API支持设置`no_cache`, 此前`mirai`已会实时更新群员权限, 即不需要设置为`true`, `no_cache`选项仅适用于实时获取群员特殊头衔 113 | - 新增: 支持`get_image`和`get_record`API #60 , **需在配置中开启对应缓存**, 返回值中`file`指向媒体文件绝对路径, `file_type`为媒体实际类型, 未知类型返回`unknown` 114 | - `get_image`会**根据缓存下载图片** 115 | - `get_record`会返回已缓存语音 116 | 117 | ### Embedded版本 - [分支](https://github.com/yyuueexxiinngg/cqhttp-mirai/tree/embedded) 118 | 119 | - 包含上述所有更新 120 | - 优化: 现在读取config.txt自动登录时不会与传参和环境变量重复导致登录两次同一个`Bot`了 #64 121 | ~碎碎念: `mirai-console-1.0.0`已基本可用, 现在应该是基于`mirai-console-0.5.2`的最后一个大版本了 , 接下来重心是对`mirai-console-1.0.0`进行适配~ 122 | 123 | ## `0.2.2` *2020/08/20* 124 | 125 | #### 0.2.2.5 126 | 127 | - HTTP API服务器及正向Websocket服务器鉴权支持`Authorization`头 #58 128 | - `0.2.2.4`中引入的读取[go-cqhttp](https://github.com/Mrs4s/go-cqhttp/)的`.image`文件现在支持`JRE 1.8`而非`JRE 1.9`以上了 129 | - 现在调用`delete_msg`接口成功时不会错误返回报错了 130 | - 现在`Bot`被邀请进群及加群申请被通过后会正常触发`MemberJoinEvent`事件了, `user_id`与`Bot`相同 131 | - 现在支持接收及处理`Bot`被邀请加群事件了 132 | - 现在发送已缓存媒体时可带上后缀了, 如以下格式都支持: `image, file=XXXX`, `image, file=XXXX.cqimg` 133 | 134 | ##### 已知BUG 135 | - 使用`Embedded`版本并加载其他`mirai`插件后**无法正确读取`CQHTTPMirai`配置文件**导致无法正常使用, 此BUG**与`Embedded`版本初衷相违背**, 但由于`mirai-console 1.0.0`发布后配置文件读取逻辑需要重写, 故暂停此问题的修复 136 | 137 | #### 0.2.2.4 138 | - 优化Websocket反向客户端及服务端API处理逻辑, 现在调用耗时API(如下载大图再发送)时不会阻塞了, 具体例子为在`nonebot`中`您有命令正在执行,请稍后再试`不会在报错`WebSocket API call timeout`后才能发出 #15 139 | - 支持读取发送由[go-cqhttp](https://github.com/Mrs4s/go-cqhttp/)生成的图片`.image`缓存文件 140 | 141 | **因小版本不一定全为BUG修复, 今后小版本不再使用`-Fix*`方式进行命名** 142 | 143 | #### 0.2.2-Fix3 144 | 145 | - 普通 修复`BotEvent`的系列化问题, 此BUG在`0.2.2`尝试升级`kotlin serialization`时引入, 会导致插件使用者收不到各类Bot时间, 如`好友请求`, `群成员加群请求/退群事件`, `禁言事件`等 146 | ~~那么Fix3它来了~~ 147 | 148 | #### 0.2.2-Fix2 149 | 150 | - 普通 修复`get_group_info`, `get_group_member_list`API的参数解析错误, 举例: 此BUG会导致yobot无法获取群组和成员信息 151 | ~~希望没有Fix3~~ 152 | 153 | #### 0.2.2-Fix1 154 | 155 | - **严重** **修复尝试修复`.handle_quick_operation`API时对其引入的新BUG, 此BUG会导致只有在群里回复并AT发送人时才能正常解析消息** 156 | 影响范围广泛, HTTP上报服务#48, 反向WS客户端与Nonebot #49 157 | 158 | #### 0.2.2 159 | 160 | - **基于`mirai-core 1.2.1`, 与1.1.3不兼容** #45 161 | - **插件版现在也支持发送语音了** 162 | - 发送`amr`和`silk`格式的语音全平台可收听, 发送`mp3`, `m4a`, `aac`等格式语音只有手机端可收听 163 | - **修复`.handle_quick_operation`API中的消息解析错误**, 此错误导致无法使用`array`格式进行快速回复 #38 164 | - POST请求支持接收form-urlencoded #44 165 | - HTTP上报服务`Content-Type`中加入编码值, 此前一些较严框架无法收到上报消息 #37 166 | - 支持发送心跳包 #41 167 | - 心跳包默认不启用, 如需启用请在`Bot`设置中新增以下项 168 | 169 | ```yaml 170 | '123456789': 171 | heartbeat: 172 | enable: true 173 | interval: 15000 # 心跳发送间隔, 单位毫秒, 如不填写默认15000 174 | ``` 175 | 176 | ### Embedded版本 - [分支](https://github.com/yyuueexxiinngg/cqhttp-mirai/tree/embedded) 177 | 178 | - 包含上述所有更新 179 | - **增加获取群荣誉的API**, 如`龙王`, `群聊之火`, `快乐源泉`等, [详细API描述](https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#get_group_honor_info-%E8%8E%B7%E5%8F%96%E7%BE%A4%E8%8D%A3%E8%AA%89%E4%BF%A1%E6%81%AF) 180 | 181 | ~碎碎念: 这版本来昨天就要发, 但是`mirai`突然复活发版`mirai-core 1.2.0`, 适配后想跳过这版直接基于`mirai-console 1.0`上一波`cqhttp-mirai 0.3.0`, 但是今天测试了`console 1.0.0`后发现破坏体验的BUG有点多, 只好选择基于`console 0.5.2`再发一版, 那么下一版不出意外将基于`console 1.0.0`, 配置文件将会不兼容, 同时需要其他插件也适配`console 1.0.0`, 目前已确认`mirai-native`, `mirai-api-http`, `mirai-kts`等下版本将适配`console 1.0.0`~ 182 | 183 | ## `0.2.1` *2020/08/13* 184 | - **修复正向WS路径`/`的事件处理逻辑错误** #33 185 | - **修复好友/群成员添加请求事件的上报格式错误** #34 186 | - 修复当未开启反向WS时处理好友/群成员添加请求时的空指针异常 187 | - 为图片下载添加UA, **减少因反爬虫机制导致的图片获取出错** #32 188 | - 对增强CQ码中的`url`值进行转义 189 | - 接收图片时`file=md5`而非`mirai`的`imageId` 190 | - **图片缓存文件夹由`images`改为`image`**, 位于`plugins/CQHTTPMirai/image` 191 | - 通过`url`发送图片时, **默认对`url`进行hash并保存图片缓存(仅保存图片元数据, <0.2KB)**, 支持`cache=0`来不使用缓存 192 | - 配置文件Bot设置中, **添加`cacheImage`字段**, 当设置为`true`时会对接收到的所有图片进行缓存, 默认不开启(仅保存图片元数据, <0.2KB) 193 | - 支持发送接收到的图片(发送接收到图片的`file=`字段值), 需开启上述接收图片的缓存 194 | - 支持发送CKYU生成的`cqimg`文件, 需将文件复制到`image`文件夹下, **发送时文件名不带`cqimg`后缀** 195 | - 对CQ码内key进行trim, 现在CQ码中带空格不会报错了 196 | 197 | ### Embedded版本 - [分支](https://github.com/yyuueexxiinngg/cqhttp-mirai/tree/embedded) 198 | 199 | - 包含上述所有更新 200 | - 启动时可以传参`--account 1234567890 --password xxxxxx`来进行自动登录 201 | - 会读取环境变量`cqhttp.account`和`cqhttp.password`, 作用同上, 优先级低, 会被参数覆盖 202 | - **支持读取miraiOK生成的config.txt配置文件中的命令** 203 | - 支持接收语音时获取下载链接 204 | - 通过`url`发送语音时, 默认对`url`进行hash并保存语音缓存(保存完整语音数据), 支持`cache=0`来不使用缓存 205 | - 配置文件Bot设置中, 添加`cacheRecord`字段, 当设置为`true`时会对接收到的所有语音进行下载缓存, 默认不开启(保存完整语音数据) 206 | - 支持发送接收到的语音(发送接收到语音的`file=`字段值), 需开启上述接收语音的缓存 207 | 208 | ## `0.2.0` *2020/08/09* 209 | #### 0.2.0-Fix1 210 | 211 | - 修复高CPU占用的问题 ~(谁还没写过个死循环呢, 我错了, 是我太菜了)~ 212 | 213 | #### 0.2.0 214 | 215 | - 修复检测反向WS客户端连接状态导致的内存泄露 #22 216 | - 修复HTTP服务端接搜JSON格式POST请求时的编码错误 #25 217 | - 修复潜在的内容转义问题 #26 218 | - 将好友请求、原消息保存条数从`4096`条下调至`512`条, 缩减内存占用 219 | - 增加拓展API`set_group_name`支持. 来自[go-cqhttp的设置群名 220 | ](https://github.com/Mrs4s/go-cqhttp/blob/master/docs/cqhttp.md#%E8%AE%BE%E7%BD%AE%E7%BE%A4%E5%90%8D) 221 | - 增加Embedded版本, 内置Core和Console, 支持语音, 目前只支持`.amr`格式语音 222 | 223 | ### Embedded版本 - [分支](https://github.com/yyuueexxiinngg/cqhttp-mirai/tree/embedded) 224 | 225 | - 此版本内置Core和Console, 支持语音, 目前只支持`.amr`格式语音 226 | - 请将此版本Jar包放至与`mirai-console`, `miraiOK`同级目录 227 | - 此版本启动方式`java -jar cqhttp-mirai-**-embedded.jar` 228 | - 此版本配置文件及`image`文件夹路径有所变更, 在`plugins`文件夹下, 而非`plugins/CQHTTPMirai` 229 | - 请不要将此版本与主分支单插件版同时使用, 即不要在`plugins`文件夹下放置`cqhttp-mirai`的Jar包 230 | 231 | ## `0.1.9` *2020/08/06* 232 | - 修复未对服务进行配置时的报错 #20 233 | - 获取群成员列表时包含Bot本身 #23 234 | - 上报服务X-Signature格式符合CQHTTP标准 #21 235 | - 修复设置特殊头衔时的错误返回值 236 | - 支持发送自定义Json消息 代码来自[mirai-native](https://github.com/iTXTech/mirai-native) 237 | - 支持发送自定义Xml消息 代码来自[mirai-native](https://github.com/iTXTech/mirai-native) 238 | - 修复CQCode转义逻辑, 现在[CQ-picfinder-robot](https://github.com/Tsuk1ko/CQ-picfinder-robot)发送的SauceNao图片可正常显示了 239 | 240 | ## `0.1.8` *2020/08/05* 241 | - 添加HTTP API服务端支持 242 | 243 | 配置中`http`项新增四项配置, 请参考[README.md](https://github.com/yyuueexxiinngg/cqhttp-mirai/blob/master/README.md) 244 | ```yaml 245 | http: 246 | enable: true #新增 247 | host: 0.0.0.0 #新增 248 | port: 5700 #新增 249 | accessToken: "" #新增 250 | postUrl: "" 251 | postMessageFormat: string 252 | secret: "" 253 | ``` 254 | 255 | ## `0.1.7` *2020/08/04* 256 | #### Fix 1 257 | 258 | - 修复ws反向客户端断线后重连问题 259 | 260 | #### 0.1.7 261 | 262 | - 添加Websoket正向服务端支持 #7 263 | - 反向Websoket客户端添加Api, Event路由支持 264 | 265 | 配置新增`ws`项用以配置Websoket正向服务端 266 | ```yaml 267 | ws: 268 | # 可选,是否启用正向Websocket服务器,默认不启用 269 | enable: true 270 | # 可选,上报消息格式,string 为字符串格式,array 为数组格式, 默认为string 271 | postMessageFormat: string 272 | # 可选,访问口令, 默认为空, 即不设置Token 273 | accessToken: "" 274 | # 监听主机 275 | wsHost: "0.0.0.0" 276 | # 监听端口 277 | wsPort: 8080 278 | ``` 279 | 280 | 反向ws配置新增三项配置以支持Api和Event路由, 现有配置默认使用Universal路由, 无需改动 281 | ```yaml 282 | ws_reverse: 283 | - enable: true 284 | postMessageFormat: string 285 | reverseHost: 286 | reversePort: 287 | reversePath: /ws 288 | reverseApiPath: /api # 新增 289 | reverseEventPath: /event # 新增 290 | useUniversal: true # 新增 291 | reconnectInterval: 3000 292 | ``` 293 | 294 | ## `0.1.6` *2020/08/02* 295 | - 修复当登录多个Bot时消息重复接收 #4 296 | - Websoket反向客户端添加多后端连接支持 #8 297 | - 使用CQHTTP原User-Agent #12 298 | 299 | ### 本更新不兼容旧版配置文件 300 | 建议参考首页说明重新配置 301 | 302 | ## `0.1.4` *2020/06/05* 303 | - 修复```get_group_member_list```接口的返回值序列化错误 304 | - 现在获取群成员信息时, ```nickname```和```name card```字段正确对应了 305 | - 修复群成员加入及新好友请求的回应操作 306 | 307 | ## `0.1.3` *2020/05/31* 308 | - 修复数个api序列化返回值时发生错误导致无法返回数据 309 | - GetFriendList 310 | - GetGroupList 311 | - GetGroupMemberList 312 | - CanSendImage 313 | - CanSendRecord 314 | - 支持_async调用 315 | 316 | ## `0.1.2` *2020/05/25* 317 | - 现在Websocket反向客户端默认为不启用 318 | - 添加支持: CQHTTP .handle_quick_operation 隐藏API 319 | 320 | ## `0.1.1` *2020/05/23* 321 | - 修复当向插件提交的发送信息为单一JsonObject时的报错 ~(CQHTTP你好坑)~ 322 | - 现在关闭Console时不会打印"Websocket连接错误"的错误信息了 323 | 324 | ## `0.1.0` *2020/05/23* 325 | ### 初始Release -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OneBot Mirai - CQHTTP Mirai 2 | 3 | [![OneBot v10](https://img.shields.io/badge/OneBot-v10-black)](https://github.com/howmanybots/onebot/blob/master/v10/specs/README.md) 4 | [![Gradle CI](https://github.com/yyuueexxiinngg/onebot-kotlin/workflows/Gradle%20CI/badge.svg)](https://github.com/yyuueexxiinngg/onebot-kotlin/actions) 5 | [![Downloads](https://img.shields.io/github/downloads/yyuueexxiinngg/onebot-kotlin/total)](https://github.com/yyuueexxiinngg/onebot-kotlin/releases) 6 | [![Release](https://img.shields.io/github/v/release/yyuueexxiinngg/onebot-kotlin?include_prereleases)](https://github.com/yyuueexxiinngg/onebot-kotlin/releases) 7 | [![Downloads @latest](https://img.shields.io/github/downloads-pre/yyuueexxiinngg/onebot-kotlin/latest/total)](https://github.com/yyuueexxiinngg/onebot-kotlin/releases) 8 | 9 | __[OneBot标准](https://github.com/howmanybots/onebot) mirai 插件版 *(原cqhttp-mirai)*__ 10 | 11 | ### 开始使用 12 | 0. 请首先运行[mirai-console](https://github.com/mamoe/mirai-console)相关客户端生成plugins文件夹 13 | 1. 将`onebot-mirai`生成的`jar包文件`放入`plugins`文件夹中 14 | 2. 运行`mirai-console`, 将在`config/OneBot`文件夹中自动生成样本配置文件`settings.yml` 15 | 3. 编辑`config/OneBot/settings.yml`配置文件, 按照以下配置给出的注释修改保存 16 | 4. 再次启动[mirai-console](https://github.com/mamoe/mirai-console)相关客户端 17 | 18 | # OneBot Kotlin - CQHTTP Mirai Embedded 19 | 20 | __[OneBot标准](https://github.com/howmanybots/onebot) Kotlin实现 *(原cqhttp-mirai-embedded)*__ 21 | 22 | ### 注意事项 23 | - 此版本内置`mirai-core`和`mirai-console` 24 | - 请将此版本Jar包放至与`mirai-console-loader`, `miraiOK`同级目录 25 | - 此版本启动方式`java -jar onebot-kotlin-**.jar` 26 | - 请不要将此版本与主分支单插件版同时使用, 即不要在`plugins`文件夹下放置`onebot-mirai`的Jar包 27 | 28 | ### 开始使用 29 | 1. 运行Jar包: `java -jar onebot-kotlin-**.jar` 30 | 2. 编辑`config/OneBot/settings.yml`配置文件, 将以下配置给出的注释修改保存 31 | 3. 重新运行 32 | 33 | ### 接收的参数 34 | - `--account 123456789` 要自动登录的账号 35 | - `--password *******` 要自动登录账号的密码 36 | - `--args -- --xx`传入参数至`mirai-console`, 如`--args -- --help`将`--help`传入获取`mirai-console`提供的帮助信息 37 | 38 | ### 读取的环境变量 39 | - `ONEBOT_ACCOUNT` 同`--account`参数, 但优先级低, 会被参数覆盖 40 | - `ONEBOT_PASSWORD` 同`--password`参数, 但优先级低, 会被参数覆盖 41 | 42 | ### 更新日志: [CHANGELOG](https://github.com/yyuueexxiinngg/onebot-kotlin/blob/master/CHANGELOG.md) 43 | 44 | ## 配置相关 45 | ```yaml 46 | # 下载图片/语音时使用的Proxy, 配置后, 发送图片/语音时指定`proxy=1`以通过Proxy下载, 如[CQ:image,proxy=1,url=http://***] 47 | # 支持HTTP及Sock两种Proxy, 设置举例 proxy: "http=http://127.0.0.1:8888", proxy : "sock=127.0.0.1:1088" 48 | proxy: '' 49 | # Mirai支持多帐号登录, 故需要对每个帐号进行单独设置 50 | bots: 51 | # 要进行配置的QQ号 52 | 1234567890: 53 | # 是否缓存所有收到的图片, 默认为否 (仅包含图片信息, 不包含图片本身, < 0.5KB) 54 | cacheImage: false 55 | # 是否缓存所有收到的语音, 默认为否 (将下载完整语音进行保存) 56 | cacheRecord: false 57 | # 心跳包相关配置 58 | heartbeat: 59 | # 是否发送心跳包, 默认为否 60 | enable: false 61 | # 心跳包发送间隔, 默认为 15000毫秒 62 | interval: 15000 63 | # HTTP 相关配置 64 | http: 65 | # 可选,是否启用HTTP API服务器, 默认为不启用, 此项开始与否跟postUrl无关 66 | enable: true 67 | # 可选,HTTP API服务器监听地址, 默认为0.0.0.0 68 | host: 0.0.0.0 69 | # 可选,HTTP API服务器监听端口, 5700 70 | port: 5700 71 | # 可选,访问口令, 默认为空, 即不设置Token 72 | accessToken: '' 73 | # 可选,事件及数据上报URL, 默认为空, 即不上报 74 | postUrl: '' 75 | # 可选,上报消息格式,string 为字符串格式,array 为数组格式, 默认为string 76 | postMessageFormat: string 77 | # 可选,上报数据签名密钥, 默认为空 78 | secret: '' 79 | # 上报超时时间, 单位毫秒, 须大于0才会生效 80 | timeout: 0 81 | # 可选,反向客户端服务 82 | ws_reverse: 83 | # 可选,是否启用反向客户端,默认不启用 84 | - enable: true 85 | # 上报消息格式,string 为字符串格式,array 为数组格式 86 | postMessageFormat: string 87 | # 反向Websocket主机 88 | reverseHost: 127.0.0.1 89 | # 反向Websocket端口 90 | reversePort: 8080 91 | # 访问口令, 默认为空, 即不设置Token 92 | accessToken: '' 93 | # 反向Websocket路径 94 | reversePath: /ws 95 | # 可选, 反向Websocket Api路径, 默认为reversePath 96 | reverseApiPath: /api 97 | # 可选, 反向Websocket Event路径, 默认为reversePath 98 | reverseEventPath: /event 99 | # 是否使用Universal客户端 默认为true 100 | useUniversal: true 101 | # 可选, 是否通过HTTPS连接, 默认为false 102 | useTLS: false 103 | # 反向 WebSocket 客户端断线重连间隔,单位毫秒 104 | reconnectInterval: 3000 105 | - enable: true # 这里是第二个连接, 相当于CQHTTP分身版 106 | postMessageFormat: string 107 | reverseHost: 127.0.0.1 108 | reversePort: 9222 109 | reversePath: /ws 110 | useUniversal: false 111 | reconnectInterval: 3000 112 | # 正向Websocket服务器 113 | ws: 114 | # 可选,是否启用正向Websocket服务器,默认不启用 115 | enable: true 116 | # 可选,上报消息格式,string 为字符串格式,array 为数组格式, 默认为string 117 | postMessageFormat: string 118 | # 监听主机 119 | wsHost: 0.0.0.0 120 | # 监听端口 121 | wsPort: 6700 122 | # 可选,访问口令, 默认为空, 即不设置Token 123 | accessToken: '' 124 | 0987654321: # 这里是第二个QQ Bot的配置 125 | ws_reverse: 126 | - enable: true 127 | postMessageFormat: string 128 | reverseHost: 129 | reversePort: 130 | reversePath: /ws 131 | reconnectInterval: 3000 132 | ``` 133 | 134 | #### 实现 135 | - [x] 反向Websocket客户端 136 | - [x] HTTP上报服务 137 | - [x] Websocket服务端 138 | - [x] HTTP API 139 | 140 |
141 | 已实现CQ码 142 | 143 | - [CQ:at] 144 | - [CQ:image] 145 | - [CQ:record] 146 | - [CQ:face] 147 | - [CQ:emoji] 148 | - [CQ:share] 149 | - [CQ:contact] 150 | - [CQ:music] 151 | - [CQ:shake] 152 | - [CQ:poke] 153 | - [CQ:xml] 154 | - [CQ:json] 155 | 156 |
157 | 158 |
159 | 已支持的OneBot API 160 | 161 | #### 特别注意, 很多信息Mirai不支持获取, 如群成员的年龄、性别等, 为保证兼容性, 这些项已用`Unknown`, `0`之类的信息填充占位 162 | 163 | | API | 功能 | 备注 | 164 | | ------------------------ | ------------------------------------------------------------ | -------------------------- | 165 | | /send_private_msg | [发送私聊消息](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#send_private_msg-发送私聊消息) | | 166 | | /send_group_msg | [发送群消息](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#send_group_msg-发送群消息) | | 167 | | /send_msg | [发送消息](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#send_msg-发送消息) | (不包含讨论组消息) | 168 | | /delete_msg | [撤回信息](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#delete_msg-撤回消息) | | 169 | | /set_group_kick | [群组T人](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#set_group_kick-群组踢人) | | 170 | | /set_group_ban | [群组单人禁言](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#set_group_ban-群组单人禁言) | | 171 | | /set_group_whole_ban | [群组全员禁言](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#set_group_whole_ban-群组全员禁言) | | 172 | | /set_group_card | [设置群名片(群备注)](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#set_group_card-设置群名片(群备注)) | | 173 | | /set_group_leave | [退出群组](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#set_group_leave-退出群组) | (不支持解散群组) | 174 | | /set_group_special_title | [设置群组专属头衔](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#set_group_special_title-设置群组专属头衔) | (不支持设置有效期) | 175 | | /set_friend_add_request | [处理加好友请求](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#set_friend_add_request-处理加好友请求) | (不支持设置备注) | 176 | | /set_group_add_request | [处理加群请求/邀请](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#set_group_add_request-处理加群请求/邀请) | | 177 | | /get_login_info | [获取登录号信息](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#get_login_info-获取登录号信息) | | 178 | | /get_friend_list | [获取好友列表](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#get_friend_list-获取好友列表) | | 179 | | /get_group_honor_info | [获取群荣誉信息](https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#get_group_honor_info-%E8%8E%B7%E5%8F%96%E7%BE%A4%E8%8D%A3%E8%AA%89%E4%BF%A1%E6%81%AF) | | 180 | | /get_image | [获取图片](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#get_image-获取图片) | | 181 | | /get_group_list | [获取群列表](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#get_group_list-获取群列表) | | 182 | | /get_group_info | [获取群信息](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#get_group_info-获取群信息) | | 183 | | /get_group_member_info | [获取群成员信息](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#get_group_member_info-获取群成员信息) | | 184 | | /get_group_member_list | [获取群成员列表](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#get_group_member_list-获取群成员列表) | | 185 | | /can_send_image | [检查是否可以发送图片](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#can_send_image-检查是否可以发送图片) | (恒为true) | 186 | | /can_send_record | [检查是否可以发送语音](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#can_send_record-检查是否可以发送语音) | | 187 | | /get_status | [获取插件运行状态](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#get_status-获取插件运行状态) | (不完全支持, 仅返回`online`和`good`两项) | 188 | | /get_version_info | [获取 酷Q 及 CQHTTP插件的版本信息](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#get_version_info-获取-酷q-及-cqhttp-插件的版本信息) | | 189 | | /set_group_name | [设置群名](https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#set_group_name-%E8%AE%BE%E7%BD%AE%E7%BE%A4%E5%90%8D) | 190 | 191 |
192 | 193 |
194 | 尚未支持的OneBot API 195 | 196 | | API | 功能 | 备注 | 197 | | ------------------------ | ------------------------------------------------------------ | -------------------------- | 198 | | /get_record | [获取语音](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#get_record-获取语音) | | 199 | | /send_discuss_msg | [发送讨论组消息](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#send_discuss_msg-发送讨论组消息) | 已无讨论组 | 200 | | /set_discuss_leave | [退出讨论组](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#set_discuss_leave-退出讨论组) | 已无讨论组 | 201 | | /get_stranger_info | [获取陌生人信息](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#get_stranger_info-获取陌生人信息) | | 202 | | /set_group_anonymous_ban | [群组匿名用户禁言](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#set_group_anonymous_ban-群组匿名用户禁言) | | 203 | | /set_group_admin | [群组设置管理员](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#set_group_admin-群组设置管理员) | | 204 | | /send_like | [发送好友赞](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#end_like-发送好友赞) | Mirai不会支持 | 205 | | /get_cookies | [获取 Cookies](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#get_cookies-获取-cookies) | Mirai不会支持 | 206 | | /get_csrf_token | [获取 CSRF Token](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#get_csrf_token-获取-csrf-token) | Mirai不会支持 | 207 | | /get_credentials | [获取 QQ 相关接口凭证](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#get_credentials-获取-qq-相关接口凭证) | Mirai不会支持 | 208 | | /set_restart_plugin | [重启 CQHTTP](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#set_restart_plugin-重启-cqhttp) | | 209 | | /clean_data_dir | [清理数据目录](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#clean_data_dir-清理数据目录) | | 210 | | /clean_plugin_log | [清理日志](https://github.com/richardchien/cqhttp-protocol/blob/master/v11/specs/api/public.md#clean_plugin_log-清理日志) | | 211 | 212 |
213 | 214 | ## 开源协议 215 | [AGPL-3.0](LICENSE) © yyuueexxiinngg 216 | 217 | ## 直接或间接引用到的其他开源项目 218 | - [mirai-api-http](https://github.com/mamoe/mirai-api-http) - [LICENSE](https://github.com/mamoe/mirai-api-http/blob/master/LICENSE) 219 | - [Mirai Native](https://github.com/iTXTech/mirai-native) - [LICENSE](https://github.com/iTXTech/mirai-native/blob/master/LICENSE) 220 | - [CQHTTP](https://github.com/richardchien/coolq-http-api) - [LICENSE](https://github.com/richardchien/coolq-http-api/blob/master/LICENSE) 221 | - [OneBot标准](https://github.com/howmanybots/onebot) 222 | - [go-cqhttp](https://github.com/Mrs4s/go-cqhttp) - [LICENSE](https://github.com/Mrs4s/go-cqhttp/blob/master/LICENSE) 223 | -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/web/websocket/WebsocketReverseClient.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot.web.websocket 2 | 3 | import com.github.yyuueexxiinngg.onebot.BotEventListener 4 | import com.github.yyuueexxiinngg.onebot.BotSession 5 | import com.github.yyuueexxiinngg.onebot.PluginSettings 6 | import com.github.yyuueexxiinngg.onebot.data.common.HeartbeatMetaEventDTO 7 | import com.github.yyuueexxiinngg.onebot.data.common.LifecycleMetaEventDTO 8 | import com.github.yyuueexxiinngg.onebot.data.common.PluginStatusData 9 | import com.github.yyuueexxiinngg.onebot.logger 10 | import com.github.yyuueexxiinngg.onebot.util.currentTimeSeconds 11 | import com.github.yyuueexxiinngg.onebot.util.toJson 12 | import com.github.yyuueexxiinngg.onebot.web.HeartbeatScope 13 | import io.ktor.client.* 14 | import io.ktor.client.features.websocket.* 15 | import io.ktor.client.request.header 16 | import io.ktor.http.cio.websocket.* 17 | import kotlinx.coroutines.* 18 | import kotlinx.coroutines.channels.ReceiveChannel 19 | import kotlinx.coroutines.channels.SendChannel 20 | import kotlinx.coroutines.channels.consumeEach 21 | import net.mamoe.mirai.Bot 22 | import java.io.EOFException 23 | import java.io.IOException 24 | import java.net.ConnectException 25 | import kotlin.collections.set 26 | import kotlin.coroutines.CoroutineContext 27 | import kotlin.coroutines.EmptyCoroutineContext 28 | 29 | class WebsocketReverseClientScope(coroutineContext: CoroutineContext) : CoroutineScope { 30 | override val coroutineContext: CoroutineContext = coroutineContext + CoroutineExceptionHandler { _, throwable -> 31 | logger.error("Exception in WebsocketReverseClient", throwable) 32 | } + SupervisorJob() 33 | } 34 | 35 | class WebSocketReverseClient( 36 | private val session: BotSession 37 | ) { 38 | private val httpClients: MutableMap = mutableMapOf() 39 | private var settings: MutableList? = session.settings.wsReverse 40 | 41 | // Pair 42 | private var subscriptions: MutableMap> = mutableMapOf() 43 | private var websocketSessions: MutableMap = mutableMapOf() 44 | private var heartbeatJobs: MutableMap = mutableMapOf() 45 | private var connectivityChecks: MutableList = mutableListOf() 46 | private val scope = WebsocketReverseClientScope(EmptyCoroutineContext) 47 | 48 | init { 49 | settings?.forEach { 50 | logger.debug("Host: ${it.reverseHost}, Port: ${it.reversePort}, Enable: ${it.enable}, Use Universal: ${it.useUniversal}") 51 | if (it.enable) { 52 | if (it.useUniversal) { 53 | scope.launch { 54 | startGeneralWebsocketClient(session.bot, it, "Universal") 55 | } 56 | } else { 57 | scope.launch { 58 | startGeneralWebsocketClient(session.bot, it, "Api") 59 | } 60 | scope.launch { 61 | startGeneralWebsocketClient(session.bot, it, "Event") 62 | } 63 | } 64 | } 65 | } 66 | } 67 | 68 | @OptIn(ExperimentalCoroutinesApi::class) 69 | @Suppress("DuplicatedCode") 70 | private suspend fun startGeneralWebsocketClient( 71 | bot: Bot, 72 | settings: PluginSettings.WebsocketReverseClientSettings, 73 | clientType: String 74 | ) { 75 | val httpClientKey = "${settings.reverseHost}:${settings.reversePort}-Client-$clientType" 76 | 77 | httpClients[httpClientKey] = HttpClient { 78 | install(WebSockets) 79 | } 80 | 81 | logger.debug("WS Reverse: $httpClientKey 开始启动...") 82 | val path = when (clientType) { 83 | "Api" -> settings.reverseApiPath 84 | "Event" -> settings.reverseEventPath 85 | else -> settings.reversePath 86 | } 87 | 88 | try { 89 | if (!settings.useTLS) { 90 | httpClients[httpClientKey]!!.ws( 91 | host = settings.reverseHost, 92 | port = settings.reversePort, 93 | path = path, 94 | request = { 95 | header("User-Agent", "CQHttp/4.15.0") 96 | header("X-Self-ID", bot.id.toString()) 97 | header("X-Client-Role", clientType) 98 | settings.accessToken.let { 99 | if (it != "") { 100 | header( 101 | "Authorization", 102 | "Token ${settings.accessToken}" 103 | ) 104 | } 105 | } 106 | } 107 | ) { 108 | // 用来检测Websocket连接是否关闭 109 | websocketSessions[httpClientKey] = this 110 | startWebsocketConnectivityCheck(bot, settings, clientType) 111 | when (clientType) { 112 | "Api" -> listenApi(incoming, outgoing) 113 | "Event" -> listenEvent(httpClientKey, settings, this, clientType) 114 | "Universal" -> { 115 | listenEvent(httpClientKey, settings, this, clientType) 116 | listenApi(incoming, outgoing) 117 | } 118 | } 119 | } 120 | } else { 121 | httpClients[httpClientKey]!!.wss( 122 | host = settings.reverseHost, 123 | port = settings.reversePort, 124 | path = path, 125 | request = { 126 | header("User-Agent", "CQHttp/4.15.0") 127 | header("X-Self-ID", bot.id.toString()) 128 | header("X-Client-Role", clientType) 129 | settings.accessToken.let { 130 | if (it != "") { 131 | header( 132 | "Authorization", 133 | "Token ${settings.accessToken}" 134 | ) 135 | } 136 | } 137 | } 138 | ) { 139 | // 用来检测Websocket连接是否关闭 140 | websocketSessions[httpClientKey] = this 141 | startWebsocketConnectivityCheck(bot, settings, clientType) 142 | when (clientType) { 143 | "Api" -> listenApi(incoming, outgoing) 144 | "Event" -> listenEvent(httpClientKey, settings, this, clientType) 145 | "Universal" -> { 146 | listenEvent(httpClientKey, settings, this, clientType) 147 | listenApi(incoming, outgoing) 148 | } 149 | } 150 | } 151 | } 152 | } catch (e: Exception) { 153 | when (e) { 154 | is ConnectException -> { 155 | logger.warning("Websocket连接出错, 请检查服务器是否开启并确认正确监听端口, 将在${settings.reconnectInterval / 1000}秒后重试连接, Host: $httpClientKey Path: $path") 156 | } 157 | is EOFException -> { 158 | logger.warning("Websocket连接出错, 服务器返回数据不正确, 请检查Websocket服务器是否配置正确, 将在${settings.reconnectInterval / 1000}秒后重试连接, Host: $httpClientKey Path: $path") 159 | } 160 | is IOException -> { 161 | logger.warning("Websocket连接出错, 可能被服务器关闭, 将在${settings.reconnectInterval / 1000}秒后重试连接, Host: $httpClientKey Path: $path") 162 | } 163 | is CancellationException -> { 164 | logger.info("Websocket连接关闭中, Host: $httpClientKey Path: $path") 165 | } 166 | else -> { 167 | logger.warning("Websocket连接出错, 未知错误, 请检查配置, 如配置错误请修正后重启mirai " + e.message + e.javaClass.name) 168 | } 169 | } 170 | closeClient(httpClientKey) 171 | delay(settings.reconnectInterval) 172 | startGeneralWebsocketClient(session.bot, settings, clientType) 173 | } 174 | } 175 | 176 | @ExperimentalCoroutinesApi 177 | private suspend fun listenApi(incoming: ReceiveChannel, outgoing: SendChannel) { 178 | incoming.consumeEach { 179 | when (it) { 180 | is Frame.Text -> { 181 | scope.launch { 182 | handleWebSocketActions(outgoing, session.apiImpl, it.readText()) 183 | } 184 | } 185 | else -> logger.warning("Unsupported incoming frame") 186 | } 187 | } 188 | } 189 | 190 | @ExperimentalCoroutinesApi 191 | private suspend fun listenEvent( 192 | httpClientKey: String, 193 | settings: PluginSettings.WebsocketReverseClientSettings, 194 | websocketSession: DefaultClientWebSocketSession, 195 | clientType: String 196 | ) { 197 | // 通知服务方链接建立 198 | websocketSession.outgoing.send( 199 | Frame.Text( 200 | LifecycleMetaEventDTO( 201 | session.bot.id, 202 | "connect", 203 | currentTimeSeconds() 204 | ).toJson() 205 | ) 206 | ) 207 | 208 | subscriptions[httpClientKey] = 209 | Pair( 210 | session.subscribeEvent( 211 | { jsonToSend -> 212 | if (websocketSession.isActive) { 213 | websocketSession.outgoing.send(Frame.Text(jsonToSend)) 214 | } else { 215 | logger.warning("WS Reverse事件发送失败, 连接已被关闭, 尝试重连中 $httpClientKey") 216 | closeClient(httpClientKey) 217 | startGeneralWebsocketClient(session.bot, settings, clientType) 218 | } 219 | }, 220 | settings.postMessageFormat != "array" 221 | ), settings.postMessageFormat != "array" 222 | ) 223 | 224 | if (session.settings.heartbeat.enable) { 225 | heartbeatJobs[httpClientKey] = HeartbeatScope(EmptyCoroutineContext).launch { 226 | while (true) { 227 | if (websocketSession.isActive) { 228 | websocketSession.outgoing.send( 229 | Frame.Text( 230 | HeartbeatMetaEventDTO( 231 | session.botId, 232 | currentTimeSeconds(), 233 | PluginStatusData( 234 | good = session.bot.isOnline, 235 | online = session.bot.isOnline 236 | ), 237 | session.settings.heartbeat.interval 238 | ).toJson() 239 | ) 240 | ) 241 | delay(session.settings.heartbeat.interval) 242 | } else { 243 | logger.warning("WS Reverse事件发送失败, 连接已被关闭, 尝试重连中 $httpClientKey") 244 | closeClient(httpClientKey) 245 | startGeneralWebsocketClient(session.bot, settings, clientType) 246 | break 247 | } 248 | } 249 | } 250 | } 251 | 252 | if (clientType != "Universal") websocketSession.incoming.consumeEach { logger.warning("WS Reverse Event 路由只负责发送事件, 不响应收到的请求") } 253 | } 254 | 255 | @OptIn(ExperimentalCoroutinesApi::class) 256 | private fun startWebsocketConnectivityCheck( 257 | bot: Bot, 258 | settings: PluginSettings.WebsocketReverseClientSettings, 259 | clientType: String 260 | ) { 261 | val httpClientKey = "${settings.reverseHost}:${settings.reversePort}-Client-$clientType" 262 | if (httpClientKey !in connectivityChecks) { 263 | connectivityChecks.add(httpClientKey) 264 | scope.launch { 265 | if (httpClients.containsKey(httpClientKey)) { 266 | var stillActive = true 267 | while (true) { 268 | websocketSessions[httpClientKey]?.apply { 269 | if (!this.isActive) { 270 | stillActive = false 271 | this.cancel() 272 | } 273 | } 274 | 275 | if (!stillActive) { 276 | closeClient(httpClientKey) 277 | logger.warning("Websocket连接已断开, 将在${settings.reconnectInterval / 1000}秒后重试连接, Host: $httpClientKey") 278 | delay(settings.reconnectInterval) 279 | startGeneralWebsocketClient(bot, settings, clientType) 280 | } 281 | delay(5000) 282 | } 283 | } else { 284 | logger.error("WS Reverse: 尝试在不存在的HTTP客户端上检测连接性 $httpClientKey") 285 | } 286 | } 287 | } 288 | } 289 | 290 | private fun closeClient(httpClientKey: String) { 291 | subscriptions[httpClientKey]?.let { session.unsubscribeEvent(it.first, it.second) } 292 | subscriptions.remove(httpClientKey) 293 | heartbeatJobs[httpClientKey]?.cancel() 294 | heartbeatJobs.remove(httpClientKey) 295 | websocketSessions[httpClientKey]?.cancel() 296 | websocketSessions.remove(httpClientKey) 297 | httpClients[httpClientKey]?.apply { this.close() } 298 | httpClients.remove(httpClientKey) 299 | } 300 | 301 | fun close() { 302 | subscriptions.forEach { session.unsubscribeEvent(it.value.first, it.value.second) } 303 | subscriptions.clear() 304 | heartbeatJobs.forEach { it.value.cancel() } 305 | heartbeatJobs.clear() 306 | websocketSessions.forEach { it.value.cancel() } 307 | websocketSessions.clear() 308 | httpClients.forEach { it.value.close() } 309 | httpClients.clear() 310 | logger.info("反向Websocket模块已禁用") 311 | } 312 | } -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/data/common/ResponseDTO.kt: -------------------------------------------------------------------------------- 1 | package com.github.yyuueexxiinngg.onebot.data.common 2 | 3 | import com.github.yyuueexxiinngg.onebot.BuildConfig 4 | import com.google.gson.annotations.SerializedName 5 | import kotlinx.serialization.KSerializer 6 | import kotlinx.serialization.SerialName 7 | import kotlinx.serialization.Serializable 8 | import kotlinx.serialization.builtins.ListSerializer 9 | import kotlinx.serialization.builtins.serializer 10 | import kotlinx.serialization.descriptors.SerialDescriptor 11 | import kotlinx.serialization.encoding.Decoder 12 | import kotlinx.serialization.encoding.Encoder 13 | import kotlinx.serialization.json.JsonElement 14 | import net.mamoe.mirai.contact.Member 15 | import net.mamoe.mirai.contact.MemberPermission 16 | 17 | @Serializable 18 | sealed class ResponseDataDTO 19 | 20 | @Serializable 21 | open class ResponseDTO( 22 | val status: String, 23 | val retcode: Int, 24 | @Serializable(with = ResponseDataSerializer::class) val data: Any?, 25 | var echo: JsonElement? = null 26 | ) { 27 | class GeneralSuccess : ResponseDTO("ok", 0, null) 28 | class AsyncStarted : ResponseDTO("async", 1, null) 29 | class MiraiFailure(message: String? = null) : ResponseDTO("failed", 102, ResponseMessageData(message)) 30 | class PluginFailure(message: String? = null) : ResponseDTO("failed", 103, ResponseMessageData(message)) 31 | class InvalidRequest(message: String? = "参数错误") : ResponseDTO("failed", 100, ResponseMessageData(message)) 32 | class MessageResponse(message_id: Int) : ResponseDTO("ok", 0, MessageData(message_id)) 33 | class GetMessageResponse(result: GetMessageData) : ResponseDTO("ok", 0, result) 34 | class LoginInfo(user_id: Long, nickname: String) : ResponseDTO("ok", 0, LoginInfoData(user_id, nickname)) 35 | class FriendList(friendList: List) : ResponseDTO("ok", 0, friendList) 36 | class StrangerInfo(info: StrangerInfoData) : ResponseDTO("ok", 0, info) 37 | 38 | class GroupList(groupList: List?) : ResponseDTO("ok", 0, groupList) 39 | class GroupInfo(group_id: Long, group_name: String, member_count: Int, max_member_count: Int) : 40 | ResponseDTO("ok", 0, GroupInfoData(group_id, group_name, member_count, max_member_count)) 41 | 42 | class ImageInfo(image: ImageInfoData) : ResponseDTO("ok", 0, image) 43 | class RecordInfo(record: RecordInfoData) : ResponseDTO("ok", 0, record) 44 | class MemberInfo(member: MemberInfoData) : ResponseDTO("ok", 0, member) 45 | class MemberList(memberList: List) : ResponseDTO("ok", 0, memberList) 46 | class CanSendImage(data: CanSendImageData = CanSendImageData()) : ResponseDTO("ok", 0, data) 47 | class CanSendRecord(data: CanSendRecordData = CanSendRecordData()) : ResponseDTO("ok", 0, data) 48 | class PluginStatus(status: PluginStatusData) : ResponseDTO("ok", 0, status) 49 | class VersionInfo(versionInfo: VersionInfoData) : ResponseDTO("ok", 0, versionInfo) 50 | 51 | class HonorInfo(honorInfo: GroupHonorInfoData) : ResponseDTO("ok", 0, honorInfo) 52 | 53 | object ResponseDataSerializer : KSerializer { 54 | override val descriptor: SerialDescriptor 55 | get() = String.serializer().descriptor 56 | 57 | override fun deserialize(decoder: Decoder): Any? { 58 | error("Not implemented") 59 | } 60 | 61 | @Suppress("UNCHECKED_CAST") 62 | override fun serialize(encoder: Encoder, value: Any?) { 63 | return when (value) { 64 | is List<*> -> encoder.encodeSerializableValue( 65 | ListSerializer(ResponseDataDTO.serializer()), 66 | value as List 67 | ) 68 | else -> encoder.encodeSerializableValue( 69 | ResponseDataDTO.serializer(), 70 | value as ResponseDataDTO 71 | ) 72 | } 73 | } 74 | } 75 | } 76 | 77 | @Serializable 78 | @SerialName("ResponseMessageData") 79 | data class ResponseMessageData(val message: String?) : ResponseDataDTO() 80 | 81 | @Serializable 82 | @SerialName("MessageData") 83 | data class MessageData(val message_id: Int) : ResponseDataDTO() 84 | 85 | @Serializable 86 | @SerialName("GetMessageData") 87 | data class GetMessageData( 88 | val time: Long, 89 | val message_type: String, 90 | val message_id: Int, 91 | val real_id: Int, 92 | val sender: ContactDTO, 93 | val message: MessageChainOrStringDTO 94 | ) : ResponseDataDTO() 95 | 96 | @Serializable 97 | @SerialName("LoginInfoData") 98 | data class LoginInfoData(val user_id: Long, val nickname: String) : ResponseDataDTO() 99 | 100 | @Serializable 101 | @SerialName("StrangerInfoData") 102 | data class StrangerInfoData( 103 | val user_id: Long, 104 | val nickname: String, 105 | val sex: String = "unknown", 106 | val age: Int = 0 107 | ) : ResponseDataDTO() 108 | 109 | @Serializable 110 | @SerialName("FriendData") 111 | data class FriendData(val user_id: Long, val nickname: String, val remark: String) : ResponseDataDTO() 112 | 113 | @Serializable 114 | @SerialName("GroupData") 115 | data class GroupData(val group_id: Long, val group_name: String) : ResponseDataDTO() 116 | 117 | @Serializable 118 | @SerialName("GroupInfoData") 119 | data class GroupInfoData( 120 | val group_id: Long, 121 | val group_name: String, 122 | val member_count: Int, 123 | val max_member_count: Int 124 | ) : ResponseDataDTO() 125 | 126 | @Serializable 127 | @SerialName("MemberInfoData") 128 | data class MemberInfoData( 129 | val group_id: Long, 130 | val user_id: Long, 131 | val nickname: String, 132 | val card: String, 133 | var sex: String = "unknown", 134 | var age: Int = 0, 135 | var join_time: Int = 0, 136 | var last_sent_time: Int = 0, 137 | var level: String = "unknown", 138 | val role: String, 139 | val unfriendly: Boolean = false, 140 | val title: String, 141 | val title_expire_time: Int = 0, 142 | val card_changeable: Boolean 143 | ) : ResponseDataDTO() { 144 | constructor(member: Member) : this( 145 | member.group.id, 146 | member.id, 147 | member.nick, 148 | member.nameCard, 149 | "unknown", 150 | 0, 151 | 0, 152 | 0, 153 | "unknown", 154 | if (member.permission == MemberPermission.ADMINISTRATOR) "admin" else member.permission.name.lowercase(), 155 | false, 156 | member.specialTitle, 157 | 0, 158 | member.group.botPermission == MemberPermission.ADMINISTRATOR || member.group.botPermission == MemberPermission.OWNER 159 | ) 160 | } 161 | 162 | @Serializable 163 | @SerialName("ImageInfoData") 164 | data class ImageInfoData( 165 | val file: String, 166 | @SerialName("filename") val fileName: String, 167 | val md5: String, 168 | val size: Int, 169 | val url: String, 170 | @SerialName("add_time") val addTime: Long, 171 | @SerialName("file_type") val fileType: String, 172 | ) : ResponseDataDTO() 173 | 174 | @Serializable 175 | @SerialName("RecordInfoData") 176 | data class RecordInfoData( 177 | val file: String, 178 | @SerialName("filename") val fileName: String, 179 | val md5: String, 180 | @SerialName("file_type") val fileType: String, 181 | ) : ResponseDataDTO() 182 | 183 | @Serializable 184 | @SerialName("CanSendImageData") 185 | data class CanSendImageData(val yes: Boolean = true) : ResponseDataDTO() 186 | 187 | @Serializable 188 | @SerialName("CanSendRecordData") 189 | data class CanSendRecordData(val yes: Boolean = true) : ResponseDataDTO() 190 | 191 | @Serializable 192 | @SerialName("PluginStatusData") 193 | data class PluginStatusData( 194 | val app_initialized: Boolean = true, 195 | val app_enabled: Boolean = true, 196 | val plugins_good: PluginsGoodData = PluginsGoodData(), 197 | val app_good: Boolean = true, 198 | val online: Boolean = true, 199 | val good: Boolean = true 200 | ) : ResponseDataDTO() 201 | 202 | @Serializable 203 | @SerialName("PluginsGoodData") 204 | data class PluginsGoodData( 205 | val asyncActions: Boolean = true, 206 | val backwardCompatibility: Boolean = true, 207 | val defaultConfigGenerator: Boolean = true, 208 | val eventDataPatcher: Boolean = true, 209 | val eventFilter: Boolean = true, 210 | val experimentalActions: Boolean = true, 211 | val extensionLoader: Boolean = true, 212 | val heartbeatGenerator: Boolean = true, 213 | val http: Boolean = true, 214 | val iniConfigLoader: Boolean = true, 215 | val jsonConfigLoader: Boolean = true, 216 | val loggers: Boolean = true, 217 | val messageEnhancer: Boolean = true, 218 | val postMessageFormatter: Boolean = true, 219 | val rateLimitedActions: Boolean = true, 220 | val restarter: Boolean = true, 221 | val updater: Boolean = true, 222 | val websocket: Boolean = true, 223 | val websocketReverse: Boolean = true, 224 | val workerPoolResizer: Boolean = true, 225 | ) : ResponseDataDTO() 226 | 227 | @Serializable 228 | @SerialName("VersionInfoData") 229 | data class VersionInfoData( 230 | val coolq_directory: String = "", 231 | val coolq_edition: String = "pro", 232 | val plugin_version: String = "4.15.0", 233 | val plugin_build_number: Int = 99, 234 | val plugin_build_configuration: String = "release", 235 | val app_name: String = "onebot-mirai", 236 | val app_version: String = BuildConfig.VERSION, 237 | val app_build_version: String = BuildConfig.COMMIT_HASH, 238 | val protocol_version: String = "v10", 239 | ) : ResponseDataDTO() 240 | 241 | @Serializable 242 | @SerialName("HonorInfoData") 243 | data class GroupHonorInfoData( 244 | @SerialName("accept_languages") val acceptLanguages: List? = null, 245 | @SerialName("group_id") @SerializedName("gc") 246 | val groupId: String?, 247 | val type: Int?, 248 | @SerialName("user_id") 249 | val uin: String?, 250 | @SerialName("talkative_list") 251 | val talkativeList: List? = null, 252 | @SerialName("current_talkative") 253 | val currentTalkative: CurrentTalkative? = null, 254 | @SerialName("actor_list") 255 | val actorList: List? = null, 256 | @SerialName("legend_list") 257 | var legendList: List? = null, 258 | @SerialName("performer_list") 259 | var performerList: List? = null, 260 | @SerialName("newbie_list") 261 | val newbieList: List? = null, 262 | @SerialName("strong_newbie_list") @SerializedName("strongnewbieList") 263 | var strongNewbieList: List? = null, 264 | @SerialName("emotion_list") 265 | var emotionList: List? = null, 266 | @SerialName("level_name") @SerializedName("levelname") 267 | val levelName: LevelName? = null, 268 | @SerialName("manage_list") 269 | var manageList: List? = null, 270 | @SerialName("exclusive_list") 271 | var exclusiveList: List? = null, 272 | @SerialName("active_obj") 273 | var activeObj: Map?>? = null, // Key为活跃等级名, 如`冒泡` 274 | @SerialName("show_active_obj") 275 | var showActiveObj: Map? = null, 276 | @SerialName("my_title") 277 | val myTitle: String?, 278 | @SerialName("my_index") 279 | val myIndex: Int? = 0, 280 | @SerialName("my_avatar") 281 | val myAvatar: String?, 282 | @SerialName("has_server_error") 283 | val hasServerError: Boolean?, 284 | @SerialName("hw_excellent_list") 285 | val hwExcellentList: List? = null 286 | ) : ResponseDataDTO() { 287 | @Serializable 288 | data class Language( 289 | @SerialName("code") 290 | val code: String? = null, 291 | @SerialName("script") 292 | val script: String? = null, 293 | @SerialName("region") 294 | val region: String? = null, 295 | @SerialName("quality") 296 | val quality: Double? = null 297 | ) 298 | 299 | @Serializable 300 | data class Actor( 301 | @SerialName("user_id") 302 | val uin: Long? = 0, 303 | @SerialName("avatar") 304 | val avatar: String? = null, 305 | @SerialName("nickname") 306 | val name: String? = null, 307 | @SerialName("description") 308 | val desc: String? = null, 309 | @SerialName("btn_text") 310 | val btnText: String? = null, 311 | @SerialName("text") 312 | val text: String? = null, 313 | @SerialName("icon") 314 | val icon: Int? 315 | ) 316 | 317 | @Serializable 318 | data class Talkative( 319 | @SerialName("user_id") 320 | val uin: Long? = 0, 321 | @SerialName("avatar") 322 | val avatar: String? = null, 323 | @SerialName("nickname") 324 | val name: String? = null, 325 | @SerialName("description") 326 | val desc: String? = null, 327 | @SerialName("btn_text") 328 | val btnText: String? = null, 329 | @SerialName("text") 330 | val text: String? = null 331 | ) 332 | 333 | @Serializable 334 | data class CurrentTalkative( 335 | @SerialName("user_id") 336 | val uin: Long? = 0, 337 | @SerialName("day_count") @SerializedName("day_count") 338 | val dayCount: Int? = null, 339 | @SerialName("avatar") 340 | val avatar: String? = null, 341 | @SerialName("avatar_size") @SerializedName("avatar_size") 342 | val avatarSize: Int? = null, 343 | @SerialName("nickname") 344 | val name: String? = null 345 | ) 346 | 347 | @Serializable 348 | data class LevelName( 349 | @SerialName("lvln1") @SerializedName("lvln1") 350 | val lv1: String? = null, 351 | @SerialName("lvln2") @SerializedName("lvln2") 352 | val lv2: String? = null, 353 | @SerialName("lvln3") @SerializedName("lvln3") 354 | val lv3: String? = null, 355 | @SerialName("lvln4") @SerializedName("lvln4") 356 | val lv4: String? = null, 357 | @SerialName("lvln5") @SerializedName("lvln5") 358 | val lv5: String? = null, 359 | @SerialName("lvln6") @SerializedName("lvln6") 360 | val lv6: String? = null 361 | ) 362 | 363 | @Serializable 364 | data class Tag( 365 | @SerialName("user_id") 366 | val uin: Long? = 0, 367 | @SerialName("avatar") 368 | val avatar: String? = null, 369 | @SerialName("nickname") 370 | val name: String? = null, 371 | @SerialName("btn_text") 372 | val btnText: String? = null, 373 | @SerialName("text") 374 | val text: String? = null, 375 | @SerialName("tag") 376 | val tag: String? = null, // 头衔 377 | @SerialName("tag_color") 378 | val tagColor: String? = null 379 | ) 380 | } -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/web/http/HttpApiModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Mamoe Technologies and contributors. 3 | * 4 | * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. 5 | * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link. 6 | * 7 | * https://github.com/mamoe/mirai/blob/master/LICENSE 8 | */ 9 | 10 | package com.github.yyuueexxiinngg.onebot.web.http 11 | 12 | import com.github.yyuueexxiinngg.onebot.BotSession 13 | import com.github.yyuueexxiinngg.onebot.PluginSettings 14 | import com.github.yyuueexxiinngg.onebot.callMiraiApi 15 | import com.github.yyuueexxiinngg.onebot.data.common.ResponseDTO 16 | import com.github.yyuueexxiinngg.onebot.logger 17 | import com.github.yyuueexxiinngg.onebot.util.toJson 18 | import io.ktor.application.* 19 | import io.ktor.features.* 20 | import io.ktor.http.* 21 | import io.ktor.request.* 22 | import io.ktor.response.* 23 | import io.ktor.routing.* 24 | import io.ktor.util.* 25 | import io.ktor.util.pipeline.* 26 | import kotlinx.coroutines.CoroutineScope 27 | import kotlinx.coroutines.launch 28 | import kotlinx.serialization.json.Json 29 | import kotlinx.serialization.json.JsonElement 30 | import kotlinx.serialization.json.JsonObject 31 | import kotlinx.serialization.json.jsonObject 32 | import java.nio.charset.Charset 33 | import kotlin.coroutines.EmptyCoroutineContext 34 | 35 | fun Application.oneBotApiServer(session: BotSession, settings: PluginSettings.HTTPSettings) { 36 | install(CallLogging) 37 | // it.second -> if is async call 38 | routing { 39 | oneBotApi("/send_msg", settings) { 40 | val responseDTO = callMiraiApi("send_msg", it.first, session.apiImpl) 41 | if (!it.second) call.responseDTO(responseDTO) 42 | } 43 | oneBotApi("/send_private_msg", settings) { 44 | val responseDTO = callMiraiApi("send_private_msg", it.first, session.apiImpl) 45 | if (!it.second) call.responseDTO(responseDTO) 46 | } 47 | oneBotApi("/send_group_msg", settings) { 48 | val responseDTO = callMiraiApi("send_group_msg", it.first, session.apiImpl) 49 | if (!it.second) call.responseDTO(responseDTO) 50 | } 51 | oneBotApi("/send_discuss_msg", settings) { 52 | val responseDTO = callMiraiApi("send_discuss_msg", it.first, session.apiImpl) 53 | if (!it.second) call.responseDTO(responseDTO) 54 | } 55 | oneBotApi("/delete_msg", settings) { 56 | val responseDTO = callMiraiApi("delete_msg", it.first, session.apiImpl) 57 | if (!it.second) call.responseDTO(responseDTO) 58 | } 59 | oneBotApi("/send_like", settings) { 60 | val responseDTO = callMiraiApi("send_like", it.first, session.apiImpl) 61 | if (!it.second) call.responseDTO(responseDTO) 62 | } 63 | oneBotApi("/set_group_kick", settings) { 64 | val responseDTO = callMiraiApi("set_group_kick", it.first, session.apiImpl) 65 | if (!it.second) call.responseDTO(responseDTO) 66 | } 67 | oneBotApi("/set_group_ban", settings) { 68 | val responseDTO = callMiraiApi("set_group_ban", it.first, session.apiImpl) 69 | if (!it.second) call.responseDTO(responseDTO) 70 | } 71 | oneBotApi("/set_group_anonymous_ban", settings) { 72 | val responseDTO = callMiraiApi("set_group_anonymous_ban", it.first, session.apiImpl) 73 | if (!it.second) call.responseDTO(responseDTO) 74 | } 75 | oneBotApi("/set_group_whole_ban", settings) { 76 | val responseDTO = callMiraiApi("set_group_whole_ban", it.first, session.apiImpl) 77 | if (!it.second) call.responseDTO(responseDTO) 78 | } 79 | oneBotApi("/set_group_admin", settings) { 80 | val responseDTO = callMiraiApi("set_group_admin", it.first, session.apiImpl) 81 | if (!it.second) call.responseDTO(responseDTO) 82 | } 83 | oneBotApi("/set_group_anonymous", settings) { 84 | val responseDTO = callMiraiApi("set_group_anonymous", it.first, session.apiImpl) 85 | if (!it.second) call.responseDTO(responseDTO) 86 | } 87 | oneBotApi("/set_group_card", settings) { 88 | val responseDTO = callMiraiApi("set_group_card", it.first, session.apiImpl) 89 | if (!it.second) call.responseDTO(responseDTO) 90 | } 91 | oneBotApi("/set_group_leave", settings) { 92 | val responseDTO = callMiraiApi("set_group_leave", it.first, session.apiImpl) 93 | if (!it.second) call.responseDTO(responseDTO) 94 | } 95 | oneBotApi("/set_group_special_title", settings) { 96 | val responseDTO = callMiraiApi("set_group_special_title", it.first, session.apiImpl) 97 | if (!it.second) call.responseDTO(responseDTO) 98 | } 99 | oneBotApi("/set_discuss_leave", settings) { 100 | val responseDTO = callMiraiApi("set_discuss_leave", it.first, session.apiImpl) 101 | if (!it.second) call.responseDTO(responseDTO) 102 | } 103 | oneBotApi("/set_friend_add_request", settings) { 104 | val responseDTO = callMiraiApi("set_friend_add_request", it.first, session.apiImpl) 105 | if (!it.second) call.responseDTO(responseDTO) 106 | } 107 | oneBotApi("/set_group_add_request", settings) { 108 | val responseDTO = callMiraiApi("set_group_add_request", it.first, session.apiImpl) 109 | if (!it.second) call.responseDTO(responseDTO) 110 | } 111 | oneBotApi("/get_login_info", settings) { 112 | val responseDTO = callMiraiApi("get_login_info", it.first, session.apiImpl) 113 | if (!it.second) call.responseDTO(responseDTO) 114 | } 115 | oneBotApi("/get_stranger_info", settings) { 116 | val responseDTO = callMiraiApi("get_stranger_info", it.first, session.apiImpl) 117 | if (!it.second) call.responseDTO(responseDTO) 118 | } 119 | oneBotApi("/get_friend_list", settings) { 120 | val responseDTO = callMiraiApi("get_friend_list", it.first, session.apiImpl) 121 | if (!it.second) call.responseDTO(responseDTO) 122 | } 123 | oneBotApi("/get_group_list", settings) { 124 | val responseDTO = callMiraiApi("get_group_list", it.first, session.apiImpl) 125 | if (!it.second) call.responseDTO(responseDTO) 126 | } 127 | oneBotApi("/get_group_info", settings) { 128 | val responseDTO = callMiraiApi("get_group_info", it.first, session.apiImpl) 129 | if (!it.second) call.responseDTO(responseDTO) 130 | } 131 | oneBotApi("/get_group_member_info", settings) { 132 | val responseDTO = callMiraiApi("get_group_member_info", it.first, session.apiImpl) 133 | if (!it.second) call.responseDTO(responseDTO) 134 | } 135 | oneBotApi("/get_group_member_list", settings) { 136 | val responseDTO = callMiraiApi("get_group_member_list", it.first, session.apiImpl) 137 | if (!it.second) call.responseDTO(responseDTO) 138 | } 139 | oneBotApi("/get_cookies", settings) { 140 | val responseDTO = callMiraiApi("get_cookies", it.first, session.apiImpl) 141 | if (!it.second) call.responseDTO(responseDTO) 142 | } 143 | oneBotApi("/get_csrf_token", settings) { 144 | val responseDTO = callMiraiApi("get_csrf_token", it.first, session.apiImpl) 145 | if (!it.second) call.responseDTO(responseDTO) 146 | } 147 | oneBotApi("/get_credentials", settings) { 148 | val responseDTO = callMiraiApi("get_credentials", it.first, session.apiImpl) 149 | if (!it.second) call.responseDTO(responseDTO) 150 | } 151 | oneBotApi("/get_record", settings) { 152 | val responseDTO = callMiraiApi("get_record", it.first, session.apiImpl) 153 | if (!it.second) call.responseDTO(responseDTO) 154 | } 155 | oneBotApi("/get_image", settings) { 156 | val responseDTO = callMiraiApi("get_image", it.first, session.apiImpl) 157 | if (!it.second) call.responseDTO(responseDTO) 158 | } 159 | oneBotApi("/can_send_image", settings) { 160 | val responseDTO = callMiraiApi("can_send_image", it.first, session.apiImpl) 161 | if (!it.second) call.responseDTO(responseDTO) 162 | } 163 | oneBotApi("/can_send_record", settings) { 164 | val responseDTO = callMiraiApi("can_send_record", it.first, session.apiImpl) 165 | if (!it.second) call.responseDTO(responseDTO) 166 | } 167 | oneBotApi("/get_status", settings) { 168 | val responseDTO = callMiraiApi("get_status", it.first, session.apiImpl) 169 | if (!it.second) call.responseDTO(responseDTO) 170 | } 171 | oneBotApi("/get_version_info", settings) { 172 | val responseDTO = callMiraiApi("get_version_info", it.first, session.apiImpl) 173 | if (!it.second) call.responseDTO(responseDTO) 174 | } 175 | oneBotApi("/set_restart_plugin", settings) { 176 | val responseDTO = callMiraiApi("set_restart_plugin", it.first, session.apiImpl) 177 | if (!it.second) call.responseDTO(responseDTO) 178 | } 179 | oneBotApi("/clean_data_dir", settings) { 180 | val responseDTO = callMiraiApi("clean_data_dir", it.first, session.apiImpl) 181 | if (!it.second) call.responseDTO(responseDTO) 182 | } 183 | oneBotApi("/clean_plugin_log", settings) { 184 | val responseDTO = callMiraiApi("clean_plugin_log", it.first, session.apiImpl) 185 | if (!it.second) call.responseDTO(responseDTO) 186 | } 187 | oneBotApi("/.handle_quick_operation", settings) { 188 | val responseDTO = callMiraiApi(".handle_quick_operation", it.first, session.apiImpl) 189 | if (!it.second) call.responseDTO(responseDTO) 190 | } 191 | 192 | //////////////// 193 | //// v11 //// 194 | ////////////// 195 | 196 | oneBotApi("/set_group_name", settings) { 197 | val responseDTO = callMiraiApi("set_group_name", it.first, session.apiImpl) 198 | if (!it.second) call.responseDTO(responseDTO) 199 | } 200 | 201 | oneBotApi("/get_group_honor_info", settings) { 202 | val responseDTO = callMiraiApi("get_group_honor_info", it.first, session.apiImpl) 203 | if (!it.second) call.responseDTO(responseDTO) 204 | } 205 | 206 | oneBotApi("/get_msg", settings) { 207 | val responseDTO = callMiraiApi("get_msg", it.first, session.apiImpl) 208 | if (!it.second) call.responseDTO(responseDTO) 209 | } 210 | 211 | ///////////////// 212 | //// hidden //// 213 | /////////////// 214 | 215 | oneBotApi("/_set_group_announcement", settings) { 216 | val responseDTO = callMiraiApi("_set_group_announcement", it.first, session.apiImpl) 217 | if (!it.second) call.responseDTO(responseDTO) 218 | } 219 | } 220 | } 221 | 222 | internal suspend fun ApplicationCall.responseDTO(dto: ResponseDTO) { 223 | val jsonToSend = dto.toJson() 224 | logger.debug("HTTP API response: $jsonToSend") 225 | respondText(jsonToSend, defaultTextContentType(ContentType("application", "json"))) 226 | } 227 | 228 | suspend fun checkAccessToken(call: ApplicationCall, settings: PluginSettings.HTTPSettings): Boolean { 229 | if (settings.accessToken != "") { 230 | val accessToken = 231 | call.parameters["access_token"] ?: call.request.headers["Authorization"]?.let { 232 | Regex("""(?:[Tt]oken|Bearer)\s+(.*)""").find(it)?.groupValues?.get(1) 233 | } 234 | if (accessToken != null) { 235 | if (accessToken != settings.accessToken) { 236 | call.respond(HttpStatusCode.Forbidden) 237 | return false 238 | } 239 | } else { 240 | call.respond(HttpStatusCode.Unauthorized) 241 | return false 242 | } 243 | } 244 | return true 245 | } 246 | 247 | fun paramsToJson(params: Parameters): JsonObject { 248 | /* val parsed = "{\"" + URLDecoder.decode(params.formUrlEncode(), "UTF-8") 249 | .replace("\"", "\\\"") 250 | .replace("&", "\",\"") 251 | .replace("=", "\":\"") + "\"}"*/ 252 | val mapped = params.toMap().map { it.key to (it.value[0].toLongOrNull() ?: it.value[0]) } 253 | var parsed = "{" 254 | mapped.forEach { 255 | parsed += "\"${it.first}\":" 256 | parsed += if (it.second is String) 257 | "\"${it.second}\"" 258 | else 259 | "${it.second}" 260 | 261 | if (it != mapped.last()) 262 | parsed += "," 263 | } 264 | parsed += "}" 265 | logger.debug("HTTP API Received: $parsed") 266 | return Json.parseToJsonElement(parsed).jsonObject 267 | } 268 | 269 | internal inline fun Route.oneBotApi( 270 | path: String, 271 | settings: PluginSettings.HTTPSettings, 272 | crossinline body: suspend PipelineContext.(Pair, Boolean>) -> Unit 273 | ) { 274 | route(path) { 275 | get { 276 | if (checkAccessToken(call, settings)) { 277 | body(Pair(paramsToJson(call.parameters), false)) 278 | } 279 | } 280 | post { 281 | if (checkAccessToken(call, settings)) { 282 | val contentType = call.request.contentType() 283 | when { 284 | contentType.contentSubtype.contains("form-urlencoded") -> { 285 | body(Pair(paramsToJson(call.receiveParameters()), false)) 286 | } 287 | contentType.contentSubtype.contains("json") -> { 288 | body(Pair(Json.parseToJsonElement(call.receiveTextWithCorrectEncoding()).jsonObject, false)) 289 | } 290 | else -> { 291 | call.respond(HttpStatusCode.BadRequest) 292 | } 293 | } 294 | } 295 | } 296 | } 297 | 298 | route("${path}_async") { 299 | get { 300 | if (checkAccessToken(call, settings)) { 301 | val req = call.parameters 302 | call.responseDTO(ResponseDTO.AsyncStarted()) 303 | CoroutineScope(EmptyCoroutineContext).launch { 304 | body(Pair(paramsToJson(req), true)) 305 | } 306 | } 307 | } 308 | post { 309 | if (checkAccessToken(call, settings)) { 310 | val contentType = call.request.contentType() 311 | when { 312 | contentType.contentSubtype.contains("form-urlencoded") -> { 313 | body(Pair(paramsToJson(call.receiveParameters()), true)) 314 | } 315 | contentType.contentSubtype.contains("json") -> { 316 | val req = call.receiveTextWithCorrectEncoding() 317 | call.responseDTO(ResponseDTO.AsyncStarted()) 318 | CoroutineScope(EmptyCoroutineContext).launch { 319 | body(Pair(Json.parseToJsonElement(req).jsonObject, true)) 320 | } 321 | } 322 | else -> { 323 | call.respond(HttpStatusCode.BadRequest) 324 | } 325 | } 326 | } 327 | } 328 | } 329 | } 330 | 331 | // https://github.com/ktorio/ktor/issues/384#issuecomment-458542686 332 | /** 333 | * Receive the request as String. 334 | * If there is no Content-Type in the HTTP header specified use ISO_8859_1 as default charset, see https://www.w3.org/International/articles/http-charset/index#charset. 335 | * But use UTF-8 as default charset for application/json, see https://tools.ietf.org/html/rfc4627#section-3 336 | */ 337 | private suspend fun ApplicationCall.receiveTextWithCorrectEncoding(): String { 338 | fun ContentType.defaultCharset(): Charset = when (this) { 339 | ContentType.Application.Json -> Charsets.UTF_8 340 | else -> Charsets.ISO_8859_1 341 | } 342 | 343 | val contentType = request.contentType() 344 | val suitableCharset = contentType.charset() ?: contentType.defaultCharset() 345 | return receiveStream().bufferedReader(charset = suitableCharset).readText() 346 | } -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/data/common/BotEventDTO.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Mamoe Technologies and contributors. 3 | * 4 | * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. 5 | * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link. 6 | * 7 | * https://github.com/mamoe/mirai/blob/master/LICENSE 8 | */ 9 | 10 | package com.github.yyuueexxiinngg.onebot.data.common 11 | 12 | import com.github.yyuueexxiinngg.onebot.logger 13 | import com.github.yyuueexxiinngg.onebot.util.currentTimeSeconds 14 | import com.github.yyuueexxiinngg.onebot.util.toMessageId 15 | import kotlinx.serialization.SerialName 16 | import kotlinx.serialization.Serializable 17 | import net.mamoe.mirai.contact.Group 18 | import net.mamoe.mirai.contact.MemberPermission 19 | import net.mamoe.mirai.event.events.* 20 | import net.mamoe.mirai.utils.MiraiExperimentalApi 21 | 22 | @Serializable 23 | sealed class BotEventDTO : EventDTO() 24 | 25 | @OptIn(MiraiExperimentalApi::class) 26 | suspend fun BotEvent.toDTO(isRawMessage: Boolean = false): EventDTO { 27 | return when (this) { 28 | is MessageEvent -> this.toDTO(isRawMessage) 29 | is MemberJoinEvent -> { 30 | when (this) { 31 | is MemberJoinEvent.Active -> MemberJoinEventDTO( 32 | self_id = bot.id, 33 | sub_type = "approve", 34 | group_id = group.id, 35 | operator_id = 0L, // Not available in Mirai 36 | user_id = member.id, 37 | time = currentTimeSeconds() 38 | ) 39 | is MemberJoinEvent.Invite -> MemberJoinEventDTO( 40 | self_id = bot.id, 41 | sub_type = "invite", 42 | group_id = group.id, 43 | operator_id = 0L, // Not available in Mirai 44 | user_id = member.id, 45 | time = currentTimeSeconds() 46 | ) 47 | else -> IgnoreEventDTO(bot.id) 48 | } 49 | } 50 | is MemberLeaveEvent -> { 51 | when (this) { 52 | is MemberLeaveEvent.Quit -> MemberLeaveEventDTO( 53 | self_id = bot.id, 54 | sub_type = "leave", 55 | group_id = group.id, 56 | operator_id = member.id, 57 | user_id = member.id, 58 | time = currentTimeSeconds() 59 | ) 60 | is MemberLeaveEvent.Kick -> MemberLeaveEventDTO( 61 | self_id = bot.id, 62 | sub_type = "kick", 63 | group_id = group.id, 64 | operator_id = operator?.id ?: bot.id, 65 | user_id = member.id, 66 | time = currentTimeSeconds() 67 | ) 68 | else -> IgnoreEventDTO(bot.id) 69 | } 70 | } 71 | is BotJoinGroupEvent.Active -> MemberJoinEventDTO( 72 | self_id = bot.id, 73 | sub_type = "approve", 74 | group_id = group.id, 75 | operator_id = 0L, // Not available in Mirai 76 | user_id = bot.id, 77 | time = currentTimeSeconds() 78 | ) 79 | is BotJoinGroupEvent.Invite -> MemberJoinEventDTO( 80 | self_id = bot.id, 81 | sub_type = "invite", 82 | group_id = group.id, 83 | operator_id = 0L, // Not available in Mirai 84 | user_id = bot.id, 85 | time = currentTimeSeconds() 86 | ) 87 | 88 | is BotLeaveEvent -> { 89 | when (this) { 90 | is BotLeaveEvent.Kick -> MemberLeaveEventDTO( 91 | self_id = bot.id, 92 | sub_type = "kick_me", 93 | group_id = group.id, 94 | operator_id = 0L, // Retrieve operator is currently not supported 95 | user_id = bot.id, 96 | time = currentTimeSeconds() 97 | ) 98 | is BotLeaveEvent.Active -> MemberLeaveEventDTO( 99 | self_id = bot.id, 100 | sub_type = "kick_me", 101 | group_id = group.id, 102 | operator_id = 0L, // Retrieve operator is currently not supported 103 | user_id = bot.id, 104 | time = currentTimeSeconds() 105 | ) 106 | else -> IgnoreEventDTO(bot.id) 107 | } 108 | } 109 | is MemberPermissionChangeEvent -> 110 | when (this.new) { 111 | MemberPermission.MEMBER -> GroupAdministratorChangeEventDTO( 112 | self_id = bot.id, 113 | sub_type = "unset", 114 | group_id = group.id, 115 | user_id = member.id, 116 | time = currentTimeSeconds() 117 | ) 118 | else -> GroupAdministratorChangeEventDTO( 119 | self_id = bot.id, 120 | sub_type = "set", 121 | group_id = group.id, 122 | user_id = member.id, 123 | time = currentTimeSeconds() 124 | ) 125 | } 126 | is MemberMuteEvent -> GroupMuteChangeEventDTO( 127 | self_id = bot.id, 128 | sub_type = "ban", 129 | group_id = group.id, 130 | operator_id = operator?.id ?: bot.id, 131 | user_id = member.id, 132 | duration = durationSeconds, 133 | time = currentTimeSeconds() 134 | ) 135 | is GroupMuteAllEvent -> { 136 | if (new) { 137 | GroupMuteChangeEventDTO( 138 | self_id = bot.id, 139 | sub_type = "ban", 140 | group_id = group.id, 141 | operator_id = operator?.id ?: bot.id, 142 | user_id = 0L, 143 | duration = 0, 144 | time = currentTimeSeconds() 145 | ) 146 | } else { 147 | GroupMuteChangeEventDTO( 148 | self_id = bot.id, 149 | sub_type = "lift_ban", 150 | group_id = group.id, 151 | operator_id = operator?.id ?: bot.id, 152 | user_id = 0L, 153 | duration = 0, 154 | time = currentTimeSeconds() 155 | ) 156 | } 157 | } 158 | is BotMuteEvent -> GroupMuteChangeEventDTO( 159 | self_id = bot.id, 160 | sub_type = "ban", 161 | group_id = group.id, 162 | operator_id = operator.id, 163 | user_id = bot.id, 164 | duration = durationSeconds, 165 | time = currentTimeSeconds() 166 | ) 167 | is MemberUnmuteEvent -> GroupMuteChangeEventDTO( 168 | self_id = bot.id, 169 | sub_type = "lift_ban", 170 | group_id = group.id, 171 | operator_id = operator?.id ?: bot.id, 172 | user_id = member.id, 173 | duration = 0, 174 | time = currentTimeSeconds() 175 | ) 176 | is BotUnmuteEvent -> GroupMuteChangeEventDTO( 177 | self_id = bot.id, 178 | sub_type = "lift_ban", 179 | group_id = group.id, 180 | operator_id = operator.id, 181 | user_id = bot.id, 182 | duration = 0, 183 | time = currentTimeSeconds() 184 | ) 185 | is FriendAddEvent -> FriendAddEventDTO( 186 | self_id = bot.id, 187 | user_id = friend.id, 188 | time = currentTimeSeconds() 189 | ) 190 | is NewFriendRequestEvent -> FriendRequestEventDTO( 191 | self_id = bot.id, 192 | user_id = fromId, 193 | comment = message, 194 | flag = eventId.toString(), 195 | time = currentTimeSeconds() 196 | ) 197 | is MemberJoinRequestEvent -> GroupMemberAddRequestEventDTO( 198 | self_id = bot.id, 199 | sub_type = "add", 200 | group_id = groupId, 201 | user_id = fromId, 202 | comment = message, 203 | flag = eventId.toString(), 204 | time = currentTimeSeconds() 205 | ) 206 | is BotInvitedJoinGroupRequestEvent -> GroupMemberAddRequestEventDTO( 207 | self_id = bot.id, 208 | sub_type = "invite", 209 | group_id = groupId, 210 | user_id = invitorId, 211 | comment = "", 212 | flag = eventId.toString(), 213 | time = currentTimeSeconds() 214 | ) 215 | is NudgeEvent -> { 216 | when (subject) { 217 | is Group -> GroupMemberNudgedEventDTO( 218 | self_id = bot.id, 219 | group_id = subject.id, 220 | user_id = from.id, 221 | target_id = target.id, 222 | time = currentTimeSeconds() 223 | ) 224 | else -> { 225 | // OneBot not yet provides private nudged event standard. 226 | logger.info("私聊被戳事件已被插件忽略: $this") 227 | IgnoreEventDTO(bot.id) 228 | } 229 | } 230 | } 231 | is MessageRecallEvent -> { 232 | when (this) { 233 | is MessageRecallEvent.GroupRecall -> { 234 | GroupMessageRecallEventDTO( 235 | self_id = bot.id, 236 | group_id = group.id, 237 | user_id = authorId, 238 | operator_id = operator?.id ?: bot.id, 239 | message_id = messageInternalIds.toMessageId(bot.id, operator?.id ?: bot.id), 240 | time = currentTimeSeconds() 241 | ) 242 | } 243 | is MessageRecallEvent.FriendRecall -> { 244 | FriendMessageRecallEventDTO( 245 | self_id = bot.id, 246 | user_id = operatorId, 247 | message_id = messageInternalIds.toMessageId(bot.id, operatorId), 248 | time = currentTimeSeconds() 249 | ) 250 | } 251 | else -> { 252 | logger.debug("发生讨论组消息撤回事件, 已被插件忽略: $this") 253 | IgnoreEventDTO(bot.id) 254 | } 255 | } 256 | } 257 | is MemberHonorChangeEvent -> { 258 | MemberHonorChangeEventDTO( 259 | self_id = bot.id, 260 | user_id = user.id, 261 | group_id = group.id, 262 | honor_type = honorType.name.lowercase(), 263 | time = currentTimeSeconds() 264 | ) 265 | } 266 | else -> { 267 | logger.debug("发生了被插件忽略的事件: $this") 268 | IgnoreEventDTO(bot.id) 269 | } 270 | } 271 | } 272 | 273 | 274 | @Serializable 275 | @SerialName("LifecycleMetaEvent") 276 | data class LifecycleMetaEventDTO( 277 | override var self_id: Long, 278 | val sub_type: String, // enable、disable、connect 279 | override var time: Long 280 | ) : BotEventDTO() { 281 | override var post_type: String = "meta_event" 282 | val meta_event_type: String = "lifecycle" 283 | } 284 | 285 | @Serializable 286 | @SerialName("HeartbeatMetaEvent") 287 | data class HeartbeatMetaEventDTO( 288 | override var self_id: Long, 289 | override var time: Long, 290 | val status: PluginStatusData, 291 | val interval: Long, 292 | ) : BotEventDTO() { 293 | override var post_type: String = "meta_event" 294 | val meta_event_type: String = "heartbeat" 295 | } 296 | 297 | @Serializable 298 | @SerialName("MemberJoinEvent") 299 | data class MemberJoinEventDTO( 300 | override var self_id: Long, 301 | val sub_type: String, // approve、invite 302 | val group_id: Long, 303 | val operator_id: Long, 304 | val user_id: Long, 305 | override var time: Long 306 | ) : BotEventDTO() { 307 | override var post_type: String = "notice" 308 | val notice_type: String = "group_increase" 309 | } 310 | 311 | @Serializable 312 | @SerialName("MemberLeaveEvent") 313 | data class MemberLeaveEventDTO( 314 | override var self_id: Long, 315 | val sub_type: String, // leave、kick、kick_me 316 | val group_id: Long, 317 | val operator_id: Long, 318 | val user_id: Long, 319 | override var time: Long 320 | ) : BotEventDTO() { 321 | override var post_type: String = "notice" 322 | val notice_type: String = "group_decrease" 323 | } 324 | 325 | 326 | @Serializable 327 | @SerialName("GroupAdministratorChangeEvent") 328 | data class GroupAdministratorChangeEventDTO( 329 | override var self_id: Long, 330 | val sub_type: String, // set、unset 331 | val group_id: Long, 332 | val user_id: Long, 333 | override var time: Long 334 | ) : BotEventDTO() { 335 | override var post_type: String = "notice" 336 | val notice_type: String = "group_admin" 337 | } 338 | 339 | @Serializable 340 | @SerialName("GroupMuteChangeEvent") 341 | data class GroupMuteChangeEventDTO( 342 | override var self_id: Long, 343 | val sub_type: String, // ban、lift_ban 344 | val group_id: Long, 345 | val operator_id: Long, 346 | val user_id: Long, // Mute all = 0F 347 | val duration: Int, 348 | override var time: Long 349 | ) : BotEventDTO() { 350 | override var post_type: String = "notice" 351 | val notice_type: String = "group_ban" 352 | } 353 | 354 | @Serializable 355 | @SerialName("FriendAddEvent") 356 | data class FriendAddEventDTO( 357 | override var self_id: Long, 358 | val user_id: Long, 359 | override var time: Long 360 | ) : BotEventDTO() { 361 | override var post_type: String = "notice" 362 | val notice_type: String = "friend_add" 363 | } 364 | 365 | @Serializable 366 | @SerialName("FriendRequestEvent") 367 | data class FriendRequestEventDTO( 368 | override var self_id: Long, 369 | val user_id: Long, 370 | val comment: String, 371 | val flag: String, 372 | override var time: Long 373 | ) : BotEventDTO() { 374 | override var post_type: String = "request" 375 | val request_type: String = "friend" 376 | } 377 | 378 | @Serializable 379 | @SerialName("GroupMemberAddRequestEvent") 380 | data class GroupMemberAddRequestEventDTO( 381 | override var self_id: Long, 382 | val sub_type: String, // add、invite 383 | val group_id: Long, 384 | val user_id: Long, 385 | val comment: String, 386 | val flag: String, 387 | override var time: Long 388 | ) : BotEventDTO() { 389 | override var post_type: String = "request" 390 | val request_type: String = "group" 391 | } 392 | 393 | @Serializable 394 | @SerialName("GroupMemberNudgedEvent") 395 | data class GroupMemberNudgedEventDTO( 396 | override var self_id: Long, 397 | val sub_type: String = "poke", 398 | val group_id: Long, 399 | val user_id: Long, 400 | val target_id: Long, 401 | override var time: Long 402 | ) : BotEventDTO() { 403 | override var post_type: String = "notice" 404 | val notice_type: String = "notify" 405 | } 406 | 407 | @Serializable 408 | @SerialName("GroupMessageRecallEvent") 409 | data class GroupMessageRecallEventDTO( 410 | override var self_id: Long, 411 | val group_id: Long, 412 | val user_id: Long, 413 | val operator_id: Long, 414 | val message_id: Int, 415 | override var time: Long 416 | ) : BotEventDTO() { 417 | override var post_type: String = "notice" 418 | val notice_type: String = "group_recall" 419 | } 420 | 421 | @Serializable 422 | @SerialName("FriendMessageRecallEvent") 423 | data class FriendMessageRecallEventDTO( 424 | override var self_id: Long, 425 | val user_id: Long, 426 | val message_id: Int, 427 | override var time: Long 428 | ) : BotEventDTO() { 429 | override var post_type: String = "notice" 430 | val notice_type: String = "friend_recall" 431 | } 432 | 433 | @Serializable 434 | @SerialName("MemberHonorChangeEvent") 435 | data class MemberHonorChangeEventDTO( 436 | override var self_id: Long, 437 | val sub_type: String = "honor", 438 | val user_id: Long, 439 | val group_id: Long, 440 | val honor_type: String = "talkative", 441 | override var time: Long 442 | ) : BotEventDTO() { 443 | override var post_type: String = "notice" 444 | val notice_type: String = "notify" 445 | } 446 | -------------------------------------------------------------------------------- /onebot-mirai/src/main/kotlin/com/github/yyuueexxiinngg/onebot/util/CQMessgeParser.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * Part of codes was taken from Mirai Native 4 | * 5 | * Copyright (C) 2020 iTX Technologies 6 | * 7 | * This program is free software: you can redistribute it and/or modify 8 | * it under the terms of the GNU Affero General Public License as 9 | * published by the Free Software Foundation, either version 3 of the 10 | * License, or (at your option) any later version. 11 | * 12 | * This program is distributed in the hope that it will be useful, 13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | * GNU Affero General Public License for more details. 16 | * 17 | * You should have received a copy of the GNU Affero General Public License 18 | * along with this program. If not, see . 19 | * 20 | * @author PeratX 21 | * @website https://github.com/iTXTech/mirai-native 22 | * 23 | */ 24 | package com.github.yyuueexxiinngg.onebot.util 25 | 26 | import com.github.yyuueexxiinngg.onebot.PluginBase 27 | import com.github.yyuueexxiinngg.onebot.PluginBase.db 28 | import com.github.yyuueexxiinngg.onebot.PluginBase.saveImageAsync 29 | import com.github.yyuueexxiinngg.onebot.PluginBase.saveRecordAsync 30 | import com.github.yyuueexxiinngg.onebot.PluginSettings 31 | import com.github.yyuueexxiinngg.onebot.logger 32 | import kotlinx.coroutines.Dispatchers 33 | import kotlinx.coroutines.withContext 34 | import kotlinx.serialization.json.* 35 | import net.mamoe.mirai.Bot 36 | import net.mamoe.mirai.contact.Contact 37 | import net.mamoe.mirai.contact.Group 38 | import net.mamoe.mirai.message.data.* 39 | import net.mamoe.mirai.message.data.Image.Key.queryUrl 40 | import net.mamoe.mirai.message.data.MessageChain.Companion.deserializeJsonToMessageChain 41 | import net.mamoe.mirai.message.data.MessageSource.Key.quote 42 | import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource 43 | import net.mamoe.mirai.utils.ExternalResource.Companion.uploadAsImage 44 | import net.mamoe.mirai.utils.MiraiExperimentalApi 45 | import net.mamoe.mirai.utils.MiraiInternalApi 46 | import java.io.File 47 | import java.net.URL 48 | import java.util.* 49 | 50 | suspend fun messageToMiraiMessageChains( 51 | bot: Bot, 52 | contact: Contact?, 53 | message: Any?, 54 | raw: Boolean = false 55 | ): MessageChain? { 56 | when (message) { 57 | is String -> { 58 | return if (raw) { 59 | PlainText(message).toMessageChain() 60 | } else { 61 | codeToChain(bot, message, contact) 62 | } 63 | } 64 | is JsonArray -> { 65 | var messageChain = buildMessageChain { } 66 | for (msg in message) { 67 | try { 68 | val data = msg.jsonObject["data"] 69 | when (msg.jsonObject["type"]?.jsonPrimitive?.content) { 70 | "text" -> messageChain += PlainText(data!!.jsonObject["text"]!!.jsonPrimitive.content) 71 | else -> messageChain += textToMessageInternal(bot, contact, msg) 72 | } 73 | } catch (e: NullPointerException) { 74 | logger.warning("Got null when parsing CQ message array") 75 | continue 76 | } 77 | } 78 | return messageChain 79 | } 80 | is JsonObject -> { 81 | return try { 82 | val data = message.jsonObject["data"] 83 | when (message.jsonObject["type"]?.jsonPrimitive?.content) { 84 | "text" -> PlainText(data!!.jsonObject["text"]!!.jsonPrimitive.content).toMessageChain() 85 | else -> textToMessageInternal(bot, contact, message).toMessageChain() 86 | } 87 | } catch (e: NullPointerException) { 88 | logger.warning("Got null when parsing CQ message object") 89 | null 90 | } 91 | } 92 | is JsonPrimitive -> { 93 | return if (raw) { 94 | PlainText(message.content).toMessageChain() 95 | } else { 96 | codeToChain(bot, message.content, contact) 97 | } 98 | } 99 | else -> { 100 | logger.warning("Cannot determine type of " + message.toString()) 101 | return null 102 | } 103 | } 104 | } 105 | 106 | 107 | private suspend fun textToMessageInternal(bot: Bot, contact: Contact?, message: Any): Message { 108 | when (message) { 109 | is String -> { 110 | if (message.startsWith("[CQ:") && message.endsWith("]")) { 111 | val parts = message.substring(4, message.length - 1).split(",", limit = 2) 112 | 113 | val args: HashMap = if (parts.size == 2) { 114 | parts[1].toMap() 115 | } else { 116 | HashMap() 117 | } 118 | return convertToMiraiMessage(bot, contact, parts[0], args) 119 | } 120 | return PlainText(message.unescape()) 121 | } 122 | is JsonObject -> { 123 | val type = message.jsonObject["type"]!!.jsonPrimitive.content 124 | val data = message.jsonObject["data"] ?: return MSG_EMPTY 125 | val args = data.jsonObject.keys.associateWith { data.jsonObject[it]!!.jsonPrimitive.content } 126 | return convertToMiraiMessage(bot, contact, type, args) 127 | } 128 | else -> return MSG_EMPTY 129 | } 130 | } 131 | 132 | @OptIn(MiraiExperimentalApi::class) 133 | private suspend fun convertToMiraiMessage( 134 | bot: Bot, 135 | contact: Contact?, 136 | type: String, 137 | args: Map 138 | ): Message { 139 | when (type) { 140 | "at" -> { 141 | if (args["qq"] == "all") { 142 | return AtAll 143 | } else { 144 | return if (contact !is Group) { 145 | logger.debug("不能在私聊中发送 At。") 146 | MSG_EMPTY 147 | } else { 148 | val member = contact[args["qq"]!!.toLong()] 149 | if (member == null) { 150 | logger.debug("无法找到群员:${args["qq"]}") 151 | MSG_EMPTY 152 | } else { 153 | At(member) 154 | } 155 | } 156 | } 157 | } 158 | "face" -> { 159 | return Face(args["id"]!!.toInt()) 160 | } 161 | "emoji" -> { 162 | return PlainText(String(Character.toChars(args["id"]!!.toInt()))) 163 | } 164 | "image" -> { 165 | return tryResolveMedia("image", contact, args) 166 | } 167 | "share" -> { 168 | return RichMessageHelper.share( 169 | args["url"]!!, 170 | args["title"], 171 | args["content"], 172 | args["image"] 173 | ) 174 | } 175 | "record" -> { 176 | return tryResolveMedia("record", contact, args) 177 | } 178 | "contact" -> { 179 | return if (args["type"] == "qq") { 180 | RichMessageHelper.contactQQ(bot, args["id"]!!.toLong()) 181 | } else { 182 | RichMessageHelper.contactGroup(bot, args["id"]!!.toLong()) 183 | } 184 | } 185 | "music" -> { 186 | return when (args["type"]) { 187 | "qq" -> QQMusic.send(args["id"]!!) 188 | "163" -> NeteaseMusic.send(args["id"]!!) 189 | "custom" -> Music.custom( 190 | args["url"]!!, 191 | args["audio"]!!, 192 | args["title"]!!, 193 | args["content"], 194 | args["image"] 195 | ) 196 | else -> throw IllegalArgumentException("Custom music share not supported anymore") 197 | } 198 | } 199 | "shake" -> { 200 | return PokeMessage.ChuoYiChuo 201 | } 202 | "poke" -> { 203 | PokeMessage.values.forEach { 204 | if (it.pokeType == args["type"]!!.toInt() && it.id == args["id"]!!.toInt()) { 205 | return it 206 | } 207 | } 208 | return MSG_EMPTY 209 | } 210 | // Could be changed at anytime. 211 | "nudge" -> { 212 | val target = args["qq"] ?: error("Nudge target `qq` must not ne null.") 213 | if (contact is Group) { 214 | contact.members[target.toLong()]?.nudge()?.sendTo(contact) 215 | } else { 216 | contact?.let { bot.friends[target.toLong()]?.nudge()?.sendTo(it) } 217 | } 218 | return MSG_EMPTY 219 | } 220 | "xml" -> { 221 | return xmlMessage(args["data"]!!) 222 | } 223 | "json" -> { 224 | return if (args["data"]!!.contains("\"app\":")) { 225 | LightApp(args["data"]!!) 226 | } else { 227 | jsonMessage(args["data"]!!) 228 | } 229 | } 230 | "reply" -> { 231 | if (PluginSettings.db.enable) { 232 | db?.apply { 233 | return String( 234 | get( 235 | args["id"]!!.toInt().toByteArray() 236 | ) 237 | ).deserializeJsonToMessageChain().sourceOrNull?.quote() ?: MSG_EMPTY 238 | } 239 | } 240 | } 241 | else -> { 242 | logger.debug("不支持的 CQ码:${type}") 243 | } 244 | } 245 | return MSG_EMPTY 246 | } 247 | 248 | 249 | private val MSG_EMPTY = PlainText("") 250 | 251 | private fun String.escape(): String { 252 | return replace("&", "&") 253 | .replace("[", "[") 254 | .replace("]", "]") 255 | .replace(",", ",") 256 | } 257 | 258 | private fun String.unescape(): String { 259 | return replace("&", "&") 260 | .replace("[", "[") 261 | .replace("]", "]") 262 | .replace(",", ",") 263 | } 264 | 265 | private fun String.toMap(): HashMap { 266 | val map = HashMap() 267 | split(",").forEach { 268 | val parts = it.split("=", limit = 2) 269 | map[parts[0].trim()] = parts[1].unescape() 270 | } 271 | return map 272 | } 273 | 274 | @OptIn(MiraiExperimentalApi::class, MiraiInternalApi::class) 275 | suspend fun Message.toCQString(): String { 276 | return when (this) { 277 | is PlainText -> content.escape() 278 | is At -> "[CQ:at,qq=$target]" 279 | is Face -> "[CQ:face,id=$id]" 280 | is VipFace -> "[CQ:vipface,id=${kind.id},name=${kind.name},count=${count}]" 281 | is PokeMessage -> "[CQ:poke,id=${id},type=${pokeType},name=${name}]" 282 | is AtAll -> "[CQ:at,qq=all]" 283 | is Image -> "[CQ:image,file=${imageId},url=${queryUrl().escape()}]" 284 | is FlashImage -> "[CQ:image,file=${image.imageId},url=${image.queryUrl().escape()},type=flash]" 285 | is ServiceMessage -> with(content) { 286 | when { 287 | contains("xml version") -> "[CQ:xml,data=${content.escape()}]" 288 | else -> "[CQ:json,data=${content.escape()}]" 289 | } 290 | } 291 | is LightApp -> "[CQ:json,data=${content.escape()}]" 292 | is MessageSource -> "" 293 | is QuoteReply -> "[CQ:reply,id=${source.internalIds.toMessageId(source.botId, source.fromId)}]" 294 | is Voice -> "[CQ:record,url=${url?.escape()},file=${md5.toUHexString("")}]" 295 | is OnlineAudio -> "[CQ:record,url=${urlForDownload.escape()},file=${fileMd5.toUHexString("")}]" 296 | else -> "此处消息的转义尚未被插件支持" 297 | } 298 | } 299 | 300 | suspend fun codeToChain(bot: Bot, message: String, contact: Contact?): MessageChain { 301 | return buildMessageChain { 302 | if (message.contains("[CQ:")) { 303 | var interpreting = false 304 | val sb = StringBuilder() 305 | var index = 0 306 | message.forEach { c: Char -> 307 | if (c == '[') { 308 | if (interpreting) { 309 | logger.error("CQ消息解析失败:$message,索引:$index") 310 | return@forEach 311 | } else { 312 | interpreting = true 313 | if (sb.isNotEmpty()) { 314 | val lastMsg = sb.toString() 315 | sb.delete(0, sb.length) 316 | +textToMessageInternal(bot, contact, lastMsg) 317 | } 318 | sb.append(c) 319 | } 320 | } else if (c == ']') { 321 | if (!interpreting) { 322 | logger.error("CQ消息解析失败:$message,索引:$index") 323 | return@forEach 324 | } else { 325 | interpreting = false 326 | sb.append(c) 327 | if (sb.isNotEmpty()) { 328 | val lastMsg = sb.toString() 329 | sb.delete(0, sb.length) 330 | +textToMessageInternal(bot, contact, lastMsg) 331 | } 332 | } 333 | } else { 334 | sb.append(c) 335 | } 336 | index++ 337 | } 338 | if (sb.isNotEmpty()) { 339 | +textToMessageInternal(bot, contact, sb.toString()) 340 | } 341 | } else { 342 | +PlainText(message.unescape()) 343 | } 344 | } 345 | } 346 | 347 | fun getDataFile(type: String, name: String): File? { 348 | arrayOf( 349 | File(PluginBase.dataFolder, type).absolutePath + File.separatorChar, 350 | "data" + File.separatorChar + type + File.separatorChar, 351 | System.getProperty("java.library.path") 352 | .substringBefore(";") + File.separatorChar + "data" + File.separatorChar + type + File.separatorChar, 353 | "" 354 | ).forEach { 355 | val f = File(it + name).absoluteFile 356 | if (f.exists()) { 357 | return f 358 | } 359 | } 360 | return null 361 | } 362 | 363 | suspend fun tryResolveMedia(type: String, contact: Contact?, args: Map): Message { 364 | var media: Message? = null 365 | var mediaBytes: ByteArray? = null 366 | var mediaUrl: String? = null 367 | 368 | withContext(Dispatchers.IO) { 369 | if (args.containsKey("file")) { 370 | with(args["file"]!!) { 371 | when { 372 | startsWith("base64://") -> { 373 | mediaBytes = Base64.getDecoder().decode(args["file"]!!.replace("base64://", "")) 374 | } 375 | startsWith("http") -> { 376 | mediaUrl = args["file"] 377 | } 378 | else -> { 379 | val filePath = args["file"]!! 380 | if (filePath.startsWith("file:///")) { 381 | var fileUri = URL(args["file"]).toURI() 382 | if (fileUri.authority != null && fileUri.authority.isNotEmpty()) { 383 | fileUri = URL("file://" + args["file"]!!.substring("file:".length)).toURI() 384 | } 385 | val file = File(fileUri).absoluteFile 386 | if (file.exists() && file.canRead()) { 387 | mediaBytes = file.readBytes() 388 | } 389 | } else { 390 | if (type == "image") { 391 | media = tryResolveCachedImage(filePath, contact) 392 | } else if (type == "record") { 393 | media = tryResolveCachedRecord(filePath, contact) 394 | } 395 | if (media == null) { 396 | val file = getDataFile(type, filePath) 397 | if (file != null && file.canRead()) { 398 | mediaBytes = file.readBytes() 399 | } 400 | } 401 | } 402 | if (mediaBytes == null) { 403 | if (args.containsKey("url")) { 404 | mediaUrl = args["url"]!! 405 | } 406 | } 407 | } 408 | } 409 | } 410 | } else if (args.containsKey("url")) { 411 | mediaUrl = args["url"]!! 412 | } 413 | 414 | if (mediaBytes == null && mediaUrl != null) { 415 | var useCache = true 416 | if (args.containsKey("cache")) { 417 | try { 418 | useCache = args["cache"]?.toIntOrNull() != 0 419 | } catch (e: Exception) { 420 | logger.debug(e.message) 421 | } 422 | } 423 | 424 | val timeoutSecond = (if (args.containsKey("timeout")) args["timeout"]?.toIntOrNull() else null) ?: 0 425 | val useProxy = (if (args.containsKey("proxy")) args["proxy"]?.toIntOrNull() == 1 else null) ?: false 426 | val urlHash = md5(mediaUrl!!).toUHexString("") 427 | 428 | when (type) { 429 | "image" -> { 430 | if (useCache) { 431 | media = tryResolveCachedImage(urlHash, contact) 432 | } 433 | 434 | if (media == null || !useCache) { 435 | mediaBytes = HttpClient.getBytes(mediaUrl!!, timeoutSecond * 1000L, useProxy) 436 | media = mediaBytes?.toExternalResource()?.use { it.uploadAsImage(contact!!) } 437 | if (useCache) { 438 | var imageType = "unknown" 439 | val imageMD5 = mediaBytes?.let { 440 | imageType = getImageType(it) 441 | md5(it) 442 | }?.toUHexString("") 443 | if (imageMD5 != null) { 444 | val imgContent = constructCacheImageMeta( 445 | imageMD5, 446 | mediaBytes?.size, 447 | (media as Image?)?.queryUrl(), 448 | imageType 449 | ) 450 | logger.info("此链接图片将缓存为$urlHash.cqimg") 451 | saveImageAsync("$urlHash.cqimg", imgContent).start() 452 | } 453 | } 454 | } 455 | } 456 | "record" -> { 457 | if (useCache) { 458 | media = tryResolveCachedRecord(urlHash, contact) 459 | } 460 | if (media == null || !useCache) { 461 | mediaBytes = HttpClient.getBytes(mediaUrl!!, timeoutSecond * 1000L, useProxy) 462 | media = mediaBytes?.toExternalResource()?.use { res -> 463 | contact?.let { (it as Group).uploadAudio(res) } 464 | } 465 | 466 | if (useCache && mediaBytes != null) { 467 | saveRecordAsync("$urlHash.cqrecord", mediaBytes!!).start() 468 | } 469 | } 470 | } 471 | } 472 | } 473 | } 474 | 475 | if (media == null && mediaBytes == null) { 476 | return PlainText("插件无法获取到媒体" + if (mediaUrl != null) ", 媒体链接: $mediaUrl" else "") 477 | } 478 | 479 | when (type) { 480 | "image" -> { 481 | val flash = args.containsKey("type") && args["type"] == "flash" 482 | if (media == null) { 483 | media = withContext(Dispatchers.IO) { 484 | mediaBytes!!.toExternalResource().use { res -> 485 | contact!!.uploadImage(res) 486 | } 487 | } 488 | } 489 | 490 | return if (flash) { 491 | (media as Image).flash() 492 | } else { 493 | media as Image 494 | } 495 | } 496 | "record" -> { 497 | if (media == null) { 498 | media = 499 | withContext(Dispatchers.IO) { 500 | mediaBytes!!.toExternalResource().use { (contact!! as Group).uploadAudio(it) } 501 | } 502 | } 503 | return media as Audio 504 | } 505 | } 506 | return PlainText("插件无法获取到媒体" + if (mediaUrl != null) ", 媒体链接: $mediaUrl" else "") 507 | } --------------------------------------------------------------------------------