├── .gitignore ├── README.md ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── src └── main ├── kotlin └── org │ └── zrnq │ └── mcmotd │ ├── GUIMain.kt │ ├── Globals.kt │ ├── ImageUtil.kt │ ├── MClient.kt │ ├── McMotd.kt │ ├── MiraiCommandHandlers.kt │ ├── PlayerHistory.kt │ ├── StandaloneMain.kt │ ├── Utils.kt │ ├── data │ ├── McMotdConfig.kt │ ├── McMotdData.kt │ ├── McMotdPluginConfig.kt │ ├── McMotdPluginData.kt │ ├── McMotdStandaloneConfig.kt │ ├── McMotdStandaloneData.kt │ └── ParsedConfig.kt │ ├── http │ ├── HttpServer.kt │ └── RateLimiter.kt │ ├── logging │ ├── GenericLogger.kt │ ├── MiraiLoggerAdapter.kt │ └── PrintLogger.kt │ ├── net │ ├── ProtocolPacket.kt │ ├── ProtocolTypes.kt │ ├── QueryPacket.kt │ ├── QueryTypes.kt │ ├── ServerAddress.kt │ └── ServerInfo.kt │ └── output │ └── OutputHandlers.kt └── resources ├── META-INF └── services │ └── net.mamoe.mirai.console.plugin.jvm.JvmPlugin └── version.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # User-specific stuff 2 | .idea/ 3 | 4 | *.iml 5 | *.ipr 6 | *.iws 7 | 8 | # IntelliJ 9 | out/ 10 | # mpeltonen/sbt-idea plugin 11 | .idea_modules/ 12 | 13 | # JIRA plugin 14 | atlassian-ide-plugin.xml 15 | 16 | # Compiled class file 17 | *.class 18 | 19 | # Log file 20 | *.log 21 | 22 | # BlueJ files 23 | *.ctxt 24 | 25 | # Package Files # 26 | *.jar 27 | *.war 28 | *.nar 29 | *.ear 30 | *.zip 31 | *.tar.gz 32 | *.rar 33 | 34 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 35 | hs_err_pid* 36 | 37 | *~ 38 | 39 | # temporary files which can be created if a process still has a handle open of a deleted file 40 | .fuse_hidden* 41 | 42 | # KDE directory preferences 43 | .directory 44 | 45 | # Linux trash folder which might appear on any partition or disk 46 | .Trash-* 47 | 48 | # .nfs files are created when an open file is removed but is still being accessed 49 | .nfs* 50 | 51 | # General 52 | .DS_Store 53 | .AppleDouble 54 | .LSOverride 55 | 56 | # Icon must end with two \r 57 | Icon 58 | 59 | # Thumbnails 60 | ._* 61 | 62 | # Files that might appear in the root of a volume 63 | .DocumentRevisions-V100 64 | .fseventsd 65 | .Spotlight-V100 66 | .TemporaryItems 67 | .Trashes 68 | .VolumeIcon.icns 69 | .com.apple.timemachine.donotpresent 70 | 71 | # Directories potentially created on remote AFP share 72 | .AppleDB 73 | .AppleDesktop 74 | Network Trash Folder 75 | Temporary Items 76 | .apdisk 77 | 78 | # Windows thumbnail cache files 79 | Thumbs.db 80 | Thumbs.db:encryptable 81 | ehthumbs.db 82 | ehthumbs_vista.db 83 | 84 | # Dump file 85 | *.stackdump 86 | 87 | # Folder config file 88 | [Dd]esktop.ini 89 | 90 | # Recycle Bin used on file shares 91 | $RECYCLE.BIN/ 92 | 93 | # Windows Installer files 94 | *.cab 95 | *.msi 96 | *.msix 97 | *.msm 98 | *.msp 99 | 100 | # Windows shortcuts 101 | *.lnk 102 | 103 | .gradle 104 | build/ 105 | 106 | # Ignore Gradle GUI config 107 | gradle-app.setting 108 | 109 | # Cache of project 110 | .gradletasknamecache 111 | 112 | **/build/ 113 | 114 | # Common working directory 115 | run/ 116 | 117 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 118 | !gradle-wrapper.jar 119 | 120 | 121 | # Local Test Launch point 122 | src/test/kotlin/RunTerminal.kt 123 | 124 | # Mirai console files with direct bootstrap 125 | /config 126 | /data 127 | /plugins 128 | /bots 129 | 130 | # Local Test Launch Point working directory 131 | /debug-sandbox 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # McMotd 2 | [![mirai](https://img.shields.io/badge/mirai-v2.16.0-brightgreen)](https://github.com/mamoe/mirai ) 3 | 基于[mirai](https://github.com/mamoe/mirai )的Minecraft服务器信息查询插件 4 | 5 | > 关于Linux运行环境 6 | > 如果你正在使用Linux而不是Windows来运行Mirai,请确保系统中安装了中文字体,否则图片可能不会被正常渲染。 7 | 8 | ## 如何安装 9 | 1. 在[这里](https://github.com/Under-estimate/McMotd/releases/ )下载最新的插件文件。 10 | > 使用`.mirai.jar`还是`.mirai2.jar`: 11 | > `mirai-console`自`2.11.0`版本起支持了[新的插件加载方式](https://github.com/mamoe/mirai/releases/tag/v2.11.0-M1),如果您正在使用高版本`mirai-console`,则可以使用`.mirai2.jar`以避免可能的插件间依赖冲突;`.mirai.jar`为兼容插件格式,大多数版本的`mirai-console`均能使用。 12 | 2. 将插件文件放入[mirai-console](https://github.com/mamoe/mirai-console )运行生成的`plugins`文件夹中。 13 | 3. 如果您还未安装[chat-command](https://github.com/project-mirai/chat-command )插件(添加聊天环境中使用命令的功能),你可以从下面选择一种方法安装此插件: 14 | > 1. 如果您正在使用[Mirai Console Loader](https://github.com/iTXTech/mirai-console-loader )来启动[mirai-console](https://github.com/mamoe/mirai-console ),您可以运行以下命令来安装[chat-command](https://github.com/project-mirai/chat-command )插件: 15 | > `./mcl --update-package net.mamoe:chat-command --channel stable --type plugin` 16 | > 2. 如果您没有使用[Mirai Console Loader](https://github.com/iTXTech/mirai-console-loader ),您可以在[这里](https://github.com/project-mirai/chat-command/releases )下载最新的[chat-command](https://github.com/project-mirai/chat-command )插件文件,并将其一同放入[mirai-console](https://github.com/mamoe/mirai-console )运行生成的`plugins`文件夹中。 17 | 4. 启动[mirai-console](https://github.com/mamoe/mirai-console )之后,在后台命令行输入以下命令授予相关用户使用此插件命令的权限: 18 | > - 如果您希望所有群的群员都可以使用此插件,请输入: 19 | > `/perm grant m* org.zrnq.mcmotd:command.mcp` (仅可使用`mcp`指令) 20 | > `/perm grant m* org.zrnq.mcmotd:*` (可使用全部指令) 21 | > - 如果您希望只授予某一个群的群员使用此插件的权限,请输入: 22 | > `/perm grant m.* org.zrnq.mcmotd:command.mcp` (仅可使用`mcp`指令) 23 | > `/perm grant m.* org.zrnq.mcmotd:*` (可使用全部指令) 24 | > - 如果您希望只授予某一个群的特定群员使用此插件的权限,请输入: 25 | > `/perm grant m.<群员QQ号> org.zrnq.mcmotd:command.mcp` (仅可使用`mcp`指令) 26 | > `/perm grant m.<群员QQ号> org.zrnq.mcmotd:*` (可使用全部指令) 27 | > - 如果你希望了解更多高级权限设置方法,请参阅[mirai-console的权限文档](https://github.com/mamoe/mirai-console/blob/master/docs/Permissions.md ) 28 | 5. 安装完成。 29 | ## 权限列表 30 | *有关权限部分的说明,参见[mirai-console的权限文档](https://github.com/mamoe/mirai-console/blob/master/docs/Permissions.md )* 31 | 根权限: `org.zrnq.mcmotd:*` 32 | + 获取MC服务器信息: `org.zrnq.mcmotd:command.mcp` 33 | + 仅允许获取本群绑定的服务器信息: `org.zrnq.mcmotd:command.mcp.strict` 34 | + 绑定服务器到群聊: `org.zrnq.mcmotd:command.mcadd` 35 | + 删除群聊绑定的服务器: `org.zrnq.mcmotd:command.mcdel` 36 | + 启动/停止服务器的在线人数记录功能: `org.zrnq.mcmotd:command.mcrec` 37 | + 获取http API访问计数: `org.zrnq.mcmotd:command.mcapi` 38 | + 重载插件配置: `org.zrnq.mcmotd:command.mcreload` 39 | ## 插件命令 40 | > /mcp (服务器地址/服务器名称) : 查询指定地址或绑定到指定名称上的服务器信息,当本群仅绑定了一个服务器时可省略参数。 41 | > 42 | > 其中,服务器地址可以仅有域名,如`mc.example.com`,也可以带有端口号,如`mc.example.com:12345`。 43 | > 44 | > 若服务器的玩家列表信息需要通过[Query协议](https://wiki.vg/Query)获取,则需要同时指定服务器连接端口和Query端口,如`mc.example.com:12345:23456`。 45 | 46 | > /mcadd <服务器名称> <服务器地址> : 将指定地址的服务器绑定到指定名称上。各个群聊绑定的服务器相互独立。 47 | 48 | > /mcdel <服务器名称> : 删除指定名称的服务器 49 | 50 | > /mcrec <服务器地址> (true/false) : 启动/停止对于指定服务器的在线人数记录,仅有启用了在线人数记录的服务器才会在查询结果图片中附加历史在线人数信息 51 | 52 | > /mcapi : 获取http API访问计数信息(需要启用http API访问计数功能) 53 | 54 | > /mcreload : 重载插件配置 55 | ## 插件配置 56 | 插件的配置文件位于`/config/org.zrnq.mcmotd/mcmotd.yml` 57 | 58 | | 配置项名称 | 配置类型 | 说明 | 59 | |-------------------------------|--------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| 60 | | fontName | 字符串(默认`Microsoft YaHei`) | 指定渲染图片时使用的字体名称 | 61 | | showTrueAddress | 布尔值(默认`false`) | 设置为`true`时,服务器状态图片中显示服务器的真实地址。设置为`false`时,服务器状态图片中显示服务器的SRV地址 | 62 | | showServerVersion | 布尔值(默认`false`) | 设置为`true`时,服务器状态图片中显示服务器版本号 | 63 | | showPlayerList | 布尔值(默认`true`) | 设置为`true`时,服务器状态图片中显示当前在线的部分玩家(某些服务器可能不提供此信息,或提供非玩家信息的任意文本) | 64 | | showPeakPlayers | 布尔值(默认`false`) | 设置为`true`时,服务器状态图片中将展示该服务器的历史最大在线人数(需要开启历史在线人数纪录) | 65 | | dnsServerList | 字符串列表 | 指定进行SRV解析时所用的DNS服务器 | 66 | | recordOnlinePlayer | 字符串列表 | 已启用历史在线人数记录的服务器 | 67 | | recordInterval | 整数 | 记录在线人数的时间间隔(秒) | 68 | | recordLimit | 整数 | 最长保留的在线人数记录时间(秒) | 69 | | fontPath | 字符串(默认为空) | 指定渲染图片时所使用的字体文件,如果指定了字体文件并且被成功加载,则不会使用`fontName`配置项(此配置项正常情况下无需使用。如果无法使用系统字体,请使用此配置项指定字体文件([#14](https://github.com/Under-estimate/McMotd/issues/14))) | 70 | | background | 字符串(默认为`#000000`) | 指定渲染图片的背景,若以`#`开头,则指定的是RGB格式的纯色背景,否则会被解析为指向背景图片的路径 | 71 | | httpServerPort | 整数(默认为0) | http API的运行端口号,设置为0以禁用http API | 72 | | httpServerMapping | 字典(默认`{}`) | http API中`minecraft服务器名`到`minecraft服务器地址`的对应关系 | 73 | | httpServerRequestCoolDown | 整数(默认为3000) | http API访问冷却时间(毫秒),设置为0以取消访问冷却 | 74 | | httpServerParallelRequest | 整数(默认为32) | http API最大支持的并行访问数,在没有访问冷却时间限制时,此配置项无效 | 75 | | httpServerAccessRecordRefresh | 整数(默认为0) | http API访问计数的统计时长(秒),设置为0以禁用访问计数 | 76 | ## HTTP API 77 | 要开启插件的HTTP API功能,需要将配置文件中的`httpServerPort`设置为非零的可用端口,并配置`httpServerMapping`。 78 | 示例配置: 79 | > httpServerPort: 8092 80 | > httpServerMapping: 81 | >   hypixel: hypixel.net 82 | >   earthmc: org.earthmc.net 83 | 84 | 以上述配置启动McMotd后,访问`http://localhost:8092/info/hypixel` 将会返回与`/mcp hypixel.net`相同的图片结果;访问`http://localhost:8092/info/earthmc` 将会返回与`/mcp org.earthmc.net`相同的图片结果。访问配置文件中未定义的服务器名(如`http://localhost:8092/info/foo` )将不会返回有效的结果 85 | ## 独立运行 86 | McMotd支持脱离mirai-console独立运行,可以用于测试或对接其他框架。如需要独立运行McMotd,请下载[release](https://github.com/Under-estimate/McMotd/releases/) 87 | 中后缀为`.mirai.jar`的插件版本(其中包含独立运行所需的完整依赖项),并根据需求选择相应的启动命令(见下方)。 88 | 独立运行时,会在当前目录下生成配置文件`mcmotd.yml`和历史人数记录数据文件`mcmotd_data.yml`。 89 | - 仅提供HTTP API访问功能 90 | ```bash 91 | java -cp mcmotd-x.x.x.mirai.jar org.zrnq.mcmotd.StandaloneMainKt 92 | ``` 93 | - 仅提供图形化界面访问(用于测试,不启用历史人数记录功能) 94 | ```bash 95 | java -cp mcmotd-x.x.x.mirai.jar org.zrnq.mcmotd.GUIMainKt 96 | ``` 97 | 98 | ## FAQ 99 | ### Q: 在QQ群中发送命令没反应 100 | A: 请检查是否安装了[chat-command](https://github.com/project-mirai/chat-command )插件,如果没有安装请看[这里](#如何安装 ) 101 | 102 | ### Q: UninitializedPropertyAccessException:lateinit property FONT has not been initialized 103 | A: 如果您正在使用Linux运行mirai,请检查是否安装了中文字体 104 | 105 | ### Q: Could not find artifact io.ktor:xxxxx:jar:2.2.2 in https://maven.aliyun.com/repository/public 106 | A: 如果您正在使用[Mirai Console Loader](https://github.com/iTXTech/mirai-console-loader ),请在`/config/Console/PluginDependencies.yml`中添加 107 | >   \- 'https://repo.maven.apache.org/maven2/' 108 | 109 | ### Q: 访问Http API有时会返回Too Many Requests 110 | A: 插件自`1.1.17`版本起,默认启用了Http API的访问冷却时间限制,您可以通过修改配置文件来调整冷却时间长度 -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.apache.tools.ant.filters.ReplaceTokens 2 | 3 | plugins { 4 | val kotlinVersion = "1.8.21" 5 | kotlin("jvm") version kotlinVersion 6 | kotlin("plugin.serialization") version kotlinVersion 7 | 8 | id("net.mamoe.mirai-console") version "2.16.0" 9 | } 10 | 11 | group = "org.zrnq" 12 | version = "1.2.3" 13 | val ktor_version = "2.3.12" 14 | 15 | repositories { 16 | mavenCentral() 17 | } 18 | 19 | /* Note: clean resources folder to update version number. 20 | * Or gradle will consider ProcessResources to be UP-TO-DATE. 21 | * https://github.com/gradle/gradle/issues/861 22 | */ 23 | tasks.withType(ProcessResources::class) { 24 | filter(ReplaceTokens::class, "tokens" to hashMapOf("version" to version)) 25 | } 26 | 27 | dependencies { 28 | implementation(kotlin("reflect")) 29 | implementation("com.charleskorn.kaml:kaml:0.54.0") 30 | implementation("com.alibaba:fastjson:1.2.83") 31 | implementation("dnsjava:dnsjava:3.5.0") 32 | implementation("io.ktor:ktor-server-core:$ktor_version") 33 | implementation("io.ktor:ktor-server-netty:$ktor_version") 34 | implementation("org.gnu.inet:libidn:1.15") 35 | } 36 | 37 | tasks.create("CopyToLib", Copy::class) { 38 | into("${buildDir}/output/libs") 39 | from(configurations.runtimeClasspath) 40 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Under-estimate/McMotd/d98505ca24535247b3466d78e9b32c0c19768024/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /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 | MSYS* | 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "mcmotd" -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/GUIMain.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd 2 | 3 | import com.alibaba.fastjson.parser.ParserConfig 4 | import org.zrnq.mcmotd.data.McMotdStandaloneConfig 5 | import org.zrnq.mcmotd.data.McMotdStandaloneData 6 | import org.zrnq.mcmotd.logging.PrintLogger 7 | import org.zrnq.mcmotd.output.GUIOutputHandler 8 | 9 | fun main() { 10 | genericLogger = PrintLogger() 11 | genericLogger.info("McMotd $mcmotdVersion is running in GUI mode. HTTP server & player history recording is disabled.") 12 | 13 | ParserConfig.getGlobalInstance().isSafeMode = true 14 | 15 | configStorage = McMotdStandaloneConfig.load() 16 | dataStorage = McMotdStandaloneData.load() 17 | configStorage.checkConfig() 18 | 19 | GUIOutputHandler() 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/Globals.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd 2 | 3 | import org.zrnq.mcmotd.data.McMotdConfig 4 | import org.zrnq.mcmotd.data.McMotdData 5 | import org.zrnq.mcmotd.logging.GenericLogger 6 | 7 | lateinit var configStorage: McMotdConfig 8 | lateinit var dataStorage: McMotdData 9 | lateinit var genericLogger: GenericLogger 10 | val mcmotdVersion by lazy { 11 | ImageUtil::class.java.getResourceAsStream("/version.txt")?.reader()?.readText() ?: "??" 12 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/ImageUtil.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd 2 | 3 | import org.zrnq.mcmotd.ColorScheme.toTransparent 4 | import org.zrnq.mcmotd.data.ParsedConfig 5 | import java.awt.* 6 | import java.awt.image.BufferedImage 7 | import java.text.SimpleDateFormat 8 | import java.util.* 9 | import kotlin.math.ceil 10 | import kotlin.math.max 11 | import kotlin.math.tan 12 | import kotlin.random.Random 13 | 14 | object ImageUtil { 15 | fun BufferedImage.appendPlayerHistory(address : String) : BufferedImage { 16 | val playerHistoryHeight = 200 17 | if(!configStorage.recordOnlinePlayer.contains(address)) return this.addBackground() 18 | val history = dataStorage.getHistory(address) 19 | val result = createTransparentImage(1000, height + playerHistoryHeight) 20 | val historyImage = renderPlayerHistory(history) 21 | 22 | val g = result.createGraphics() 23 | g.drawImage(this, 0, 0, null) 24 | g.drawImage(historyImage, 0, height, null) 25 | return result.addBackground() 26 | } 27 | fun renderPlayerHistory(history : MutableList>) : BufferedImage { 28 | val height = 200 29 | val width = 1000 30 | val result = createTransparentImage(width, height) 31 | val g = result.createGraphics() 32 | g.color = Color.WHITE 33 | g.font = ParsedConfig.font 34 | g.setRenderingHints(mapOf( 35 | RenderingHints.KEY_TEXT_ANTIALIASING to RenderingHints.VALUE_TEXT_ANTIALIAS_ON, 36 | RenderingHints.KEY_ANTIALIASING to RenderingHints.VALUE_ANTIALIAS_ON)) 37 | g.drawString("在线人数趋势", 20, 20) 38 | 39 | if(history.size <= 1) { 40 | g.drawErrorMessage("没有足够的数据来绘制图表,稍后再来吧", 0, 0, width, height) 41 | return result 42 | } 43 | 44 | val minTime = history.first().first 45 | val maxTime = history.last().first 46 | val timeRange = maxTime - minTime 47 | val maxCount = history.maxOf { it.second }.coerceAtLeast(1) 48 | 49 | val minX = max(40, 20 + g.fontMetrics.stringWidth(maxCount.toString())) 50 | val maxX = width - 40 51 | val minY = 20 + g.fontMetrics.height 52 | val maxY = height - 30 53 | val xRange = maxX - minX 54 | val yRange = maxY - minY 55 | 56 | g.color = Color.GRAY 57 | g.drawLine(minX, minY, minX, maxY) 58 | (1..4).map { 59 | it * yRange / 4 + minY 60 | }.forEach { 61 | g.drawLine(minX, it, maxX, it) 62 | } 63 | 64 | g.color = Color.WHITE 65 | 66 | g.paintTextRB("0", minX, maxY) 67 | g.paintTextRC(maxCount.toString(), minX, minY) 68 | 69 | val format = SimpleDateFormat("HH:mm") 70 | 71 | // x-axis ticks 72 | (0 .. 4).map { 73 | format.format(Date(it * timeRange / 4 + minTime)) to 74 | it * xRange / 4 + minX 75 | }.forEach { 76 | g.paintTextCT(it.first, it.second, maxY) 77 | } 78 | 79 | val plot = history.map { 80 | ((it.first - minTime) * xRange / timeRange).toInt() to 81 | (it.second * yRange / maxCount) 82 | }.map { 83 | minX + it.first to 84 | maxY - it.second 85 | } 86 | 87 | val polygon = plot.toMutableList().also { 88 | it.add(maxX to maxY) 89 | it.add(minX to maxY) 90 | } 91 | 92 | g.paint = GradientPaint(minX.toFloat(), minY.toFloat(), ColorScheme.darkGreen, 93 | minX.toFloat(), maxY.toFloat(), ColorScheme.darkGreen.toTransparent()) 94 | g.fillPolygon(polygon.map { it.first }.toIntArray(), polygon.map { it.second }.toIntArray(), polygon.size) 95 | 96 | g.color = ColorScheme.brightGreen 97 | g.stroke = BasicStroke(3f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND) 98 | 99 | g.drawPolyline(plot.map { it.first }.toIntArray(), plot.map { it.second }.toIntArray(), plot.size) 100 | 101 | return result 102 | } 103 | /**指定字符串的右侧中心坐标来绘制*/ 104 | fun Graphics2D.paintTextRC(str : String, x : Int, y : Int) { 105 | drawString(str, x - fontMetrics.stringWidth(str), y + fontMetrics.ascent - fontMetrics.height / 2) 106 | } 107 | fun Graphics2D.paintTextRB(str : String, x : Int, y : Int) { 108 | drawString(str, x - fontMetrics.stringWidth(str), y - fontMetrics.descent) 109 | } 110 | /**指定字符串的中心顶部坐标来绘制*/ 111 | fun Graphics2D.paintTextCT(str : String, x : Int, y : Int) { 112 | drawString(str, x - fontMetrics.stringWidth(str) / 2, y + fontMetrics.ascent) 113 | } 114 | fun Graphics2D.paintTextCC(str : String, x : Int, y : Int) { 115 | drawString(str, x - fontMetrics.stringWidth(str) / 2, y + fontMetrics.ascent - fontMetrics.height / 2) 116 | } 117 | 118 | fun Graphics2D.fillStripedRect(color1 : Color, color2 : Color, x : Int, y : Int, width : Int, height : Int) { 119 | val angle = 45.0 120 | val stripeWidth = 20 121 | val offset = Random.Default.nextInt(0, stripeWidth * 2) 122 | setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) 123 | color = color1 124 | fillRect(x, y, width, height) 125 | color = color2 126 | val deviation = height / tan(Math.toRadians(angle)) 127 | val limit = ceil((width + deviation) / stripeWidth).toInt() 128 | for(i in 0..limit step 2) { 129 | val base = x + i * stripeWidth - offset 130 | fillPolygon( 131 | intArrayOf(base, base + stripeWidth, base + stripeWidth - deviation.toInt(), base - deviation.toInt()), 132 | intArrayOf(y, y, y + height, y + height), 133 | 4 134 | ) 135 | } 136 | } 137 | 138 | fun Graphics2D.drawErrorMessage(msg : String, x : Int, y : Int, width: Int, height: Int) { 139 | font = ParsedConfig.font 140 | fillStripedRect(Color.black, ColorScheme.darkRed, x, y, width, height) 141 | val msgRectWidth = fontMetrics.stringWidth(msg) + 30 142 | val msgRectHeight = fontMetrics.height + 20 143 | color = Color.black 144 | fillRect(x + (width - msgRectWidth) / 2, y + (height - msgRectHeight) / 2, msgRectWidth, msgRectHeight) 145 | color = ColorScheme.darkRed 146 | drawRect(x + (width - msgRectWidth) / 2, y + (height - msgRectHeight) / 2, msgRectWidth, msgRectHeight) 147 | color = ColorScheme.brightRed 148 | paintTextCC(msg, x + width / 2, y + height / 2) 149 | } 150 | 151 | fun combineImages(images: List) : BufferedImage { 152 | val width = images[0].width 153 | val height = images.sumOf { it.height } 154 | val result = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) 155 | val g = result.createGraphics() 156 | var currentY = 0 157 | for (image in images) { 158 | g.drawImage(image, 0, currentY, null) 159 | currentY += image.height 160 | } 161 | return result 162 | } 163 | } 164 | 165 | object ColorScheme { 166 | val brightRed = Color(254, 71, 81) 167 | val darkRed = Color(201, 42, 42) 168 | val brightGreen = Color(132, 188, 60) 169 | val darkGreen = Color(132, 188, 60, 150) 170 | 171 | fun Color.toTransparent() 172 | = Color(red, green, blue, 0) 173 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/MClient.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd 2 | 3 | import org.zrnq.mcmotd.output.AbstractOutputHandler 4 | import org.zrnq.mcmotd.net.* 5 | import java.net.InetSocketAddress 6 | import java.net.Socket 7 | 8 | 9 | fun pingInternal(target : ServerAddress, outputHandler : AbstractOutputHandler) { 10 | try { 11 | outputHandler.beforePing() 12 | for(it in target.addressList()) { 13 | try { 14 | outputHandler.onAttemptAddress("${it.first}:${it.second}") 15 | val info = getInfo(it.first, it.second, target.queryPort) 16 | .let { if(!configStorage.showTrueAddress) it.setOriginalAddress(target.originalAddress) else it } 17 | outputHandler.onSuccess(info) 18 | outputHandler.afterPing() 19 | return 20 | } catch (ex : Exception) { 21 | outputHandler.onAttemptFailure(ex, "${it.first}:${it.second}") 22 | } 23 | } 24 | outputHandler.onFailure() 25 | outputHandler.afterPing() 26 | } catch (e : Exception) { 27 | e.printStackTrace() 28 | } 29 | } 30 | 31 | fun getInfo(address : String, port : Int = 25565, queryPort: Int = -1) : ServerInfo { 32 | val serverInfo = Socket().use { socket -> 33 | socket.soTimeout = 3000 34 | socket.connect(InetSocketAddress(address, port)) 35 | val input = socket.getInputStream().buffered() 36 | val output = socket.getOutputStream() 37 | 38 | output.write( 39 | ProtocolPacket(0, 40 | PVarInt(757), 41 | PString(address), 42 | PUnsignedShort(port.toUShort()), 43 | PVarInt(1)).byteArray) 44 | output.flush() 45 | 46 | output.write(ProtocolPacket(0).byteArray) 47 | output.flush() 48 | 49 | val result = ProtocolPacket(input, PString::class).data[0].value as String 50 | 51 | val latency = try { 52 | val time = System.currentTimeMillis() 53 | output.write(ProtocolPacket(1, PLong(time)).byteArray) 54 | output.flush() 55 | // https://wiki.vg/Protocol#Ping : The returned value from server could be any number 56 | ProtocolPacket(input, PLong::class) 57 | (System.currentTimeMillis() - time).toInt() 58 | } catch (e : Exception) { 59 | -1 60 | } 61 | 62 | ServerInfo("$address:$port", result, latency) 63 | } 64 | if(queryPort < 0) return serverInfo 65 | val queryInfo = try { 66 | getQueryInfo(address, queryPort) 67 | } catch (e: Exception) { 68 | e.printStackTrace() 69 | return serverInfo 70 | } 71 | serverInfo.merge(queryInfo) 72 | return serverInfo 73 | } 74 | 75 | // See https://wiki.vg/Query for these magic bytes 76 | private val splitNum = byteArrayOf(0x73, 0x70, 0x6C, 0x69, 0x74, 0x6E, 0x75, 0x6D, 0x00, -128, 0x00) 77 | private val player_ = byteArrayOf(0x01, 0x70, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x5F, 0x00, 0x00) 78 | 79 | fun getQueryInfo(address: String, port: Int) = QuerySession(InetSocketAddress(address, port)).use { session -> 80 | session.sendPacket(QueryPacketType.Handshake) {} 81 | val handshakeResponse = listOf(QString()) 82 | session.receivePacket(QueryPacketType.Handshake, handshakeResponse) 83 | val token = handshakeResponse[0].value.toInt() 84 | session.sendPacket(QueryPacketType.Stat) { payload -> 85 | payload.putInt(token) 86 | payload.putInt(0) // padding 87 | } 88 | val serverProperties = QMap() 89 | val playerList = QList() 90 | session.receivePacket( 91 | QueryPacketType.Stat, listOf( 92 | QConstRegion(splitNum), 93 | serverProperties, 94 | QConstRegion(player_), 95 | playerList 96 | )) 97 | QueryServerInfo(serverProperties.value, playerList.value) 98 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/McMotd.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd 2 | 3 | import com.alibaba.fastjson.parser.ParserConfig 4 | import net.mamoe.mirai.console.command.Command 5 | import net.mamoe.mirai.console.command.CommandManager.INSTANCE.register 6 | import net.mamoe.mirai.console.command.CommandManager.INSTANCE.unregister 7 | import net.mamoe.mirai.console.plugin.jvm.JvmPluginDescription 8 | import net.mamoe.mirai.console.plugin.jvm.KotlinPlugin 9 | import org.zrnq.mcmotd.data.McMotdPluginConfig 10 | import org.zrnq.mcmotd.data.McMotdPluginData 11 | import org.zrnq.mcmotd.http.HttpServer 12 | import org.zrnq.mcmotd.logging.MiraiLoggerAdapter 13 | 14 | object McMotd : KotlinPlugin( 15 | JvmPluginDescription( 16 | id = "org.zrnq.mcmotd", 17 | name = "Minecraft MOTD Fetcher", 18 | version = mcmotdVersion, 19 | ) { 20 | author("ZRnQ") 21 | info("""以图片的形式获取指定Minecraft服务器的基本信息""") 22 | } 23 | ) { 24 | private lateinit var commandList : List 25 | override fun onEnable() { 26 | genericLogger = MiraiLoggerAdapter(logger) 27 | logger.info("McMotd is loading") 28 | 29 | ParserConfig.getGlobalInstance().isSafeMode = true 30 | 31 | McMotdPluginConfig.reload() 32 | McMotdPluginData.reload() 33 | configStorage = McMotdPluginConfig 34 | dataStorage = McMotdPluginData 35 | configStorage.checkConfig() 36 | 37 | QueryCommand.preparePermissions() 38 | 39 | commandList = listOf(QueryCommand, BindCommand, DelCommand, RecordCommand, HttpServerCommand, ConfigReloadCommand) 40 | commandList.forEach { it.register() } 41 | 42 | PlayerHistory.startRecord() 43 | HttpServer.configureHttpServer() 44 | } 45 | 46 | override fun onDisable() { 47 | logger.info("McMotd is unloading") 48 | commandList.forEach { it.unregister() } 49 | PlayerHistory.stopRecord() 50 | HttpServer.stopHttpServer() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/MiraiCommandHandlers.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.runBlocking 5 | import kotlinx.coroutines.withContext 6 | import net.mamoe.mirai.console.command.CommandSender 7 | import net.mamoe.mirai.console.command.MemberCommandSender 8 | import net.mamoe.mirai.console.command.SimpleCommand 9 | import net.mamoe.mirai.console.permission.Permission 10 | import net.mamoe.mirai.console.permission.PermissionService 11 | import net.mamoe.mirai.console.permission.PermissionService.Companion.testPermission 12 | import net.mamoe.mirai.console.util.sendAnsiMessage 13 | import net.mamoe.mirai.message.data.At 14 | import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource 15 | import org.zrnq.mcmotd.output.APIOutputHandler 16 | import org.zrnq.mcmotd.ImageUtil.appendPlayerHistory 17 | import org.zrnq.mcmotd.McMotd.permissionId 18 | import org.zrnq.mcmotd.McMotd.reload 19 | import org.zrnq.mcmotd.data.McMotdPluginData 20 | import org.zrnq.mcmotd.data.McMotdPluginConfig 21 | import org.zrnq.mcmotd.http.RateLimiter 22 | import org.zrnq.mcmotd.net.ServerAddress 23 | import org.zrnq.mcmotd.net.parseAddress 24 | import java.awt.image.BufferedImage 25 | import java.io.ByteArrayInputStream 26 | import java.io.ByteArrayOutputStream 27 | import java.io.File 28 | import java.util.* 29 | import javax.imageio.ImageIO 30 | 31 | @Suppress("unused") 32 | object QueryCommand : SimpleCommand(McMotd, "mcp", description = "获取指定MC服务器的信息") { 33 | 34 | override val permission: Permission 35 | get() = strictPermission 36 | 37 | private lateinit var strictPermission: Permission 38 | private lateinit var relaxedPermission: Permission 39 | 40 | fun preparePermissions() { 41 | relaxedPermission = PermissionService.INSTANCE.register( 42 | permissionId("command.mcp"), 43 | "获取任意MC服务器的信息", 44 | McMotd.parentPermission 45 | ) 46 | strictPermission = PermissionService.INSTANCE.register( 47 | permissionId("command.mcp.strict"), 48 | "获取指定MC服务器的信息(仅限本群绑定的服务器)", 49 | relaxedPermission) 50 | } 51 | 52 | @Handler 53 | suspend fun MemberCommandSender.handle() { 54 | val serverList = McMotdPluginData.getBoundServer(this.group.id) 55 | if(serverList == null) { 56 | reply("本群未绑定服务器,请使用/mcadd绑定服务器或直接提供服务器地址") 57 | return 58 | } 59 | if(serverList.size == 1) 60 | doPing(serverList.first().second) 61 | else 62 | pingMultiple(serverList.map { it.second }) 63 | } 64 | 65 | @Handler 66 | suspend fun CommandSender.handle(target : String) { 67 | if(this is MemberCommandSender) { 68 | McMotdPluginData.getBoundServer(this.group.id) 69 | ?.firstOrNull { it.first == target } 70 | ?.let { doPing(it.second); return } 71 | } 72 | if(!relaxedPermission.testPermission(this)) return 73 | doPing(target) 74 | } 75 | 76 | 77 | private suspend fun CommandSender.doPing(target : String) = withContext(Dispatchers.IO) { 78 | var error : String? = null 79 | var image : BufferedImage? = null 80 | val address = target.parseAddress() 81 | if(address == null) { 82 | reply("服务器地址格式错误,请指定形如: mc.example.com 或者 mc.example.com:25565 的地址") 83 | return@withContext 84 | } 85 | pingInternal(address, APIOutputHandler({ error = it }, { image = renderBasicInfoImage(it).appendPlayerHistory(target) })) 86 | if(image == null) 87 | reply(error!!) 88 | else 89 | reply(image!!) 90 | } 91 | 92 | private suspend fun CommandSender.pingMultiple(target: List) = withContext(Dispatchers.IO) { 93 | val addressList = mutableListOf() 94 | target.forEach { 95 | val parsed = it.parseAddress() 96 | if(parsed == null) { 97 | reply("配置的服务器地址\"$it\"有误,请检查配置文件") 98 | return@withContext 99 | } 100 | addressList.add(parsed) 101 | } 102 | val imageList = mutableListOf() 103 | addressList.forEachIndexed { index, addr -> 104 | var error : String? = null 105 | var image : BufferedImage? = null 106 | pingInternal(addr, APIOutputHandler({ error = it }, { image = renderBasicInfoImage(it).appendPlayerHistory(target[index])})) 107 | if(image == null) 108 | reply("连接\"${target[index]}\"时出错:$error") 109 | else 110 | imageList.add(image!!) 111 | } 112 | reply(ImageUtil.combineImages(imageList)) 113 | } 114 | } 115 | 116 | @Suppress("unused") 117 | object BindCommand : SimpleCommand(McMotd, "mcadd", description = "为当前群聊绑定MC服务器") { 118 | @Handler 119 | suspend fun MemberCommandSender.handle(name : String, address : String) { 120 | val serverList = McMotdPluginData.getBoundServer(this.group.id) ?: mutableListOf() 121 | val existing = serverList.find { it.first == name } 122 | if(existing != null) { 123 | reply("服务器名称已存在:$name") 124 | return 125 | } 126 | if(address.parseAddress() == null) { 127 | reply("服务器地址格式错误,请指定形如: mc.example.com 或者 mc.example.com:25565 的地址") 128 | return 129 | } 130 | serverList.add(name to address) 131 | McMotdPluginData.setBoundServer(this.group.id, serverList) 132 | reply("绑定成功:$name -> $address") 133 | } 134 | } 135 | 136 | @Suppress("unused") 137 | object DelCommand : SimpleCommand(McMotd, "mcdel", description = "删除当前群聊绑定的服务器") { 138 | @Handler 139 | suspend fun MemberCommandSender.handle(name : String) { 140 | val serverList = McMotdPluginData.getBoundServer(this.group.id) ?: mutableListOf() 141 | val existing = serverList.find { it.first == name } 142 | if(existing == null) { 143 | reply("本群没有绑定服务器:$name。可用的服务器:${serverList.serverNameList}") 144 | return 145 | } 146 | serverList.remove(existing) 147 | McMotdPluginData.setBoundServer(this.group.id, serverList) 148 | reply("删除成功") 149 | } 150 | } 151 | 152 | @Suppress("unused") 153 | object RecordCommand : SimpleCommand(McMotd, "mcrec", description = "指定需要记录在线人数的服务器") { 154 | @Handler 155 | suspend fun MemberCommandSender.handle() { 156 | if(McMotdPluginConfig.recordOnlinePlayer.isEmpty()) { 157 | reply("没有已启用在线人数记录的服务器,使用\"/mcrec <服务器地址> true\"以开始记录指定服务器的在线人数") 158 | return 159 | } 160 | reply("已启用在线人数记录的服务器:${McMotdPluginConfig.recordOnlinePlayer.joinToString(",")}。每${McMotdPluginConfig.recordInterval.secondToReadableTime()}记录一次在线人数,最多保存${McMotdPluginConfig.recordLimit.secondToReadableTime()}之前的记录。") 161 | } 162 | 163 | @Handler 164 | suspend fun MemberCommandSender.handle(address : String) { 165 | if(McMotdPluginConfig.recordOnlinePlayer.contains(address)) 166 | reply("服务器[$address]已启用在线人数记录,使用\"/mcrec $address false\"禁用此服务器的在线人数记录功能") 167 | else 168 | reply("服务器[$address]未启用在线人数记录,使用\"/mcrec $address true\"启用此服务器的在线人数记录功能") 169 | } 170 | 171 | @Handler 172 | suspend fun MemberCommandSender.handle(address : String, enable : Boolean) { 173 | if(enable) { 174 | if(!McMotdPluginConfig.recordOnlinePlayer.contains(address)) 175 | McMotdPluginConfig.recordOnlinePlayer.add(address) 176 | reply("已开始记录${address}的在线人数") 177 | } else { 178 | McMotdPluginConfig.recordOnlinePlayer.remove(address) 179 | McMotdPluginData.history.remove(address) 180 | reply("已停止记录${address}的在线人数") 181 | } 182 | } 183 | } 184 | 185 | @Suppress("unused") 186 | object HttpServerCommand : SimpleCommand(McMotd, "mcapi", description = "获取Http API访问计数信息") { 187 | @Handler 188 | suspend fun CommandSender.handle() { 189 | if(McMotdPluginConfig.httpServerPort == 0) { 190 | reply("Http API未开启") 191 | return 192 | } 193 | if(McMotdPluginConfig.httpServerAccessRecordRefresh == 0) { 194 | reply("Http API访问计数功能未开启") 195 | return 196 | } 197 | reply(RateLimiter.getRecordData()) 198 | } 199 | } 200 | 201 | @Suppress("unused") 202 | object ConfigReloadCommand : SimpleCommand(McMotd, "mcreload", description = "重载插件配置") { 203 | @Handler 204 | suspend fun CommandSender.handle() { 205 | // run inside McMotd.timer to avoid ConcurrentModification with player history recording. 206 | PlayerHistory.timer.schedule(object : TimerTask() { 207 | override fun run() { 208 | McMotdPluginConfig.reload() 209 | configStorage.checkConfig() 210 | runBlocking { 211 | this@handle.reply("配置重载完成") 212 | } 213 | } 214 | }, 0) 215 | } 216 | } 217 | 218 | private suspend fun CommandSender.reply(message : String) { 219 | if(user == null) sendAnsiMessage { lightPurple().append(message) } 220 | else sendMessage(At(user!!.id) + message) 221 | } 222 | 223 | private suspend fun CommandSender.reply(image : BufferedImage) { 224 | if(user == null) { 225 | val savePath = File("${UUID.randomUUID()}.png") 226 | withContext(Dispatchers.IO) { ImageIO.write(image, "png", savePath) } 227 | reply("查询结果已保存至${savePath.absolutePath}") 228 | } else { 229 | val bis = ByteArrayOutputStream() 230 | withContext(Dispatchers.IO) { ImageIO.write(image, "png", bis) } 231 | ByteArrayInputStream(bis.toByteArray()).toExternalResource("png").use { 232 | sendMessage(At(user!!.id) + user!!.uploadImage(it)) 233 | } 234 | } 235 | } 236 | 237 | private val List>.serverNameList get() = joinToString(", ") { it.first } 238 | -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/PlayerHistory.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd 2 | 3 | import org.zrnq.mcmotd.data.McMotdStandaloneData 4 | import org.zrnq.mcmotd.net.parseAddressCached 5 | import org.zrnq.mcmotd.output.APIOutputHandler 6 | import java.util.* 7 | 8 | object PlayerHistory { 9 | lateinit var timer : Timer 10 | 11 | fun startRecord() { 12 | timer = Timer() 13 | timer.schedule(object : TimerTask() { 14 | override fun run() { 15 | configStorage.recordOnlinePlayer.forEach { address -> 16 | val resolvedAddress = address.parseAddressCached() 17 | if(resolvedAddress == null) { 18 | genericLogger.warning("在线人数记录配置的服务器地址无效: $address") 19 | return@forEach 20 | } 21 | pingInternal(resolvedAddress, APIOutputHandler( 22 | { genericLogger.warning("在线人数记录失败 $address: $it") }, 23 | { if(it.onlinePlayerCount == null) genericLogger.warning("在线人数记录: ($address) 没有提供在线人数数据") 24 | else dataStorage.recordHistory(address, it.onlinePlayerCount!!) }) 25 | ) 26 | } 27 | (dataStorage as? McMotdStandaloneData)?.save() 28 | } 29 | }, configStorage.recordInterval.toLong() * 1000, configStorage.recordInterval.toLong() * 1000) 30 | } 31 | 32 | fun stopRecord() { 33 | timer.cancel() 34 | } 35 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/StandaloneMain.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd 2 | 3 | import com.alibaba.fastjson.parser.ParserConfig 4 | import org.zrnq.mcmotd.data.McMotdStandaloneConfig 5 | import org.zrnq.mcmotd.data.McMotdStandaloneData 6 | import org.zrnq.mcmotd.http.HttpServer 7 | import org.zrnq.mcmotd.logging.PrintLogger 8 | 9 | fun main() { 10 | genericLogger = PrintLogger() 11 | genericLogger.info("McMotd $mcmotdVersion is running in Standalone mode.") 12 | 13 | ParserConfig.getGlobalInstance().isSafeMode = true 14 | 15 | configStorage = McMotdStandaloneConfig.load() 16 | dataStorage = McMotdStandaloneData.load() 17 | configStorage.checkConfig() 18 | 19 | PlayerHistory.startRecord() 20 | HttpServer.configureHttpServer() 21 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/Utils.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd 2 | 3 | import com.alibaba.fastjson.JSON 4 | import com.alibaba.fastjson.JSONArray 5 | import com.alibaba.fastjson.JSONObject 6 | import kotlinx.coroutines.runBlocking 7 | import org.zrnq.mcmotd.data.ParsedConfig 8 | import org.zrnq.mcmotd.net.ServerInfo 9 | import java.awt.* 10 | import java.awt.event.ComponentAdapter 11 | import java.awt.event.ComponentEvent 12 | import java.awt.image.BufferedImage 13 | import java.io.ByteArrayInputStream 14 | import java.util.* 15 | import javax.imageio.ImageIO 16 | import javax.swing.JEditorPane 17 | import javax.swing.JFrame 18 | import kotlin.coroutines.resume 19 | import kotlin.coroutines.suspendCoroutine 20 | 21 | fun Int.secondToReadableTime() : String { 22 | return when { 23 | this < 60 -> "${this}s" 24 | this < 3600 -> String.format("%.2fmin", this.toFloat() / 60) 25 | else -> String.format("%.2fh", this.toFloat() / 3600) 26 | } 27 | } 28 | 29 | fun Exception.translateCommonException() 30 | = when { 31 | matches("Connection timed out: connect") -> "连接服务器超时" 32 | matches("Connection refused: connect") -> "无法连接到服务器" 33 | matches("Read timed out") -> "连接服务器超时" 34 | matches() -> "找不到目标主机" 35 | else -> "${javaClass.name.substringAfterLast('.')}:$message" 36 | } 37 | 38 | inline fun Exception.matches(msg : String? = null) = this::class == E::class && (msg == null || message == msg) 39 | 40 | fun String.limitLength(max : Int) = if (length > max) this.substring(0, max) + "..." else this 41 | 42 | fun renderBasicInfoImage(info: ServerInfo) : BufferedImage = runBlocking { 43 | val margin = 20 44 | val width = 1000 45 | val iconSize = 160 46 | val iconCenter = 100 47 | val textWidth = width - iconSize - 3 * margin 48 | val textX = iconSize + 2 * margin 49 | 50 | val textContent = info.toHTMLString() 51 | // Creating a new JEditorPane every time since swing objects are not thread safe 52 | val textRenderer = JEditorPane("text/html", textContent) 53 | textRenderer.putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, true) 54 | textRenderer.text = textContent 55 | textRenderer.background = Color(0,0,0,0) 56 | textRenderer.font = ParsedConfig.font 57 | // [GH-ISSUE#28] must wait for the textRenderer to be resized, or we may get the wrong dimensions. 58 | suspendCoroutine { cont -> 59 | textRenderer.addComponentListener(object : ComponentAdapter() { 60 | override fun componentResized(e: ComponentEvent) { 61 | // Do not fetch the preferred size here. 62 | cont.resume(Unit) 63 | } 64 | }) 65 | textRenderer.setSize(textWidth, Short.MAX_VALUE.toInt()) 66 | } 67 | val textSize = textRenderer.preferredSize 68 | val imageHeight = (textSize.height + 2 * margin).coerceAtLeast(iconSize + 2 * margin) 69 | val result = createTransparentImage(width, imageHeight) 70 | 71 | val g = result.createGraphics() 72 | g.font = ParsedConfig.font 73 | g.setRenderingHints(mapOf( 74 | RenderingHints.KEY_INTERPOLATION to RenderingHints.VALUE_INTERPOLATION_BICUBIC, 75 | RenderingHints.KEY_TEXT_ANTIALIASING to RenderingHints.VALUE_TEXT_ANTIALIAS_ON 76 | )) 77 | if(info.favicon != null) 78 | paintBase64Image(info.favicon, g, margin, margin, iconSize, iconSize) 79 | else 80 | paintStringWithBackground("NO IMAGE", g, iconCenter, iconCenter, Color.WHITE, Color(0xaa0000), 15, 10) 81 | g.color = Color.WHITE 82 | g.drawRect(margin, margin, iconSize, iconSize) 83 | 84 | textRenderer.paint(g.create(textX, margin, textWidth, textSize.height)) 85 | return@runBlocking result 86 | } 87 | 88 | fun paintStringWithBackground(str : String, g : Graphics2D, x : Int, y : Int, fg : Color, bg : Color, horizontalPadding : Int, verticalPadding : Int) { 89 | val fontMetrics = g.fontMetrics 90 | val textWidth = fontMetrics.stringWidth(str) 91 | val textX = x - textWidth / 2 92 | val textY = y + fontMetrics.ascent - fontMetrics.height / 2 93 | val rectX = textX - horizontalPadding 94 | val rectY = y - fontMetrics.height / 2 - verticalPadding 95 | g.color = bg 96 | g.fillRect(rectX, rectY, textWidth + 2 * horizontalPadding, fontMetrics.height + 2 * verticalPadding) 97 | g.color = fg 98 | g.drawString(str, textX, textY) 99 | } 100 | 101 | fun paintBase64Image(img : String, g : Graphics2D, x : Int, y : Int, w : Int, h : Int) { 102 | val imgDescriptor = img.split(",").let { 103 | it[0].substring(11, it[0].length - 7) to it[1].replace("\n", "") 104 | } 105 | val image = ImageIO.read(ByteArrayInputStream(Base64.getDecoder().decode(imgDescriptor.second))) 106 | g.drawImage(image, x, y, w, h, null) 107 | } 108 | 109 | fun createTransparentImage(width: Int, height: Int) : BufferedImage { 110 | val result = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) 111 | val g = result.createGraphics() 112 | g.composite = AlphaComposite.Clear 113 | g.fillRect(0, 0, width, height) 114 | g.composite = AlphaComposite.Src 115 | return result 116 | } 117 | 118 | fun generateBackgroundImage(width : Int, height : Int) : BufferedImage { 119 | val result = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) 120 | val g = result.createGraphics() 121 | if(ParsedConfig.isPureColorBackground) { 122 | g.color = ParsedConfig.backgroundColor 123 | g.fillRect(0, 0, width, height) 124 | return result 125 | } 126 | val backgroundImage = ParsedConfig.backgroundImage!! 127 | for(h in 0 until height step backgroundImage.height) { 128 | for(w in 0 until width step backgroundImage.width) { 129 | g.drawImage(backgroundImage, w, h, null) 130 | } 131 | } 132 | return result 133 | } 134 | 135 | fun BufferedImage.addBackground() : BufferedImage { 136 | val background = generateBackgroundImage(width, height) 137 | val g = background.createGraphics() 138 | g.drawImage(this, 0, 0, null) 139 | return background 140 | } 141 | 142 | fun flatJsonEntity(result: JSONArray, entity: Any) { 143 | when(entity) { 144 | is String -> result.add(JSONObject().apply { put("text", entity) }) 145 | is JSONObject -> { 146 | val flatObj = JSONObject() 147 | val currentIndex = result.size 148 | for(key in entity.keys) { 149 | if(key == "extra") flatJsonEntity(result, entity[key]!!) 150 | flatObj[key] = entity[key] 151 | } 152 | result.add(currentIndex, flatObj) 153 | } 154 | is JSONArray -> { 155 | for(element in entity) flatJsonEntity(result, element) 156 | } 157 | } 158 | 159 | } 160 | 161 | private const val RAW = 8 162 | private const val SEQ = 0 163 | private const val COLOR = 0 164 | private const val BOLD = 1 165 | private const val ITALIC = 2 166 | private const val UNDERLINE = 3 167 | private const val STRIKE = 4 168 | 169 | fun jsonStringToHTML(json : JSON) : String{ 170 | val line = JSONArray() 171 | flatJsonEntity(line, json) 172 | val builder = StringBuilder() 173 | 174 | val attributes = Array(RAW * 2) { null } 175 | 176 | fun color() = (attributes[SEQ + COLOR] ?: attributes[RAW + COLOR] ?: "white") as String 177 | fun bold() = (attributes[SEQ + BOLD] ?: attributes[RAW + BOLD] ?: false) as Boolean 178 | fun italic() = (attributes[SEQ + ITALIC] ?: attributes[RAW + ITALIC] ?: false) as Boolean 179 | fun underline() = (attributes[SEQ + UNDERLINE] ?: attributes[RAW + UNDERLINE] ?: false) as Boolean 180 | fun strike() = (attributes[SEQ + STRIKE] ?: attributes[RAW + STRIKE] ?: false) as Boolean 181 | fun styleSpan() : String { 182 | val sb = StringBuilder("") 192 | return sb.toString() 193 | } 194 | val spanEnd = "" 195 | var escapeSeq = false 196 | 197 | for(i in line.indices) { 198 | val paragraph = line.getJSONObject(i) 199 | attributes[RAW + COLOR] = paragraph["color"] 200 | attributes[RAW + BOLD] = paragraph["bold"] 201 | attributes[RAW + ITALIC] = paragraph["italic"] 202 | attributes[RAW + UNDERLINE] = paragraph["underlined"] 203 | attributes[RAW + STRIKE] = paragraph["strikethrough"] 204 | builder.append(styleSpan()) 205 | val text = paragraph.getStringOrDefault("text", "") 206 | for(c in text) { 207 | if(escapeSeq) { 208 | if(c in '0'..'9' || c in 'a'..'f') { 209 | attributes[SEQ + COLOR] = colorSequence[c.digitToInt(16)] 210 | attributes[SEQ + BOLD] = null 211 | attributes[SEQ + STRIKE] = null 212 | attributes[SEQ + UNDERLINE] = null 213 | attributes[SEQ + ITALIC] = null 214 | } else if(c in 'l'..'r') { 215 | when(c) { 216 | 'l' -> attributes[SEQ + BOLD] = true 217 | 'm' -> attributes[SEQ + STRIKE] = true 218 | 'n' -> attributes[SEQ + UNDERLINE] = true 219 | 'o' -> attributes[SEQ + ITALIC] = true 220 | 'r' -> { 221 | attributes[SEQ + COLOR] = null 222 | attributes[SEQ + BOLD] = null 223 | attributes[SEQ + STRIKE] = null 224 | attributes[SEQ + UNDERLINE] = null 225 | attributes[SEQ + ITALIC] = null 226 | } 227 | } 228 | } 229 | escapeSeq = false 230 | builder.append(spanEnd) 231 | .append(styleSpan()) 232 | } else { 233 | if(c == '§') escapeSeq = true 234 | else builder.append(when(c) { 235 | '<' -> "<" 236 | '>' -> ">" 237 | '&' -> "&" 238 | ' ' -> " " 239 | '\n' -> "
" 240 | else -> c 241 | }) 242 | } 243 | } 244 | builder.append(spanEnd) 245 | } 246 | return builder.toString() 247 | } 248 | 249 | fun JSONObject.getStringOrDefault(key : String, default : String = "") : String = 250 | if(containsKey(key)) getString(key) else default 251 | 252 | fun JFrame.centerOnScreen() { 253 | val screen = Toolkit.getDefaultToolkit().screenSize 254 | setLocation((screen.width - width) / 2, (screen.height - height) / 2) 255 | } 256 | 257 | val colorMap = mapOf( 258 | "black" to "#000000", 259 | "dark_blue" to "#0000aa", 260 | "dark_green" to "#00aa00", 261 | "dark_aqua" to "#00aaaa", 262 | "dark_red" to "#aa0000", 263 | "dark_purple" to "#aa00aa", 264 | "gold" to "#ffaa00", 265 | "gray" to "#aaaaaa", 266 | "dark_gray" to "#555555", 267 | "blue" to "#5555ff", 268 | "green" to "#55ff55", 269 | "aqua" to "#55ffff", 270 | "red" to "#ff5555", 271 | "light_purple" to "#ff55ff", 272 | "yellow" to "#ffff55", 273 | "white" to "#ffffff") 274 | 275 | val colorSequence = listOf( 276 | "#000000", 277 | "#0000aa", 278 | "#00aa00", 279 | "#00aaaa", 280 | "#aa0000", 281 | "#aa00aa", 282 | "#ffaa00", 283 | "#aaaaaa", 284 | "#555555", 285 | "#5555ff", 286 | "#55ff55", 287 | "#55ffff", 288 | "#ff5555", 289 | "#ff55ff", 290 | "#ffff55", 291 | "#ffffff" 292 | ) 293 | -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/data/McMotdConfig.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.data 2 | 3 | import org.zrnq.mcmotd.genericLogger 4 | import java.awt.Color 5 | import java.awt.Font 6 | import java.awt.GraphicsEnvironment 7 | import java.io.File 8 | import javax.imageio.ImageIO 9 | 10 | interface McMotdConfig { 11 | val fontPath : String 12 | val fontName : String 13 | val showTrueAddress : Boolean 14 | val showServerVersion : Boolean 15 | val showPlayerList : Boolean 16 | val dnsServerList : MutableList 17 | val recordOnlinePlayer : MutableList 18 | val recordInterval : Int 19 | val recordLimit : Int 20 | val background : String 21 | val showPeakPlayers: Boolean 22 | 23 | val httpServerPort : Int 24 | val httpServerMapping : MutableMap 25 | val httpServerParallelRequest : Int 26 | val httpServerRequestCoolDown : Int 27 | val httpServerAccessRecordRefresh : Int 28 | 29 | fun checkConfig() { 30 | if(dnsServerList.isEmpty()) { 31 | genericLogger.warning("配置文件中没有填写DNS服务器地址,正在使用默认的DNS服务器") 32 | dnsServerList.add("223.5.5.5") 33 | dnsServerList.add("8.8.8.8") 34 | } 35 | 36 | if(background.matches(Regex("#[0-9a-fA-F]{6}"))) { 37 | ParsedConfig.backgroundColor = Color(Integer.parseInt(background.drop(1), 16)) 38 | } else { 39 | try { 40 | ParsedConfig.backgroundImage = ImageIO.read(File(background)) 41 | ParsedConfig.isPureColorBackground = false 42 | } catch (e : Exception) { 43 | genericLogger.warning("无法打开指定的背景图片: $background") 44 | } 45 | } 46 | 47 | if(fontPath.isNotBlank()) { 48 | val fontFile = File(fontPath) 49 | if(!fontFile.exists()) { 50 | genericLogger.warning("无法打开指定的字体文件: $fontPath,请检查配置文件") 51 | } else { 52 | try { 53 | val font = Font.createFont(Font.TRUETYPE_FONT, fontFile) 54 | if(!GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(font)) { 55 | genericLogger.warning("注册字体文件到LocalGraphicsEnvironment时失败") 56 | } 57 | ParsedConfig.font = font.deriveFont(20f) 58 | genericLogger.info("正在使用字体文件: $fontPath") 59 | return 60 | } catch (e : Exception) { 61 | genericLogger.warning("读取字体文件: ${fontPath}时出错", e) 62 | } 63 | } 64 | } 65 | 66 | val fontList = mutableListOf() 67 | for(f in GraphicsEnvironment.getLocalGraphicsEnvironment().allFonts) { 68 | if(f.name == fontName) { 69 | ParsedConfig.font = f.deriveFont(20f) 70 | return 71 | } 72 | if(f.canDisplay('啊')) { 73 | fontList.add(f) 74 | } 75 | } 76 | genericLogger.warning("找不到指定的字体 : ${fontName},您可以在mcmotd.yml中修改字体名称") 77 | ParsedConfig.font = if(fontList.isEmpty()) { 78 | genericLogger.error("找不到可用的字体, 请检查您的系统是否安装了中文字体") 79 | Font(fontName, Font.PLAIN, 20) 80 | } else { 81 | genericLogger.info("检测到可用的字体列表: ${fontList.joinToString(",") { it.name }}") 82 | genericLogger.warning("正在使用第一个可用的字体: ${fontList[0].name}") 83 | fontList[0].deriveFont(20f) 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/data/McMotdData.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.data 2 | 3 | import org.zrnq.mcmotd.configStorage 4 | import kotlin.math.max 5 | 6 | interface McMotdData { 7 | val relation : MutableMap>> 8 | val history : MutableMap>> 9 | val peakPlayers: MutableMap 10 | 11 | fun getBoundServer(groupId : Long) : MutableList>? { 12 | val result = relation[groupId] 13 | ?: return null 14 | if(result.isEmpty()) { 15 | relation.remove(groupId) 16 | return null 17 | } 18 | return result 19 | } 20 | 21 | fun setBoundServer(groupId : Long, value : MutableList>) { 22 | if(value.isEmpty()) 23 | relation.remove(groupId) 24 | else 25 | relation[groupId] = value 26 | } 27 | 28 | fun getHistory(address : String) : MutableList> { 29 | val result = history[address] 30 | ?: return mutableListOf() 31 | val limit = System.currentTimeMillis() - configStorage.recordLimit * 1000 32 | result.removeIf { it.first < limit } 33 | return result 34 | } 35 | 36 | fun recordHistory(address : String, count : Int) { 37 | val target = getHistory(address) 38 | target.add(System.currentTimeMillis() to count) 39 | history[address] = target 40 | peakPlayers[address] = max(peakPlayers[address] ?: 0, count) 41 | } 42 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/data/McMotdPluginConfig.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.data 2 | 3 | import net.mamoe.mirai.console.data.AutoSavePluginConfig 4 | import net.mamoe.mirai.console.data.value 5 | 6 | object McMotdPluginConfig : AutoSavePluginConfig("mcmotd"), McMotdConfig { 7 | override val fontPath by value("") 8 | override val fontName by value("Microsoft YaHei") 9 | override val showTrueAddress by value(false) 10 | override val showServerVersion by value(false) 11 | override val showPlayerList by value(true) 12 | override val dnsServerList by value(mutableListOf("223.5.5.5", "8.8.8.8")) 13 | override val recordOnlinePlayer by value(mutableListOf()) 14 | override val recordInterval by value(300) 15 | override val recordLimit by value(21600) 16 | override val background by value("#000000") 17 | override val showPeakPlayers by value(false) 18 | 19 | override val httpServerPort by value(0) 20 | override val httpServerMapping by value(mutableMapOf()) 21 | override val httpServerParallelRequest by value(32) 22 | override val httpServerRequestCoolDown by value(3000) 23 | override val httpServerAccessRecordRefresh by value(0) 24 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/data/McMotdPluginData.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.data 2 | 3 | import net.mamoe.mirai.console.data.AutoSavePluginData 4 | import net.mamoe.mirai.console.data.value 5 | 6 | object McMotdPluginData : AutoSavePluginData("mcmotd_relation"), McMotdData { 7 | override val relation by value>>>() 8 | override val history by value>>>() 9 | override val peakPlayers by value>() 10 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/data/McMotdStandaloneConfig.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.data 2 | 3 | import com.charleskorn.kaml.Yaml 4 | import kotlinx.serialization.Serializable 5 | import org.zrnq.mcmotd.genericLogger 6 | import java.io.File 7 | 8 | @Serializable 9 | class McMotdStandaloneConfig : McMotdConfig { 10 | override var fontPath = "" 11 | override var fontName = "Microsoft YaHei" 12 | override var showTrueAddress = false 13 | override var showServerVersion = false 14 | override var showPlayerList = true 15 | override var dnsServerList = mutableListOf("223.5.5.5", "8.8.8.8") 16 | override var recordOnlinePlayer = mutableListOf() 17 | override var recordInterval = 300 18 | override var recordLimit = 21600 19 | override var background = "#000000" 20 | override val showPeakPlayers = false 21 | 22 | override var httpServerPort = 0 23 | override var httpServerMapping = mutableMapOf() 24 | override var httpServerParallelRequest = 32 25 | override var httpServerRequestCoolDown = 3000 26 | override var httpServerAccessRecordRefresh = 0 27 | 28 | fun save() { 29 | val file = File(savefile) 30 | file.writeText(Yaml.default.encodeToString(serializer(), this)) 31 | } 32 | 33 | companion object { 34 | private const val savefile = "mcmotd.yml" 35 | fun load() : McMotdStandaloneConfig { 36 | val file = File(savefile) 37 | if(file.exists()) { 38 | try { 39 | return Yaml.default.decodeFromString(serializer(), file.readText()).also { it.save() } 40 | } catch (e : Exception) { 41 | genericLogger.error("Error reading config file", e) 42 | } 43 | } 44 | return McMotdStandaloneConfig().also { it.save() } 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/data/McMotdStandaloneData.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.data 2 | 3 | import com.charleskorn.kaml.Yaml 4 | import kotlinx.serialization.Serializable 5 | import org.zrnq.mcmotd.genericLogger 6 | import java.io.File 7 | 8 | @Serializable 9 | class McMotdStandaloneData : McMotdData { 10 | override val relation = mutableMapOf>>() 11 | override val history = mutableMapOf>>() 12 | override val peakPlayers = mutableMapOf() 13 | 14 | fun save() { 15 | val file = File(savefile) 16 | file.writeText(Yaml.default.encodeToString(serializer(), this)) 17 | } 18 | companion object { 19 | private const val savefile = "mcmotd_data.yml" 20 | fun load() : McMotdStandaloneData { 21 | val file = File(savefile) 22 | if(file.exists()) { 23 | try { 24 | return Yaml.default.decodeFromString(serializer(), file.readText()).also { it.save() } 25 | } catch (e: Exception) { 26 | genericLogger.error("Error reading data file", e) 27 | } 28 | } 29 | return McMotdStandaloneData().also { it.save() } 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/data/ParsedConfig.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.data 2 | 3 | import java.awt.Color 4 | import java.awt.Font 5 | import java.awt.image.BufferedImage 6 | 7 | object ParsedConfig { 8 | lateinit var font: Font 9 | var backgroundColor = Color.BLACK 10 | var backgroundImage : BufferedImage? = null 11 | var isPureColorBackground = true 12 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/http/HttpServer.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.http 2 | 3 | import io.ktor.http.* 4 | import io.ktor.server.application.* 5 | import io.ktor.server.engine.* 6 | import io.ktor.server.netty.* 7 | import io.ktor.server.plugins.* 8 | import io.ktor.server.response.* 9 | import io.ktor.server.routing.* 10 | import io.ktor.util.pipeline.* 11 | import kotlinx.coroutines.Dispatchers 12 | import kotlinx.coroutines.withContext 13 | import org.zrnq.mcmotd.* 14 | import org.zrnq.mcmotd.output.APIOutputHandler 15 | import org.zrnq.mcmotd.ImageUtil.appendPlayerHistory 16 | import org.zrnq.mcmotd.ImageUtil.drawErrorMessage 17 | import org.zrnq.mcmotd.net.parseAddressCached 18 | import java.awt.image.BufferedImage 19 | import java.io.ByteArrayOutputStream 20 | import javax.imageio.ImageIO 21 | 22 | object HttpServer { 23 | 24 | private var httpServer : ApplicationEngine? = null 25 | 26 | fun configureHttpServer() { 27 | if(configStorage.httpServerPort == 0) return 28 | genericLogger.info("Starting embedded http server on http://localhost:${configStorage.httpServerPort}") 29 | httpServer = embeddedServer(Netty, configStorage.httpServerPort, module = Application::mcmotdHttpServer).start(false) 30 | } 31 | 32 | fun stopHttpServer() { 33 | if(httpServer != null) 34 | httpServer!!.stop() 35 | } 36 | } 37 | fun Application.mcmotdHttpServer() { 38 | routing { 39 | configureRouting() 40 | } 41 | } 42 | 43 | suspend fun PipelineContext<*, ApplicationCall>.respondImage(image : BufferedImage) 44 | = call.respondBytes(ContentType.Image.PNG, HttpStatusCode.OK) { 45 | ByteArrayOutputStream().also { stream -> 46 | ImageIO.write(image, "png", stream) 47 | }.toByteArray() 48 | } 49 | 50 | suspend fun PipelineContext<*, ApplicationCall>.respondErrorImage(msg : String) 51 | = respondImage(BufferedImage(1000, 200, BufferedImage.TYPE_INT_RGB).also { 52 | it.createGraphics().drawErrorMessage(msg, 0, 0, 1000, 200) 53 | }) 54 | 55 | fun Route.configureRouting() { 56 | route("/info") { 57 | get("{server?}") { 58 | if(!RateLimiter.pass(call.request.origin.remoteAddress)) 59 | return@get call.respondText("Too many requests", status = HttpStatusCode.TooManyRequests) 60 | val servername = call.parameters["server"] ?: return@get respondErrorImage("未指定服务器名") 61 | val target = configStorage.httpServerMapping[servername] 62 | ?: return@get respondErrorImage("指定的服务器名没有在配置文件中定义") 63 | var error : String? = null 64 | var image : BufferedImage? = null 65 | val address = target.parseAddressCached() 66 | if(address == null) { 67 | genericLogger.error("Http服务器中配置的服务器地址无效:$target") 68 | return@get 69 | } 70 | withContext(Dispatchers.IO) { 71 | pingInternal(address, APIOutputHandler({ error = it }, { image = renderBasicInfoImage(it).appendPlayerHistory(target) })) 72 | } 73 | if(image == null) { 74 | genericLogger.error("Http请求失败:$error") 75 | return@get respondErrorImage("服务器信息获取失败") 76 | } 77 | return@get respondImage(image!!) 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/http/RateLimiter.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.http 2 | 3 | import org.zrnq.mcmotd.configStorage 4 | import java.text.SimpleDateFormat 5 | import java.util.* 6 | import java.util.concurrent.TimeUnit 7 | import java.util.stream.Collectors 8 | 9 | object RateLimiter { 10 | class AccessRecord { 11 | var success = 0 12 | var total = 0 13 | fun update(success: Boolean) { 14 | total++ 15 | if(success) this.success++ 16 | } 17 | } 18 | private var requestCoolDownRecord = Collections.synchronizedMap(HashMap()) 19 | private var nextCleanup = 0L 20 | private var requestRecord = Collections.synchronizedMap(HashMap()) 21 | private var nextRecordRefresh = 0L 22 | private val format = SimpleDateFormat("MM/dd HH:mm:ss") 23 | private fun recordRequest(address: String, success: Boolean) { 24 | if(configStorage.httpServerAccessRecordRefresh == 0) return 25 | val timeNow = System.currentTimeMillis() 26 | if(timeNow > nextRecordRefresh) { 27 | requestRecord.clear() 28 | nextRecordRefresh = timeNow + TimeUnit.SECONDS.toMillis(configStorage.httpServerAccessRecordRefresh.toLong()) 29 | } 30 | requestRecord.getOrPut(address) { AccessRecord() }.update(success) 31 | } 32 | fun getRecordData() : String { 33 | return "%s - %s\n%s".format( 34 | format.format(Date(nextRecordRefresh - TimeUnit.SECONDS.toMillis(configStorage.httpServerAccessRecordRefresh.toLong()))), 35 | format.format(Date(nextRecordRefresh)), 36 | if(requestRecord.isEmpty()) "统计时间段内没有访问记录" 37 | else synchronized(requestRecord) { 38 | requestRecord.entries.stream() 39 | .sorted { o1, o2 -> o2.value.total - o1.value.total } 40 | .limit(10) 41 | .map { "${it.key}: ${it.value.total}(${it.value.success})" } 42 | .collect(Collectors.joining("\n")) 43 | }) 44 | } 45 | fun pass(address : String) : Boolean = run { 46 | if(configStorage.httpServerRequestCoolDown == 0) return@run true // cool down disabled 47 | val lastAccessRecord = requestCoolDownRecord[address] 48 | val timeNow = System.currentTimeMillis() 49 | if(lastAccessRecord == null || lastAccessRecord < timeNow) { 50 | if(requestCoolDownRecord.size > configStorage.httpServerParallelRequest) { 51 | if(nextCleanup > timeNow) return@run false // reaching parallel request limit 52 | // Clean up records 53 | synchronized(requestCoolDownRecord) { 54 | val it = requestCoolDownRecord.iterator() 55 | while(it.hasNext()) { 56 | if(it.next().value < timeNow) it.remove() 57 | } 58 | } 59 | nextCleanup = timeNow + configStorage.httpServerRequestCoolDown 60 | } 61 | requestCoolDownRecord[address] = timeNow + configStorage.httpServerRequestCoolDown 62 | return@run true 63 | } else return@run false // cool down incomplete 64 | }.also { recordRequest(address, it) } 65 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/logging/GenericLogger.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.logging 2 | 3 | interface GenericLogger { 4 | fun info(message: String) 5 | fun warning(message: String, exception: Throwable? = null) 6 | fun error(message: String, exception: Throwable? = null) 7 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/logging/MiraiLoggerAdapter.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.logging 2 | 3 | import net.mamoe.mirai.utils.MiraiLogger 4 | 5 | class MiraiLoggerAdapter(private val logger: MiraiLogger) : GenericLogger { 6 | override fun info(message: String) = logger.info(message) 7 | override fun warning(message: String, exception: Throwable?) = logger.warning(message, exception) 8 | override fun error(message: String, exception: Throwable?) = logger.error(message, exception) 9 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/logging/PrintLogger.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.logging 2 | 3 | import java.text.SimpleDateFormat 4 | import java.util.* 5 | 6 | class PrintLogger : GenericLogger { 7 | private val dateFormat = SimpleDateFormat("HH:mm:ss") 8 | private val prefix get() = "${dateFormat.format(Date())} " 9 | override fun info(message: String) { 10 | println("$prefix I $message") 11 | } 12 | 13 | override fun warning(message: String, exception: Throwable?) { 14 | println("$prefix W $message") 15 | exception?.printStackTrace(System.out) 16 | } 17 | 18 | override fun error(message: String, exception: Throwable?) { 19 | println("$prefix E $message") 20 | exception?.printStackTrace(System.out) 21 | } 22 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/net/ProtocolPacket.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.net 2 | 3 | import java.io.InputStream 4 | import kotlin.reflect.KClass 5 | 6 | class ProtocolPacket(var packetId : Int, vararg dataArgs : PacketData<*>) { 7 | val data = mutableListOf>() 8 | val byteArray : ByteArray 9 | get() { 10 | val builder = PacketDataBuilder() + PVarInt(packetId) 11 | data.forEach { builder + it } 12 | val byteData = builder.toByteArray() 13 | return PVarInt(byteData.size).byteArray + byteData 14 | } 15 | init { 16 | data.addAll(dataArgs) 17 | } 18 | constructor(stream : InputStream, vararg contentTypes : KClass>) : this(0) { 19 | val packetLength = PVarInt().also { it.parseFrom(stream) }.value 20 | var receivedLength = 0 21 | packetId = PVarInt().also { receivedLength += it.parseFrom(stream) }.value 22 | for(dataType in contentTypes) { 23 | val instance = dataType.constructors.find { it.parameters.all { it.isOptional } }!!.callBy(mapOf()) 24 | receivedLength += instance.parseFrom(stream) 25 | data.add(instance) 26 | } 27 | if(receivedLength != packetLength) 28 | org.zrnq.mcmotd.genericLogger.error("Packet length mismatch (Declared : ${packetLength}, Received : ${receivedLength})") 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/net/ProtocolTypes.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.net 2 | 3 | import java.io.InputStream 4 | import java.nio.ByteBuffer 5 | import java.nio.ByteOrder 6 | import java.nio.charset.StandardCharsets 7 | import kotlin.experimental.and 8 | 9 | class PacketDataBuilder { 10 | private val data = mutableListOf() 11 | operator fun plus(append : PacketData<*>) = 12 | data.addAll(append.byteArray.asIterable()).let{ this } 13 | fun toByteArray() = data.toByteArray() 14 | } 15 | 16 | abstract class PacketData { 17 | abstract var value : T 18 | abstract val byteArray : ByteArray 19 | abstract fun parseFrom(stream : InputStream) : Int 20 | } 21 | 22 | class PLong(override var value : Long = 0L) : PacketData() { 23 | override val byteArray : ByteArray 24 | get() = ByteBuffer.allocate(8).let { 25 | it.order(ByteOrder.BIG_ENDIAN) 26 | it.putLong(value) 27 | it.array() 28 | } 29 | 30 | override fun parseFrom(stream : InputStream) : Int = 31 | ByteBuffer.allocate(8).let { 32 | for(i in 0..7) it.put(stream.readChecked().toByte()) 33 | value = it.getLong(0) 34 | 8 35 | } 36 | } 37 | 38 | class PUnsignedShort(override var value : UShort = 0u) : PacketData() { 39 | override val byteArray : ByteArray 40 | get() = ByteBuffer.allocate(2).let { 41 | it.order(ByteOrder.BIG_ENDIAN) 42 | it.putShort(value.toShort()) 43 | it.array() 44 | } 45 | 46 | override fun parseFrom(stream : InputStream) : Int { 47 | value = (stream.readChecked() shl 8 or stream.readChecked()).toUShort() 48 | return 2 49 | } 50 | } 51 | 52 | class PString(override var value : String = "") : PacketData() { 53 | companion object { 54 | const val MAX_LENGTH = 32767 55 | } 56 | override val byteArray: ByteArray 57 | get() { 58 | if(value.isEmpty()) throw IllegalArgumentException("String is empty!") 59 | if(value.length > MAX_LENGTH) throw IllegalArgumentException("String too long (${value.length}>$MAX_LENGTH)") 60 | return PVarInt(value.length).byteArray + value.toByteArray(StandardCharsets.UTF_8) 61 | } 62 | 63 | override fun parseFrom(stream : InputStream) : Int { 64 | var fieldLength : Int 65 | val length = PVarInt().also { fieldLength = it.parseFrom(stream) } 66 | if(length.value > MAX_LENGTH) throw IllegalArgumentException("String too long (${length.value}>$MAX_LENGTH)") 67 | val buf = ByteArray(length.value) 68 | var readLen = 0 69 | while(readLen < length.value) { 70 | val thisRead = stream.read(buf, readLen, buf.size - readLen) 71 | if(thisRead < 0) 72 | throw IllegalArgumentException("Unexpected end of String (${readLen}<${length.value}).") 73 | readLen += thisRead 74 | } 75 | value = String(buf, StandardCharsets.UTF_8) 76 | return fieldLength + length.value 77 | } 78 | 79 | } 80 | 81 | class PVarInt(override var value : Int = 0) : PacketData() { 82 | override val byteArray : ByteArray 83 | get() { 84 | var tmp = value 85 | val result = mutableListOf() 86 | while (true) { 87 | if (tmp and 0x7F.inv() == 0) { 88 | result.add(tmp.toByte()) 89 | return result.toByteArray() 90 | } 91 | result.add((tmp and 0x7F or 0x80).toByte()) 92 | tmp = tmp ushr 7 93 | } 94 | } 95 | override fun parseFrom(stream : InputStream) : Int { 96 | var value = 0 97 | var length = 0 98 | 99 | while(true) { 100 | val currentByte = stream.readChecked().toByte() 101 | value = value or ((currentByte and 0x7F.toByte()).toInt() shl length * 7) 102 | length += 1 103 | if (length > 5) { 104 | throw IllegalArgumentException("VarInt is too big") 105 | } 106 | if (currentByte and 0x80.toByte() != 0x80.toByte()) { 107 | break 108 | } 109 | } 110 | this.value = value 111 | return length 112 | } 113 | } 114 | 115 | fun InputStream.readChecked() = read().also { if(it < 0) throw IllegalStateException("Unexpected end of stream.") } 116 | 117 | -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/net/QueryPacket.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.net 2 | 3 | import java.io.Closeable 4 | import java.net.DatagramPacket 5 | import java.net.DatagramSocket 6 | import java.net.SocketAddress 7 | import java.nio.ByteBuffer 8 | import kotlin.random.Random 9 | 10 | enum class QueryPacketType(val typeId: Byte) { 11 | Stat(0), 12 | Handshake(9) 13 | } 14 | 15 | class QuerySession(val address: SocketAddress) : Closeable { 16 | val sessionId = Random.nextInt() and 0x0F0F0F0F 17 | val socket = DatagramSocket() 18 | init { 19 | socket.soTimeout = 3000 20 | } 21 | fun sendPacket(type: QueryPacketType, payload: (ByteBuffer) -> Unit) { 22 | val packet = QueryClientPacket(type, sessionId) 23 | payload(packet.data) 24 | socket.send(packet.toDatagramPacket(address)) 25 | } 26 | 27 | fun receivePacket(expectType: QueryPacketType, contentSlots: List>) { 28 | val packet = QueryServerPacket(contentSlots, socket, 1024) 29 | if(packet.type != expectType) throw IllegalArgumentException("Received packet with unexpected type ${packet.type}") 30 | if(packet.sessionId != sessionId) throw IllegalArgumentException("Received packet with unexpected sessionId ${packet.sessionId}") 31 | } 32 | 33 | override fun close() { 34 | socket.close() 35 | } 36 | } 37 | 38 | class QueryClientPacket(type: QueryPacketType, sessionId: Int) { 39 | val data = ByteBuffer.allocate(64) 40 | init { 41 | data.putShort(magic) 42 | data.put(type.typeId) 43 | data.putInt(sessionId) 44 | } 45 | 46 | fun toDatagramPacket(address: SocketAddress) : DatagramPacket { 47 | return DatagramPacket(data.array(), data.position(), address) 48 | } 49 | companion object { 50 | const val magic : Short = -259 // 0xFEFD 51 | } 52 | } 53 | 54 | class QueryServerPacket(contentSlots: List>, socket: DatagramSocket, size: Int) { 55 | val type: QueryPacketType 56 | val sessionId: Int 57 | init { 58 | val packet = DatagramPacket(ByteArray(size), size) 59 | socket.receive(packet) 60 | val buffer = ByteBuffer.wrap(packet.data) 61 | val typeId = buffer.get() 62 | type = QueryPacketType.values().find { it.typeId == typeId }!! 63 | sessionId = buffer.getInt() 64 | contentSlots.forEach { it.parseFrom(buffer) } 65 | } 66 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/net/QueryTypes.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.net 2 | 3 | import java.nio.ByteBuffer 4 | import java.nio.charset.StandardCharsets 5 | import java.util.* 6 | 7 | abstract class QueryPacketData { 8 | abstract var value: T 9 | abstract fun parseFrom(byteBuffer: ByteBuffer) 10 | } 11 | 12 | class QString : QueryPacketData() { 13 | override lateinit var value: String 14 | override fun parseFrom(byteBuffer: ByteBuffer) { 15 | val initPos = byteBuffer.position() 16 | while(true) { 17 | val b = byteBuffer.get() 18 | if(b == NULL_BYTE) 19 | break 20 | } 21 | val endPos = byteBuffer.position() 22 | byteBuffer.position(initPos) 23 | val byteArr = ByteArray(endPos - initPos) 24 | byteBuffer.get(byteArr) 25 | value = String(byteArr, 0, byteArr.size - 1, StandardCharsets.US_ASCII) 26 | } 27 | 28 | companion object { 29 | const val NULL_BYTE = 0.toByte() 30 | } 31 | } 32 | 33 | class QConstRegion(override var value: ByteArray) : QueryPacketData() { 34 | override fun parseFrom(byteBuffer: ByteBuffer) { 35 | val actual = ByteArray(value.size) 36 | byteBuffer.get(actual) 37 | if(!Arrays.equals(value, actual)) { 38 | throw IllegalArgumentException("Const Region Mismatch! Expected: $value, Actual: $actual") 39 | } 40 | } 41 | } 42 | 43 | class QMap : QueryPacketData>() { 44 | override lateinit var value: Map 45 | override fun parseFrom(byteBuffer: ByteBuffer) { 46 | val stringParser = QString() 47 | val map = mutableMapOf() 48 | while(true) { 49 | stringParser.parseFrom(byteBuffer) 50 | if(stringParser.value.isEmpty()) break 51 | val key = stringParser.value 52 | stringParser.parseFrom(byteBuffer) 53 | map[key] = stringParser.value 54 | } 55 | value = map 56 | } 57 | } 58 | 59 | class QList : QueryPacketData>() { 60 | override lateinit var value: List 61 | override fun parseFrom(byteBuffer: ByteBuffer) { 62 | val stringParser = QString() 63 | val list = mutableListOf() 64 | while(true) { 65 | stringParser.parseFrom(byteBuffer) 66 | if(stringParser.value.isEmpty()) break 67 | list.add(stringParser.value) 68 | } 69 | value = list 70 | } 71 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/net/ServerAddress.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.net 2 | 3 | import gnu.inet.encoding.IDNA 4 | import org.xbill.DNS.* 5 | import org.zrnq.mcmotd.configStorage 6 | import java.net.Inet4Address 7 | import java.util.* 8 | 9 | const val DefaultPort = 25565 10 | 11 | const val addressPrefix = "_minecraft._tcp." 12 | private val dnsResolvers by lazy { 13 | configStorage.dnsServerList.map { 14 | SimpleResolver(Inet4Address.getByName(it)) 15 | .also { resolver -> resolver.timeout = java.time.Duration.ofSeconds(2) } 16 | } 17 | } 18 | 19 | private fun Resolver.query(name : String) : List { 20 | return send(Message.newQuery(Record.newRecord(Name.fromString(name), Type.SRV, DClass.IN))) 21 | .getSection(Section.ANSWER) 22 | } 23 | 24 | abstract class ServerAddress(val originalAddress: String, val port: Int, val queryPort: Int) { 25 | abstract fun addressList(): List> 26 | } 27 | 28 | class HostnameServerAddress(originalAddress: String, 29 | private val hostname : String, 30 | private var shouldResolve :Boolean = true, 31 | port : Int = DefaultPort, 32 | queryPort : Int = -1) 33 | : ServerAddress(originalAddress, port, queryPort) { 34 | init { 35 | if(hostname == "localhost") shouldResolve = false 36 | } 37 | private lateinit var cachedDNSResolutionResult : List> 38 | override fun addressList(): List> { 39 | if(!shouldResolve) return listOf(hostname to port) 40 | if(this::cachedDNSResolutionResult.isInitialized) 41 | return cachedDNSResolutionResult 42 | val encodedDomain = IDNA.toASCII(hostname) 43 | val addressList = mutableListOf>() 44 | val nameSet = mutableSetOf() 45 | addressList.add(encodedDomain to port) 46 | for(dnsResolver in dnsResolvers) { 47 | runCatching { 48 | dnsResolver.query("$addressPrefix${encodedDomain}.") 49 | }.fold({ 50 | it.forEach { rec -> 51 | check(rec is SRVRecord) 52 | rec.target.toString(true) 53 | .takeIf { addr -> nameSet.add(addr) } 54 | ?.also { addr -> addressList.add(addr to rec.port) } 55 | } 56 | }, { 57 | org.zrnq.mcmotd.genericLogger.error("SRV解析出错,请检查DNS服务器配置项[${dnsResolver.address}]:${it.message}") 58 | }) 59 | } 60 | cachedDNSResolutionResult = addressList 61 | return cachedDNSResolutionResult 62 | } 63 | } 64 | 65 | class IPServerAddress(originalAddress: String, 66 | private val address : String, 67 | port : Int = DefaultPort, 68 | queryPort : Int = -1) 69 | : ServerAddress(originalAddress, port, queryPort) { 70 | override fun addressList() = listOf(address to port) 71 | } 72 | 73 | object AddressRegexes { 74 | val ipv4addr = Regex("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$") 75 | val ipv6addr = Regex( 76 | "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|" + 77 | "([0-9a-fA-F]{1,4}:){1,7}:|" + 78 | "([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|" + 79 | "([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|" + 80 | "([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|" + 81 | "([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|" + 82 | "([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|" + 83 | "[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|" + 84 | ":((:[0-9a-fA-F]{1,4}){1,7}|:)|" + 85 | "fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|" + 86 | "::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|" + 87 | "([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))$") 88 | val addrWithPort = Regex("^[^:]*?(:((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4}))){1,2}$") 89 | val ipv6addrWithPort = Regex("^\\[[^\\[\\]]*?](:((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4}))){1,2}$") 90 | val genericHostname = Regex("^(localhost)|([^:.]+(\\.[^:.]+)+)$") 91 | } 92 | 93 | private val addressCache = Collections.synchronizedMap(WeakHashMap()) 94 | 95 | fun String.parseAddressCached(): ServerAddress? { 96 | var result = addressCache[this] 97 | if(result == null) { 98 | result = parseAddress() 99 | if(result != null) addressCache[this] = result 100 | } 101 | return result 102 | } 103 | 104 | fun String.parseAddress(): ServerAddress? { 105 | if(matches(AddressRegexes.addrWithPort)) { 106 | val segments = split(':') 107 | val serverAddress = segments[0] 108 | val serverPort = segments[1].toInt() 109 | val queryPort = if(segments.size >= 3) segments[2].toInt() else -1 110 | val serverOriginalAddress = if(segments.size >= 3) "$serverAddress:$serverPort" else this 111 | if(serverAddress.matches(AddressRegexes.ipv4addr)) 112 | return IPServerAddress(serverOriginalAddress, serverAddress, serverPort, queryPort) 113 | if(serverAddress.matches(AddressRegexes.genericHostname)) 114 | return HostnameServerAddress(serverOriginalAddress, serverAddress, false, serverPort, queryPort) 115 | return null 116 | } 117 | if(matches(AddressRegexes.ipv4addr)) 118 | return IPServerAddress(this, this) 119 | if(matches(AddressRegexes.genericHostname)) 120 | return HostnameServerAddress(this, this) 121 | if(matches(AddressRegexes.ipv6addrWithPort)) { 122 | val segments = split("]:") 123 | val serverAddress = segments[0].substring(1) 124 | val portSegments = segments[1].split(':') 125 | val serverPort = portSegments[0].toInt() 126 | val queryPort = if(portSegments.size >= 2) segments[1].toInt() else -1 127 | val serverOriginalAddress = if(portSegments.size >= 2) "[$serverAddress]:$serverPort" else this 128 | if(serverAddress.matches(AddressRegexes.ipv6addr)) 129 | return IPServerAddress(serverOriginalAddress, serverAddress, serverPort, queryPort) 130 | } 131 | if(matches(AddressRegexes.ipv6addr)) 132 | return IPServerAddress(this, this) 133 | return null 134 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/net/ServerInfo.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.net 2 | 3 | import com.alibaba.fastjson.JSON 4 | import com.alibaba.fastjson.JSONArray 5 | import com.alibaba.fastjson.JSONObject 6 | import org.zrnq.mcmotd.* 7 | 8 | class ServerInfo(private val actualAddress: String, 9 | response : String, 10 | private val latency : Int) { 11 | /**服务器图标*/ 12 | val favicon : String? 13 | /**服务器描述*/ 14 | private val description : String 15 | /**服务器版本号*/ 16 | private val version : String 17 | /**在线人数*/ 18 | var onlinePlayerCount : Int? 19 | /**服务器宣称的最大人数*/ 20 | private var maxPlayerCount : Int? 21 | /**服务器提供的部分在线玩家列表*/ 22 | private var samplePlayerList : String? 23 | /**本次查询用户所提供的原始服务器地址*/ 24 | private lateinit var originalAddress : String 25 | /**服务器的显示地址*/ 26 | private val serverAddress : String 27 | get() = if(configStorage.showTrueAddress) actualAddress else originalAddress 28 | 29 | init { 30 | val json = JSON.parseObject(response) 31 | favicon = json.getString("favicon") 32 | description = json.getString("description") 33 | version = json.getJSONObject("version").getString("name") 34 | val playerJson = json.getJSONObject("players") 35 | 36 | onlinePlayerCount = playerJson?.getIntValue("online") 37 | maxPlayerCount = playerJson?.getIntValue("max") 38 | samplePlayerList = playerJson?.getJSONArray("sample")?.toPlayerListString(10) 39 | } 40 | 41 | fun setOriginalAddress(address : String) : ServerInfo { 42 | originalAddress = address 43 | return this 44 | } 45 | 46 | private fun getDescriptionHTML(): String 47 | = if(description.startsWith("{")) jsonStringToHTML(JSON.parseObject(description)) 48 | else jsonStringToHTML(JSON.parseObject("{\"text\":\"$description\"}")) 49 | 50 | private fun getPingHTML(): String { 51 | val bars = when(latency) { 52 | -1 -> "red" to 0 53 | in 0 until 100 -> "green" to 5 54 | in 100 until 300 -> "green" to 4 55 | in 300 until 500 -> "green" to 3 56 | in 500 until 1000 -> "yellow" to 2 57 | else -> "red" to 1 58 | }.let { colorMap[it.first] to it.second } 59 | if(latency < 0) return "失败 [×]" 60 | return "${latency}ms " + 61 | "[${"|".repeat(bars.second)}" + 62 | "${"|".repeat(5 - bars.second)}]" 63 | } 64 | 65 | private fun getPlayerDescriptionHTML(): String { 66 | if(onlinePlayerCount == null) return "服务器未提供在线玩家信息" 67 | val playerCount = StringBuilder("在线人数: $onlinePlayerCount") 68 | if(configStorage.showPeakPlayers && dataStorage.peakPlayers.contains(originalAddress)) { 69 | playerCount.append("(${dataStorage.peakPlayers[originalAddress]})") 70 | } 71 | playerCount.append("/$maxPlayerCount ") 72 | if(!configStorage.showPlayerList) return playerCount.toString() 73 | playerCount.append("玩家列表: ") 74 | if(samplePlayerList == null) return playerCount.append("没有信息").toString() 75 | return playerCount.append(jsonStringToHTML(JSON.parseObject("{\"text\":\"$samplePlayerList\"}"))).toString() 76 | } 77 | 78 | 79 | fun toHTMLString(): String { 80 | val sb = StringBuilder("
") 81 | sb.append(getDescriptionHTML()) 82 | .append("
") 83 | .append("
访问地址: $serverAddress Ping: ") 84 | .append(getPingHTML()) 85 | .append("
") 86 | if(configStorage.showServerVersion) { 87 | sb.append("
") 88 | .append(version.limitLength(50)) 89 | .append("
") 90 | } 91 | sb.append("
") 92 | .append(getPlayerDescriptionHTML()) 93 | .append("
") 94 | .append("") 95 | return sb.toString() 96 | } 97 | 98 | fun merge(queryInfo: QueryServerInfo) { 99 | if(onlinePlayerCount == null) { 100 | onlinePlayerCount = queryInfo.serverProperties["numplayers"]?.toInt() 101 | } 102 | if(maxPlayerCount == null) { 103 | maxPlayerCount = queryInfo.serverProperties["maxplayers"]?.toInt() 104 | } 105 | if(samplePlayerList == null || samplePlayerList == emptyPlayerListMsg) { 106 | samplePlayerList = if(queryInfo.playerList.isEmpty()) emptyPlayerListMsg 107 | else queryInfo.playerList.joinToString(", ", limit = 10) 108 | } 109 | } 110 | 111 | companion object { 112 | fun JSONArray?.toPlayerListString(limit: Int) : String? { 113 | return if(this == null) null 114 | else if(isEmpty()) emptyPlayerListMsg 115 | else joinToString(", ", limit = limit) { (it as JSONObject).getString("name") } 116 | } 117 | 118 | const val emptyPlayerListMsg = "空" 119 | } 120 | } 121 | 122 | class QueryServerInfo(val serverProperties: Map, val playerList: List) -------------------------------------------------------------------------------- /src/main/kotlin/org/zrnq/mcmotd/output/OutputHandlers.kt: -------------------------------------------------------------------------------- 1 | package org.zrnq.mcmotd.output 2 | 3 | import org.zrnq.mcmotd.* 4 | import org.zrnq.mcmotd.data.ParsedConfig 5 | import org.zrnq.mcmotd.net.ServerInfo 6 | import org.zrnq.mcmotd.net.parseAddress 7 | import java.awt.BorderLayout 8 | import java.awt.Color 9 | import java.awt.Dimension 10 | import java.awt.event.KeyAdapter 11 | import java.awt.event.KeyEvent 12 | import javax.swing.* 13 | import javax.swing.border.EmptyBorder 14 | import kotlin.concurrent.thread 15 | 16 | abstract class AbstractOutputHandler { 17 | /** 18 | * Called before each ping request. 19 | * */ 20 | abstract fun beforePing() 21 | /** 22 | * Called before attempting each available address. 23 | * */ 24 | abstract fun onAttemptAddress(address : String) 25 | /** 26 | * Called after ping failed for an address. 27 | * */ 28 | abstract fun onAttemptFailure(exception : Exception, address : String) 29 | /** 30 | * Called if no server address returns a valid response, and before afterPing(). 31 | * */ 32 | abstract fun onFailure() 33 | /** 34 | * Called if one server address returns a valid response, and before afterPing(). 35 | * */ 36 | abstract fun onSuccess(info : ServerInfo) 37 | /** 38 | * Called after each ping request, and after onFailure() and onSuccess(). 39 | * */ 40 | abstract fun afterPing() 41 | } 42 | /** 43 | * Debug use only 44 | * */ 45 | class GUIOutputHandler : AbstractOutputHandler() { 46 | private val errBuilder = StringBuilder() 47 | private val mainFrame = JFrame("Ping MC Server") 48 | private val progress = JProgressBar().apply { isIndeterminate = true; isVisible = false; isStringPainted = true; font = ParsedConfig.font } 49 | private val resultLabel = JLabel().apply { font = ParsedConfig.font; foreground = Color.RED } 50 | private val textField = JTextField() 51 | init { 52 | textField.apply { 53 | font = ParsedConfig.font 54 | preferredSize = Dimension(500, preferredSize.height) 55 | addKeyListener(object : KeyAdapter() { 56 | override fun keyPressed(e : KeyEvent) { 57 | if(e.keyCode != KeyEvent.VK_ENTER) return 58 | val address = text.parseAddress() 59 | if(address == null) { 60 | resultLabel.icon = null 61 | resultLabel.text = "Invalid URL" 62 | mainFrame.pack() 63 | return 64 | } 65 | thread { 66 | pingInternal(address, this@GUIOutputHandler) 67 | progress.isVisible = false 68 | isVisible = true 69 | } 70 | } 71 | }) 72 | } 73 | val inputPane = JPanel().apply { 74 | layout = BorderLayout(20, 20) 75 | add(JLabel("Ping Target:").apply { font = ParsedConfig.font }, BorderLayout.WEST) 76 | add(JPanel().apply { 77 | layout = BorderLayout() 78 | add(progress, BorderLayout.CENTER) 79 | add(textField, BorderLayout.SOUTH) 80 | }, BorderLayout.CENTER) 81 | } 82 | mainFrame.apply { 83 | layout = BorderLayout(20, 20) 84 | add(inputPane, BorderLayout.NORTH) 85 | add(resultLabel, BorderLayout.CENTER) 86 | (contentPane as JPanel).border = EmptyBorder(20, 20, 20, 20) 87 | isVisible = true 88 | defaultCloseOperation = WindowConstants.EXIT_ON_CLOSE 89 | pack() 90 | centerOnScreen() 91 | } 92 | } 93 | override fun beforePing() { 94 | errBuilder.clear() 95 | errBuilder.append("") 96 | progress.isVisible = true 97 | progress.string = "Performing DNS Lookup..." 98 | textField.isVisible = false 99 | resultLabel.icon = null 100 | resultLabel.text = "" 101 | } 102 | 103 | override fun onAttemptAddress(address : String) { 104 | progress.string = address 105 | } 106 | 107 | override fun onAttemptFailure(exception : Exception, address : String) { 108 | exception.printStackTrace() 109 | errBuilder.append("${exception.translateCommonException()}
") 110 | } 111 | 112 | override fun onFailure() { 113 | resultLabel.text = errBuilder.append("
").toString() 114 | } 115 | 116 | override fun onSuccess(info : ServerInfo) { 117 | resultLabel.icon = ImageIcon(renderBasicInfoImage(info).addBackground()) 118 | } 119 | 120 | override fun afterPing() { 121 | progress.isVisible = false 122 | textField.isVisible = true 123 | mainFrame.pack() 124 | } 125 | } 126 | 127 | class APIOutputHandler( 128 | private val fail : (String) -> Unit, 129 | private val success : (ServerInfo) -> Unit 130 | ) : AbstractOutputHandler() { 131 | private val errBuilder = StringBuilder() 132 | override fun beforePing() { 133 | errBuilder.clear() 134 | errBuilder.append("查询失败,以下地址均未能成功获取:\n") 135 | } 136 | 137 | override fun onAttemptAddress(address : String) = Unit 138 | 139 | override fun onAttemptFailure(exception : Exception, address : String) { 140 | val message = exception.translateCommonException() 141 | if(message.contains(':')) 142 | genericLogger.warning("MC Ping Failed", exception) 143 | errBuilder.append("$address:$message\n") 144 | } 145 | 146 | override fun onFailure() = fail(errBuilder.toString()) 147 | 148 | override fun onSuccess(info : ServerInfo) = success(info) 149 | 150 | override fun afterPing() = Unit 151 | } -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/net.mamoe.mirai.console.plugin.jvm.JvmPlugin: -------------------------------------------------------------------------------- 1 | org.zrnq.mcmotd.McMotd -------------------------------------------------------------------------------- /src/main/resources/version.txt: -------------------------------------------------------------------------------- 1 | @version@ --------------------------------------------------------------------------------