nonebot-plugin-mcserver-status-check 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nonebot_plugin_mcserver_status_check-0.1.2/LICENSE +21 -0
- nonebot_plugin_mcserver_status_check-0.1.2/PKG-INFO +178 -0
- nonebot_plugin_mcserver_status_check-0.1.2/README.md +152 -0
- nonebot_plugin_mcserver_status_check-0.1.2/nonebot_plugin_mcserver_status_check/__init__.py +102 -0
- nonebot_plugin_mcserver_status_check-0.1.2/nonebot_plugin_mcserver_status_check/checker.py +536 -0
- nonebot_plugin_mcserver_status_check-0.1.2/nonebot_plugin_mcserver_status_check/config.py +17 -0
- nonebot_plugin_mcserver_status_check-0.1.2/nonebot_plugin_mcserver_status_check/mc_renderer.py +318 -0
- nonebot_plugin_mcserver_status_check-0.1.2/nonebot_plugin_mcserver_status_check/minecraft.ttf +0 -0
- nonebot_plugin_mcserver_status_check-0.1.2/pyproject.toml +20 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 leiuary
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nonebot-plugin-mcserver-status-check
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: A vanilla-style, beautiful and efficient Minecraft server status query plugin
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: leiuary
|
|
8
|
+
Author-email: leiuary@qq.com
|
|
9
|
+
Requires-Python: >=3.9,<4.0
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Requires-Dist: Pillow (>=9.1.0)
|
|
19
|
+
Requires-Dist: mcstatus (>=11.0.0)
|
|
20
|
+
Requires-Dist: nonebot-adapter-onebot (>=2.0.0,<3.0.0)
|
|
21
|
+
Requires-Dist: nonebot2 (>=2.0.0,<3.0.0)
|
|
22
|
+
Project-URL: Homepage, https://github.com/leiuary/nonebot-plugin-mcserver-status-check
|
|
23
|
+
Project-URL: Repository, https://github.com/leiuary/nonebot-plugin-mcserver-status-check
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
<div align="center">
|
|
27
|
+
<a href="https://v2.nonebot.dev/store">
|
|
28
|
+
<img src="https://raw.githubusercontent.com/fllesser/nonebot-plugin-template/refs/heads/resource/.docs/NoneBotPlugin.svg" width="310" alt="logo"></a>
|
|
29
|
+
|
|
30
|
+
## ✨ nonebot-plugin-mcserver-status-check ✨
|
|
31
|
+
[](./LICENSE)
|
|
32
|
+
[](https://pypi.python.org/pypi/nonebot-plugin-mcserver-status-check)
|
|
33
|
+
[](https://www.python.org)
|
|
34
|
+
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
## 📖 介绍
|
|
38
|
+
|
|
39
|
+
- 支持同时查询多个服务器。
|
|
40
|
+
- 生成美观的状态卡片图片。
|
|
41
|
+
- 显示服务器图标 (Favicon)。
|
|
42
|
+
- 解析并渲染 Minecraft 颜色代码 (MOTD)。
|
|
43
|
+
- 详细的延迟测试(预热、多次测试取平均值、去极值)。
|
|
44
|
+
- 可配置的字体和显示选项。
|
|
45
|
+
|
|
46
|
+
## 💿 安装
|
|
47
|
+
|
|
48
|
+
<details open>
|
|
49
|
+
<summary>使用 nb-cli 安装</summary>
|
|
50
|
+
在 nonebot2 项目的根目录下打开命令行, 输入以下指令即可安装
|
|
51
|
+
|
|
52
|
+
nb plugin install nonebot-plugin-mcserver-status-check --upgrade
|
|
53
|
+
使用 **pypi** 源安装
|
|
54
|
+
|
|
55
|
+
nb plugin install nonebot-plugin-mcserver-status-check --upgrade -i "https://pypi.org/simple"
|
|
56
|
+
使用**清华源**安装
|
|
57
|
+
|
|
58
|
+
nb plugin install nonebot-plugin-mcserver-status-check --upgrade -i "https://pypi.tuna.tsinghua.edu.cn/simple"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
</details>
|
|
62
|
+
|
|
63
|
+
<details>
|
|
64
|
+
<summary>使用包管理器安装</summary>
|
|
65
|
+
在 nonebot2 项目的插件目录下, 打开命令行, 根据你使用的包管理器, 输入相应的安装命令
|
|
66
|
+
|
|
67
|
+
<details open>
|
|
68
|
+
<summary>uv</summary>
|
|
69
|
+
|
|
70
|
+
uv add nonebot-plugin-mcserver-status-check
|
|
71
|
+
安装仓库 master 分支
|
|
72
|
+
|
|
73
|
+
uv add git+https://github.com/leiuary/nonebot-plugin-mcserver-status-check@master
|
|
74
|
+
</details>
|
|
75
|
+
|
|
76
|
+
<details>
|
|
77
|
+
<summary>pdm</summary>
|
|
78
|
+
|
|
79
|
+
pdm add nonebot-plugin-mcserver-status-check
|
|
80
|
+
安装仓库 master 分支
|
|
81
|
+
|
|
82
|
+
pdm add git+https://github.com/leiuary/nonebot-plugin-mcserver-status-check@master
|
|
83
|
+
</details>
|
|
84
|
+
<details>
|
|
85
|
+
<summary>poetry</summary>
|
|
86
|
+
|
|
87
|
+
poetry add nonebot-plugin-mcserver-status-check
|
|
88
|
+
安装仓库 master 分支
|
|
89
|
+
|
|
90
|
+
poetry add git+https://github.com/leiuary/nonebot-plugin-mcserver-status-check@master
|
|
91
|
+
</details>
|
|
92
|
+
|
|
93
|
+
打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot]` 部分追加写入
|
|
94
|
+
|
|
95
|
+
plugins = ["nonebot_plugin_mcserver_status_check"]
|
|
96
|
+
|
|
97
|
+
</details>
|
|
98
|
+
|
|
99
|
+
<details>
|
|
100
|
+
<summary>使用 nbr 安装(使用 uv 管理依赖可用)</summary>
|
|
101
|
+
|
|
102
|
+
[nbr](https://github.com/fllesser/nbr) 是一个基于 uv 的 nb-cli,可以方便地管理 nonebot2
|
|
103
|
+
|
|
104
|
+
nbr plugin install nonebot-plugin-mcserver-status-check
|
|
105
|
+
使用 **pypi** 源安装
|
|
106
|
+
|
|
107
|
+
nbr plugin install nonebot-plugin-mcserver-status-check -i "https://pypi.org/simple"
|
|
108
|
+
使用**清华源**安装
|
|
109
|
+
|
|
110
|
+
nbr plugin install nonebot-plugin-mcserver-status-check -i "https://pypi.tuna.tsinghua.edu.cn/simple"
|
|
111
|
+
|
|
112
|
+
</details>
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
## ⚙️ 配置
|
|
116
|
+
|
|
117
|
+
在 nonebot2 项目的`.env`文件中添加下表中的配置
|
|
118
|
+
|
|
119
|
+
```dotenv
|
|
120
|
+
# 服务器列表
|
|
121
|
+
MSC_SERVER_LIST='[]'
|
|
122
|
+
|
|
123
|
+
# 延迟测试间隔(秒)
|
|
124
|
+
MSC_LATENCY_INTERVAL=0.1
|
|
125
|
+
|
|
126
|
+
# 延迟测试预热次数
|
|
127
|
+
MSC_LATENCY_WARMUP=1
|
|
128
|
+
|
|
129
|
+
# 延迟测试次数
|
|
130
|
+
MSC_LATENCY_COUNT=3
|
|
131
|
+
|
|
132
|
+
# 是否去极值(去掉最大最小)
|
|
133
|
+
MSC_LATENCY_TRIM=true
|
|
134
|
+
|
|
135
|
+
# 是否在控制台显示详细耗时
|
|
136
|
+
MSC_SHOW_TIMING_DETAILS=false
|
|
137
|
+
|
|
138
|
+
# 字体文件
|
|
139
|
+
MSC_FONT_PATH="minecraft.ttf"
|
|
140
|
+
|
|
141
|
+
# 触发指令列表
|
|
142
|
+
MSC_COMMAND_TRIGGERS=["查服"]
|
|
143
|
+
|
|
144
|
+
# 是否在列表模式显示玩家名单
|
|
145
|
+
MSC_SHOW_PLAYER_LIST=false
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### 服务器列表配置示例
|
|
149
|
+
|
|
150
|
+
```dotenv
|
|
151
|
+
MSC_SERVER_LIST='[
|
|
152
|
+
{"address": "mc.hypixel.net", "alias": "Hypixel"},
|
|
153
|
+
{"address": "play.example.com", "alias": "示例服"}
|
|
154
|
+
]'
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## 🎉 使用
|
|
158
|
+
### 指令列表
|
|
159
|
+
|
|
160
|
+
- **查服**
|
|
161
|
+
- 查询所有配置的服务器状态,生成汇总图片。
|
|
162
|
+
- **查服 [IP/别名]**
|
|
163
|
+
- 单独查询指定服务器的状态。
|
|
164
|
+
- **查服列表**
|
|
165
|
+
- 查看当前配置的服务器列表及别名。
|
|
166
|
+
|
|
167
|
+
### 🎨 效果图
|
|
168
|
+
如果有效果图的话
|
|
169
|
+
|
|
170
|
+
## 💡 特性
|
|
171
|
+
|
|
172
|
+
- 🎨 **原版风格**:完美还原 Minecraft 游戏内的服务器列表显示效果。
|
|
173
|
+
- ⚡ **高效并发**:支持多服务器并行查询,速度飞快。
|
|
174
|
+
- 📊 **精准延迟**:内置预热和去极值算法,提供最真实的延迟数据。
|
|
175
|
+
- 📝 **颜色支持**:完整支持 Minecraft 样式代码 (§) 和 JSON 格式 MOTD。
|
|
176
|
+
- 🖼️ **图标显示**:自动获取并显示服务器 Favicon。
|
|
177
|
+
- 👥 **玩家列表**:支持显示在线玩家列表(需服务器开启相关功能)。
|
|
178
|
+
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<a href="https://v2.nonebot.dev/store">
|
|
3
|
+
<img src="https://raw.githubusercontent.com/fllesser/nonebot-plugin-template/refs/heads/resource/.docs/NoneBotPlugin.svg" width="310" alt="logo"></a>
|
|
4
|
+
|
|
5
|
+
## ✨ nonebot-plugin-mcserver-status-check ✨
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
[](https://pypi.python.org/pypi/nonebot-plugin-mcserver-status-check)
|
|
8
|
+
[](https://www.python.org)
|
|
9
|
+
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
## 📖 介绍
|
|
13
|
+
|
|
14
|
+
- 支持同时查询多个服务器。
|
|
15
|
+
- 生成美观的状态卡片图片。
|
|
16
|
+
- 显示服务器图标 (Favicon)。
|
|
17
|
+
- 解析并渲染 Minecraft 颜色代码 (MOTD)。
|
|
18
|
+
- 详细的延迟测试(预热、多次测试取平均值、去极值)。
|
|
19
|
+
- 可配置的字体和显示选项。
|
|
20
|
+
|
|
21
|
+
## 💿 安装
|
|
22
|
+
|
|
23
|
+
<details open>
|
|
24
|
+
<summary>使用 nb-cli 安装</summary>
|
|
25
|
+
在 nonebot2 项目的根目录下打开命令行, 输入以下指令即可安装
|
|
26
|
+
|
|
27
|
+
nb plugin install nonebot-plugin-mcserver-status-check --upgrade
|
|
28
|
+
使用 **pypi** 源安装
|
|
29
|
+
|
|
30
|
+
nb plugin install nonebot-plugin-mcserver-status-check --upgrade -i "https://pypi.org/simple"
|
|
31
|
+
使用**清华源**安装
|
|
32
|
+
|
|
33
|
+
nb plugin install nonebot-plugin-mcserver-status-check --upgrade -i "https://pypi.tuna.tsinghua.edu.cn/simple"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
</details>
|
|
37
|
+
|
|
38
|
+
<details>
|
|
39
|
+
<summary>使用包管理器安装</summary>
|
|
40
|
+
在 nonebot2 项目的插件目录下, 打开命令行, 根据你使用的包管理器, 输入相应的安装命令
|
|
41
|
+
|
|
42
|
+
<details open>
|
|
43
|
+
<summary>uv</summary>
|
|
44
|
+
|
|
45
|
+
uv add nonebot-plugin-mcserver-status-check
|
|
46
|
+
安装仓库 master 分支
|
|
47
|
+
|
|
48
|
+
uv add git+https://github.com/leiuary/nonebot-plugin-mcserver-status-check@master
|
|
49
|
+
</details>
|
|
50
|
+
|
|
51
|
+
<details>
|
|
52
|
+
<summary>pdm</summary>
|
|
53
|
+
|
|
54
|
+
pdm add nonebot-plugin-mcserver-status-check
|
|
55
|
+
安装仓库 master 分支
|
|
56
|
+
|
|
57
|
+
pdm add git+https://github.com/leiuary/nonebot-plugin-mcserver-status-check@master
|
|
58
|
+
</details>
|
|
59
|
+
<details>
|
|
60
|
+
<summary>poetry</summary>
|
|
61
|
+
|
|
62
|
+
poetry add nonebot-plugin-mcserver-status-check
|
|
63
|
+
安装仓库 master 分支
|
|
64
|
+
|
|
65
|
+
poetry add git+https://github.com/leiuary/nonebot-plugin-mcserver-status-check@master
|
|
66
|
+
</details>
|
|
67
|
+
|
|
68
|
+
打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot]` 部分追加写入
|
|
69
|
+
|
|
70
|
+
plugins = ["nonebot_plugin_mcserver_status_check"]
|
|
71
|
+
|
|
72
|
+
</details>
|
|
73
|
+
|
|
74
|
+
<details>
|
|
75
|
+
<summary>使用 nbr 安装(使用 uv 管理依赖可用)</summary>
|
|
76
|
+
|
|
77
|
+
[nbr](https://github.com/fllesser/nbr) 是一个基于 uv 的 nb-cli,可以方便地管理 nonebot2
|
|
78
|
+
|
|
79
|
+
nbr plugin install nonebot-plugin-mcserver-status-check
|
|
80
|
+
使用 **pypi** 源安装
|
|
81
|
+
|
|
82
|
+
nbr plugin install nonebot-plugin-mcserver-status-check -i "https://pypi.org/simple"
|
|
83
|
+
使用**清华源**安装
|
|
84
|
+
|
|
85
|
+
nbr plugin install nonebot-plugin-mcserver-status-check -i "https://pypi.tuna.tsinghua.edu.cn/simple"
|
|
86
|
+
|
|
87
|
+
</details>
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
## ⚙️ 配置
|
|
91
|
+
|
|
92
|
+
在 nonebot2 项目的`.env`文件中添加下表中的配置
|
|
93
|
+
|
|
94
|
+
```dotenv
|
|
95
|
+
# 服务器列表
|
|
96
|
+
MSC_SERVER_LIST='[]'
|
|
97
|
+
|
|
98
|
+
# 延迟测试间隔(秒)
|
|
99
|
+
MSC_LATENCY_INTERVAL=0.1
|
|
100
|
+
|
|
101
|
+
# 延迟测试预热次数
|
|
102
|
+
MSC_LATENCY_WARMUP=1
|
|
103
|
+
|
|
104
|
+
# 延迟测试次数
|
|
105
|
+
MSC_LATENCY_COUNT=3
|
|
106
|
+
|
|
107
|
+
# 是否去极值(去掉最大最小)
|
|
108
|
+
MSC_LATENCY_TRIM=true
|
|
109
|
+
|
|
110
|
+
# 是否在控制台显示详细耗时
|
|
111
|
+
MSC_SHOW_TIMING_DETAILS=false
|
|
112
|
+
|
|
113
|
+
# 字体文件
|
|
114
|
+
MSC_FONT_PATH="minecraft.ttf"
|
|
115
|
+
|
|
116
|
+
# 触发指令列表
|
|
117
|
+
MSC_COMMAND_TRIGGERS=["查服"]
|
|
118
|
+
|
|
119
|
+
# 是否在列表模式显示玩家名单
|
|
120
|
+
MSC_SHOW_PLAYER_LIST=false
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### 服务器列表配置示例
|
|
124
|
+
|
|
125
|
+
```dotenv
|
|
126
|
+
MSC_SERVER_LIST='[
|
|
127
|
+
{"address": "mc.hypixel.net", "alias": "Hypixel"},
|
|
128
|
+
{"address": "play.example.com", "alias": "示例服"}
|
|
129
|
+
]'
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## 🎉 使用
|
|
133
|
+
### 指令列表
|
|
134
|
+
|
|
135
|
+
- **查服**
|
|
136
|
+
- 查询所有配置的服务器状态,生成汇总图片。
|
|
137
|
+
- **查服 [IP/别名]**
|
|
138
|
+
- 单独查询指定服务器的状态。
|
|
139
|
+
- **查服列表**
|
|
140
|
+
- 查看当前配置的服务器列表及别名。
|
|
141
|
+
|
|
142
|
+
### 🎨 效果图
|
|
143
|
+
如果有效果图的话
|
|
144
|
+
|
|
145
|
+
## 💡 特性
|
|
146
|
+
|
|
147
|
+
- 🎨 **原版风格**:完美还原 Minecraft 游戏内的服务器列表显示效果。
|
|
148
|
+
- ⚡ **高效并发**:支持多服务器并行查询,速度飞快。
|
|
149
|
+
- 📊 **精准延迟**:内置预热和去极值算法,提供最真实的延迟数据。
|
|
150
|
+
- 📝 **颜色支持**:完整支持 Minecraft 样式代码 (§) 和 JSON 格式 MOTD。
|
|
151
|
+
- 🖼️ **图标显示**:自动获取并显示服务器 Favicon。
|
|
152
|
+
- 👥 **玩家列表**:支持显示在线玩家列表(需服务器开启相关功能)。
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import io
|
|
5
|
+
from typing import Annotated, Set
|
|
6
|
+
|
|
7
|
+
from nonebot import get_plugin_config, on_command
|
|
8
|
+
from nonebot.adapters.onebot.v11 import Message, MessageSegment
|
|
9
|
+
from nonebot.exception import FinishedException
|
|
10
|
+
from nonebot.params import CommandArg
|
|
11
|
+
from nonebot.plugin import PluginMetadata
|
|
12
|
+
from nonebot.matcher import Matcher
|
|
13
|
+
|
|
14
|
+
from .config import Config
|
|
15
|
+
from .checker import generate_mcmotd_image, generate_single_server_image
|
|
16
|
+
|
|
17
|
+
__plugin_meta__ = PluginMetadata(
|
|
18
|
+
name="Minecraft Server Status",
|
|
19
|
+
description="一款原版风格、美观高效的 Minecraft 服务器状态查询插件",
|
|
20
|
+
usage="\n".join([
|
|
21
|
+
"指令:",
|
|
22
|
+
"查服",
|
|
23
|
+
"查服 [IP/别名]",
|
|
24
|
+
]),
|
|
25
|
+
type="application",
|
|
26
|
+
homepage="https://github.com/leiuary/nonebot-plugin-mcserver-status-check",
|
|
27
|
+
supported_adapters={"nonebot.adapters.onebot.v11"},
|
|
28
|
+
config=Config,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Load configuration
|
|
32
|
+
plugin_config = get_plugin_config(Config)
|
|
33
|
+
|
|
34
|
+
# Determine command name and aliases
|
|
35
|
+
triggers = plugin_config.msc_command_triggers
|
|
36
|
+
cmd_name = triggers[0] if triggers else "查服"
|
|
37
|
+
cmd_aliases: Set[str | tuple[str, ...]] = set(triggers[1:]) if len(triggers) > 1 else set()
|
|
38
|
+
|
|
39
|
+
# Define command
|
|
40
|
+
mcmotd: Matcher = on_command(cmd_name, aliases=cmd_aliases, priority=5, block=True)
|
|
41
|
+
|
|
42
|
+
@mcmotd.handle()
|
|
43
|
+
async def handle_mcmotd(args: Annotated[Message, CommandArg()]):
|
|
44
|
+
arg_text = args.extract_plain_text().strip()
|
|
45
|
+
|
|
46
|
+
if arg_text:
|
|
47
|
+
# Single server query
|
|
48
|
+
target_address = arg_text
|
|
49
|
+
# Check if it matches an alias
|
|
50
|
+
for server in plugin_config.msc_server_list:
|
|
51
|
+
if server.alias == arg_text:
|
|
52
|
+
target_address = server.address
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
await mcmotd.send(f"正在查询服务器 {target_address},请稍候...")
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
image = await asyncio.to_thread(generate_single_server_image, target_address, plugin_config)
|
|
59
|
+
|
|
60
|
+
if image:
|
|
61
|
+
img_byte_arr = io.BytesIO()
|
|
62
|
+
image.save(img_byte_arr, format='PNG')
|
|
63
|
+
img_bytes = img_byte_arr.getvalue()
|
|
64
|
+
await mcmotd.finish(MessageSegment.image(img_bytes))
|
|
65
|
+
else:
|
|
66
|
+
await mcmotd.finish("查询失败,未能生成状态图片。")
|
|
67
|
+
|
|
68
|
+
except FinishedException:
|
|
69
|
+
raise
|
|
70
|
+
except Exception as e:
|
|
71
|
+
await mcmotd.finish(f"发生错误: {str(e)}")
|
|
72
|
+
|
|
73
|
+
else:
|
|
74
|
+
# All servers query
|
|
75
|
+
await mcmotd.send("正在查询服务器状态,请稍候...")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
image = await asyncio.to_thread(generate_mcmotd_image, plugin_config)
|
|
79
|
+
|
|
80
|
+
if image:
|
|
81
|
+
img_byte_arr = io.BytesIO()
|
|
82
|
+
image.save(img_byte_arr, format='PNG')
|
|
83
|
+
img_bytes = img_byte_arr.getvalue()
|
|
84
|
+
await mcmotd.finish(MessageSegment.image(img_bytes))
|
|
85
|
+
else:
|
|
86
|
+
await mcmotd.finish("查询失败,未能生成状态图片。")
|
|
87
|
+
|
|
88
|
+
except FinishedException:
|
|
89
|
+
raise
|
|
90
|
+
except Exception as e:
|
|
91
|
+
await mcmotd.finish(f"发生错误: {str(e)}")
|
|
92
|
+
|
|
93
|
+
# List command
|
|
94
|
+
cmd_list = on_command("查服列表", priority=5, block=True)
|
|
95
|
+
|
|
96
|
+
@cmd_list.handle()
|
|
97
|
+
async def handle_list():
|
|
98
|
+
msg = "已保存的服务器列表:\n"
|
|
99
|
+
for server in plugin_config.msc_server_list:
|
|
100
|
+
alias_str = f" (别名: {server.alias})" if server.alias else ""
|
|
101
|
+
msg += f"- {server.address}{alias_str}\n"
|
|
102
|
+
await cmd_list.finish(msg.strip())
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import concurrent.futures
|
|
4
|
+
import json
|
|
5
|
+
import socket
|
|
6
|
+
import struct
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
13
|
+
from mcstatus import JavaServer
|
|
14
|
+
|
|
15
|
+
from .config import Config
|
|
16
|
+
from .mc_renderer import calculate_required_width, generate_server_card, parse_inner_legacy
|
|
17
|
+
|
|
18
|
+
# Image Settings
|
|
19
|
+
TITLE_TEXT = "Minecraft Server Status"
|
|
20
|
+
FOOTER_TEXT_TEMPLATE = "{time} | Made by leiuary"
|
|
21
|
+
BG_COLOR = (30, 30, 30)
|
|
22
|
+
TEXT_COLOR = (255, 255, 255)
|
|
23
|
+
FOOTER_COLOR = (150, 150, 150)
|
|
24
|
+
ROW_GAP = 10
|
|
25
|
+
|
|
26
|
+
# --- 辅助函数 ---
|
|
27
|
+
|
|
28
|
+
def get_font_path(config: Config) -> str:
|
|
29
|
+
"""解析字体路径,如果未找到则检查本地目录。"""
|
|
30
|
+
path = Path(config.msc_font_path)
|
|
31
|
+
if path.exists():
|
|
32
|
+
return str(path)
|
|
33
|
+
|
|
34
|
+
# 尝试在与此文件相同的目录中查找
|
|
35
|
+
local_path = Path(__file__).parent / config.msc_font_path
|
|
36
|
+
if local_path.exists():
|
|
37
|
+
return str(local_path)
|
|
38
|
+
|
|
39
|
+
return config.msc_font_path
|
|
40
|
+
|
|
41
|
+
def pack_varint(d):
|
|
42
|
+
"""将整数打包为 VarInt (Minecraft 协议)。"""
|
|
43
|
+
o = b''
|
|
44
|
+
while True:
|
|
45
|
+
b = d & 0x7F
|
|
46
|
+
d >>= 7
|
|
47
|
+
o += struct.pack("B", b | (0x80 if d > 0 else 0))
|
|
48
|
+
if d == 0: break
|
|
49
|
+
return o
|
|
50
|
+
|
|
51
|
+
def unpack_varint(sock):
|
|
52
|
+
"""从 socket 解包 VarInt。"""
|
|
53
|
+
d = 0
|
|
54
|
+
for i in range(5):
|
|
55
|
+
b = sock.recv(1)
|
|
56
|
+
if not b: return 0
|
|
57
|
+
b = ord(b)
|
|
58
|
+
d |= (b & 0x7F) << 7 * i
|
|
59
|
+
if not b & 0x80: return d
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
def get_rgb_json(hostname, port):
|
|
63
|
+
"""
|
|
64
|
+
使用原始协议连接到 Minecraft 服务器以检索完整的 JSON 状态。
|
|
65
|
+
这样做是因为 mcstatus 有时会简化 JSON 结构。
|
|
66
|
+
"""
|
|
67
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
68
|
+
sock.settimeout(5)
|
|
69
|
+
try:
|
|
70
|
+
sock.connect((hostname, port))
|
|
71
|
+
protocol = 763 # MC 1.20.4 协议版本 (近似值)
|
|
72
|
+
host_bytes = hostname.encode('utf-8')
|
|
73
|
+
|
|
74
|
+
# 握手包
|
|
75
|
+
data = (b'\x00' + pack_varint(protocol) + pack_varint(len(host_bytes)) +
|
|
76
|
+
host_bytes + struct.pack('!H', port) + pack_varint(1))
|
|
77
|
+
sock.send(pack_varint(len(data)) + data)
|
|
78
|
+
|
|
79
|
+
# 请求包
|
|
80
|
+
sock.send(b'\x01\x00')
|
|
81
|
+
time.sleep(0.1)
|
|
82
|
+
sock.send(b'\x01\x00') # 发送两次以确保在某些情况下能收到响应
|
|
83
|
+
|
|
84
|
+
_ = unpack_varint(sock) # 包长度
|
|
85
|
+
packet_id = unpack_varint(sock)
|
|
86
|
+
|
|
87
|
+
if packet_id != 0:
|
|
88
|
+
raise Exception("Packet ID mismatch")
|
|
89
|
+
|
|
90
|
+
json_len = unpack_varint(sock)
|
|
91
|
+
buffer = b''
|
|
92
|
+
while len(buffer) < json_len:
|
|
93
|
+
chunk = sock.recv(min(4096, json_len - len(buffer)))
|
|
94
|
+
if not chunk: break
|
|
95
|
+
buffer += chunk
|
|
96
|
+
|
|
97
|
+
return json.loads(buffer)
|
|
98
|
+
finally:
|
|
99
|
+
sock.close()
|
|
100
|
+
|
|
101
|
+
class MixedStatus:
|
|
102
|
+
"""
|
|
103
|
+
结合了原始 JSON 数据和 mcstatus 延迟的混合状态对象。
|
|
104
|
+
模仿渲染器预期的结构。
|
|
105
|
+
"""
|
|
106
|
+
@dataclass
|
|
107
|
+
class Players:
|
|
108
|
+
online: int
|
|
109
|
+
max: int
|
|
110
|
+
sample: list[dict[str, Any]]
|
|
111
|
+
|
|
112
|
+
def __init__(self, raw_json: dict[str, Any], mcstatus_latency: float, fail_count: int = 0):
|
|
113
|
+
self.raw = raw_json
|
|
114
|
+
self.latency = mcstatus_latency
|
|
115
|
+
self.fail_count = fail_count
|
|
116
|
+
self.favicon = raw_json.get("favicon")
|
|
117
|
+
p = raw_json.get("players", {})
|
|
118
|
+
self.players = self.Players(
|
|
119
|
+
online=p.get("online", 0),
|
|
120
|
+
max=p.get("max", 0),
|
|
121
|
+
sample=p.get("sample", []),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
class OfflineStatus:
|
|
125
|
+
"""
|
|
126
|
+
表示服务器连接失败。
|
|
127
|
+
"""
|
|
128
|
+
def __init__(self, error_msg: str):
|
|
129
|
+
self.raw = {
|
|
130
|
+
"description": {"text": f"§c无法连接到服务器\n§7{error_msg}"},
|
|
131
|
+
"players": {"online": 0, "max": 0},
|
|
132
|
+
"favicon": None, "version": {"name": "Unknown"}
|
|
133
|
+
}
|
|
134
|
+
self.latency = -1
|
|
135
|
+
self.players = MixedStatus.Players(online=0, max=0, sample=[])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# --- 核心逻辑 ---
|
|
139
|
+
|
|
140
|
+
def query_one_server(index, address, config: Config):
|
|
141
|
+
"""
|
|
142
|
+
查询单个服务器的状态。
|
|
143
|
+
执行预热 Ping,多次测试 Ping,并计算平均延迟。
|
|
144
|
+
"""
|
|
145
|
+
print(f"⏳ [{index+1}] 查询: {address}")
|
|
146
|
+
t_start = time.time()
|
|
147
|
+
detailed_logs = []
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
# --- 延迟测试 ---
|
|
151
|
+
latencies = []
|
|
152
|
+
last_std_status = None
|
|
153
|
+
|
|
154
|
+
t_warmup_start = time.time()
|
|
155
|
+
|
|
156
|
+
# 1. 预热
|
|
157
|
+
if config.msc_latency_warmup > 0:
|
|
158
|
+
for w_i in range(config.msc_latency_warmup):
|
|
159
|
+
try:
|
|
160
|
+
t0 = time.time()
|
|
161
|
+
temp_server = JavaServer.lookup(address, timeout=5)
|
|
162
|
+
t1 = time.time()
|
|
163
|
+
temp_server.status()
|
|
164
|
+
t2 = time.time()
|
|
165
|
+
detailed_logs.append(f"预热#{w_i+1}: DNS={(t1-t0)*1000:.1f}ms, Query={(t2-t1)*1000:.1f}ms")
|
|
166
|
+
time.sleep(config.msc_latency_interval)
|
|
167
|
+
except Exception:
|
|
168
|
+
detailed_logs.append(f"预热#{w_i+1}: 失败")
|
|
169
|
+
t_warmup_end = time.time()
|
|
170
|
+
|
|
171
|
+
# 2. 实际测试
|
|
172
|
+
target_count = config.msc_latency_count + (2 if config.msc_latency_trim else 0)
|
|
173
|
+
t_test_start = time.time()
|
|
174
|
+
|
|
175
|
+
fail_count = 0
|
|
176
|
+
for i in range(target_count):
|
|
177
|
+
try:
|
|
178
|
+
t0 = time.time()
|
|
179
|
+
# 每次都重新 lookup 以避免连接复用问题
|
|
180
|
+
server = JavaServer.lookup(address, timeout=5)
|
|
181
|
+
t1 = time.time()
|
|
182
|
+
st = server.status()
|
|
183
|
+
t2 = time.time()
|
|
184
|
+
|
|
185
|
+
dns_ms = (t1 - t0) * 1000
|
|
186
|
+
query_ms = (t2 - t1) * 1000
|
|
187
|
+
ping_ms = st.latency
|
|
188
|
+
|
|
189
|
+
latencies.append(ping_ms)
|
|
190
|
+
last_std_status = st
|
|
191
|
+
detailed_logs.append(f"测试#{i+1}: DNS={dns_ms:.1f}ms, Query={query_ms:.1f}ms, Ping={ping_ms:.1f}ms")
|
|
192
|
+
except Exception:
|
|
193
|
+
fail_count += 1
|
|
194
|
+
detailed_logs.append(f"测试#{i+1}: 失败")
|
|
195
|
+
|
|
196
|
+
if i < target_count - 1:
|
|
197
|
+
time.sleep(config.msc_latency_interval)
|
|
198
|
+
t_test_end = time.time()
|
|
199
|
+
|
|
200
|
+
if not latencies:
|
|
201
|
+
raise Exception("无法连接到服务器 (所有尝试均失败)")
|
|
202
|
+
|
|
203
|
+
# 3. 计算统计数据
|
|
204
|
+
is_trimmed = False
|
|
205
|
+
raw_latencies = list(latencies)
|
|
206
|
+
|
|
207
|
+
# 方差始终基于原始数据计算
|
|
208
|
+
raw_avg = sum(latencies) / len(latencies)
|
|
209
|
+
variance = sum((x - raw_avg) ** 2 for x in latencies) / len(latencies)
|
|
210
|
+
|
|
211
|
+
if config.msc_latency_trim and len(latencies) >= 3:
|
|
212
|
+
sorted_latencies = sorted(latencies)
|
|
213
|
+
valid_latencies = sorted_latencies[1:-1]
|
|
214
|
+
avg_latency = sum(valid_latencies) / len(valid_latencies)
|
|
215
|
+
is_trimmed = True
|
|
216
|
+
else:
|
|
217
|
+
avg_latency = raw_avg
|
|
218
|
+
|
|
219
|
+
# 记录结果
|
|
220
|
+
latency_str = ", ".join([f"{l:.2f}" for l in raw_latencies])
|
|
221
|
+
avg_note = " (已去极值)" if is_trimmed else ""
|
|
222
|
+
|
|
223
|
+
t_total = time.time() - t_start
|
|
224
|
+
t_warmup = t_warmup_end - t_warmup_start
|
|
225
|
+
t_test = t_test_end - t_test_start
|
|
226
|
+
|
|
227
|
+
print(f"✅ [{index+1}] {address} -> 延迟: [{latency_str}] -> 平均: {avg_latency:.2f} ms{avg_note}, 方差: {variance:.2f}, 丢包: {fail_count}")
|
|
228
|
+
|
|
229
|
+
if config.msc_show_timing_details:
|
|
230
|
+
print(f" 🕒 耗时详情: 总计 {t_total:.2f}s (预热: {t_warmup:.2f}s, 测试: {t_test:.2f}s)")
|
|
231
|
+
for log in detailed_logs:
|
|
232
|
+
print(f" -> {log}")
|
|
233
|
+
|
|
234
|
+
# 4. 获取完整 JSON 数据
|
|
235
|
+
# 我们需要真实的 host/port (已解析) 来获取原始 JSON
|
|
236
|
+
final_server = JavaServer.lookup(address, timeout=5)
|
|
237
|
+
real_host = final_server.address.host
|
|
238
|
+
real_port = final_server.address.port
|
|
239
|
+
|
|
240
|
+
rgb_json = get_rgb_json(real_host, real_port)
|
|
241
|
+
status_obj = MixedStatus(rgb_json, avg_latency, fail_count)
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
"index": index,
|
|
245
|
+
"address": address,
|
|
246
|
+
"success": True,
|
|
247
|
+
"json": rgb_json,
|
|
248
|
+
"status_obj": status_obj
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
except Exception as e:
|
|
252
|
+
err_str = str(e)
|
|
253
|
+
if "Expecting value" in err_str: err_str = "服务器返回无效数据"
|
|
254
|
+
elif "timed out" in err_str or "lifetime expired" in err_str: err_str = "连接超时"
|
|
255
|
+
elif "getaddrinfo" in err_str: err_str = "域名解析失败"
|
|
256
|
+
|
|
257
|
+
print(f"⚠️ [{index+1}] {address} 失败: {err_str}")
|
|
258
|
+
|
|
259
|
+
offline_obj = OfflineStatus(err_str)
|
|
260
|
+
return {
|
|
261
|
+
"index": index,
|
|
262
|
+
"address": address,
|
|
263
|
+
"success": False,
|
|
264
|
+
"json": {"error": err_str},
|
|
265
|
+
"status_obj": offline_obj
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
def create_summary_image(combined_rows, config: Config) -> Optional[Image.Image]:
|
|
269
|
+
"""
|
|
270
|
+
将单独的服务器行合并为带有标题和页脚的单个汇总图像。
|
|
271
|
+
"""
|
|
272
|
+
if not combined_rows:
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
# 加载字体
|
|
276
|
+
font_path = get_font_path(config)
|
|
277
|
+
try:
|
|
278
|
+
font_title = ImageFont.truetype(font_path, 48)
|
|
279
|
+
font_footer = ImageFont.truetype(font_path, 24)
|
|
280
|
+
except:
|
|
281
|
+
font_title = ImageFont.load_default()
|
|
282
|
+
font_footer = ImageFont.load_default()
|
|
283
|
+
|
|
284
|
+
# 计算尺寸
|
|
285
|
+
dummy_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
|
286
|
+
title_w = dummy_draw.textlength(TITLE_TEXT, font=font_title)
|
|
287
|
+
title_h = 100
|
|
288
|
+
|
|
289
|
+
footer_text = FOOTER_TEXT_TEMPLATE.format(time=time.strftime('%Y-%m-%d %H:%M:%S'))
|
|
290
|
+
footer_w = dummy_draw.textlength(footer_text, font=font_footer)
|
|
291
|
+
footer_h = 40
|
|
292
|
+
|
|
293
|
+
content_w = combined_rows[0].width
|
|
294
|
+
total_w = max(content_w, int(title_w + 60), int(footer_w + 60))
|
|
295
|
+
|
|
296
|
+
list_h = sum(img.height for img in combined_rows) + (len(combined_rows)-1) * ROW_GAP
|
|
297
|
+
total_h = title_h + list_h + footer_h
|
|
298
|
+
|
|
299
|
+
# 绘制
|
|
300
|
+
summary_img = Image.new('RGB', (total_w, total_h), BG_COLOR)
|
|
301
|
+
draw = ImageDraw.Draw(summary_img)
|
|
302
|
+
|
|
303
|
+
# 标题
|
|
304
|
+
draw.text(((total_w - title_w) // 2, (title_h - 48) // 2), TITLE_TEXT, fill=TEXT_COLOR, font=font_title)
|
|
305
|
+
|
|
306
|
+
# 服务器行
|
|
307
|
+
curr_y = title_h
|
|
308
|
+
for img in combined_rows:
|
|
309
|
+
x_offset = (total_w - img.width) // 2
|
|
310
|
+
summary_img.paste(img, (x_offset, curr_y))
|
|
311
|
+
curr_y += img.height + ROW_GAP
|
|
312
|
+
|
|
313
|
+
# 页脚
|
|
314
|
+
draw.text((total_w - footer_w - 20, total_h - footer_h + 10), footer_text, fill=FOOTER_COLOR, font=font_footer)
|
|
315
|
+
|
|
316
|
+
return summary_img
|
|
317
|
+
|
|
318
|
+
def generate_mcmotd_image(config: Config) -> Optional[Image.Image]:
|
|
319
|
+
print(f"🚀 开始并行查询 {len(config.msc_server_list)} 个服务器...")
|
|
320
|
+
|
|
321
|
+
font_path = get_font_path(config)
|
|
322
|
+
|
|
323
|
+
# 并行查询
|
|
324
|
+
query_results = []
|
|
325
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
|
|
326
|
+
future_map = {executor.submit(query_one_server, i, server.address, config): i for i, server in enumerate(config.msc_server_list)}
|
|
327
|
+
for future in concurrent.futures.as_completed(future_map):
|
|
328
|
+
query_results.append(future.result())
|
|
329
|
+
|
|
330
|
+
query_results.sort(key=lambda x: x["index"])
|
|
331
|
+
|
|
332
|
+
# 1. 计算全局宽度以进行对齐
|
|
333
|
+
max_width = 0
|
|
334
|
+
for res in query_results:
|
|
335
|
+
idx = res["index"]
|
|
336
|
+
alias = config.msc_server_list[idx].alias
|
|
337
|
+
w = calculate_required_width(res["address"], res["status_obj"], font_path=font_path, alias=alias)
|
|
338
|
+
if w > max_width: max_width = w
|
|
339
|
+
|
|
340
|
+
max_width = max(max_width, 800) # 最小宽度
|
|
341
|
+
|
|
342
|
+
# 2. 生成单独的行
|
|
343
|
+
combined_rows = []
|
|
344
|
+
for res in query_results:
|
|
345
|
+
idx = res["index"]
|
|
346
|
+
alias = config.msc_server_list[idx].alias
|
|
347
|
+
icon, info = generate_server_card(res["address"], res["status_obj"], fixed_width=max_width, font_path=font_path, alias=alias)
|
|
348
|
+
|
|
349
|
+
# 合并图标 + 信息
|
|
350
|
+
row_width = icon.width + info.width
|
|
351
|
+
row_height = 128
|
|
352
|
+
|
|
353
|
+
# 检查玩家列表
|
|
354
|
+
player_img = None
|
|
355
|
+
if config.msc_show_player_list and hasattr(res["status_obj"].players, 'sample') and res["status_obj"].players.sample:
|
|
356
|
+
player_img = render_player_list(res["status_obj"].players.sample, row_width, font_path)
|
|
357
|
+
|
|
358
|
+
if player_img:
|
|
359
|
+
row_height += player_img.height
|
|
360
|
+
|
|
361
|
+
row_img = Image.new('RGB', (row_width, row_height), (30, 30, 30))
|
|
362
|
+
row_img.paste(icon, (0, 0))
|
|
363
|
+
row_img.paste(info, (icon.width, 0))
|
|
364
|
+
|
|
365
|
+
if player_img:
|
|
366
|
+
row_img.paste(player_img, (0, 128))
|
|
367
|
+
|
|
368
|
+
combined_rows.append(row_img)
|
|
369
|
+
|
|
370
|
+
# 3. 生成汇总图片
|
|
371
|
+
if combined_rows:
|
|
372
|
+
return create_summary_image(combined_rows, config)
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
def render_player_list(players, width, font_path):
|
|
376
|
+
if not players: return None
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
font = ImageFont.truetype(font_path, 20)
|
|
380
|
+
except:
|
|
381
|
+
font = ImageFont.load_default()
|
|
382
|
+
|
|
383
|
+
dummy = ImageDraw.Draw(Image.new("RGB", (1,1)))
|
|
384
|
+
|
|
385
|
+
# 展平名称并处理颜色
|
|
386
|
+
# 某些服务器在玩家列表中使用 '&' 表示颜色
|
|
387
|
+
names = []
|
|
388
|
+
for p in players:
|
|
389
|
+
raw_name = p.get("name", "Unknown")
|
|
390
|
+
# 将 & 替换为 § 以进行颜色解析,但仅当它看起来像颜色代码时
|
|
391
|
+
# 目前进行简单替换
|
|
392
|
+
names.append(raw_name.replace("&", "§"))
|
|
393
|
+
|
|
394
|
+
# 检测这是否可能是消息列表(自定义信息)或真实的玩家列表
|
|
395
|
+
# 标准:包含颜色代码、空格或非 ASCII 字符
|
|
396
|
+
is_message_list = False
|
|
397
|
+
for n in names:
|
|
398
|
+
if "§" in n or " " in n:
|
|
399
|
+
is_message_list = True
|
|
400
|
+
break
|
|
401
|
+
# 检查非 ascii (例如中文)
|
|
402
|
+
if any(ord(c) > 127 for c in n):
|
|
403
|
+
is_message_list = True
|
|
404
|
+
break
|
|
405
|
+
|
|
406
|
+
lines = []
|
|
407
|
+
current_line = []
|
|
408
|
+
current_width = 0
|
|
409
|
+
max_width = width - 40 # 填充
|
|
410
|
+
|
|
411
|
+
if is_message_list:
|
|
412
|
+
# 每行一个条目
|
|
413
|
+
for name in names:
|
|
414
|
+
lines.append([name])
|
|
415
|
+
else:
|
|
416
|
+
# 紧凑模式(逗号分隔)
|
|
417
|
+
for name in names:
|
|
418
|
+
# 去除代码以计算宽度
|
|
419
|
+
clean_name = name
|
|
420
|
+
for i in range(10): clean_name = clean_name.replace(f"§{i}", "")
|
|
421
|
+
for c in "abcdefklmnor": clean_name = clean_name.replace(f"§{c}", "")
|
|
422
|
+
|
|
423
|
+
name_w = dummy.textlength(clean_name + ", ", font=font)
|
|
424
|
+
|
|
425
|
+
if current_width + name_w > max_width and current_line:
|
|
426
|
+
lines.append(current_line)
|
|
427
|
+
current_line = [name]
|
|
428
|
+
current_width = name_w
|
|
429
|
+
else:
|
|
430
|
+
current_line.append(name)
|
|
431
|
+
current_width += name_w
|
|
432
|
+
|
|
433
|
+
if current_line: lines.append(current_line)
|
|
434
|
+
|
|
435
|
+
line_height = 24
|
|
436
|
+
h = len(lines) * line_height + 20
|
|
437
|
+
|
|
438
|
+
img = Image.new("RGB", (width, h), BG_COLOR)
|
|
439
|
+
draw = ImageDraw.Draw(img)
|
|
440
|
+
|
|
441
|
+
y = 10
|
|
442
|
+
default_color = (200, 200, 200)
|
|
443
|
+
|
|
444
|
+
for line in lines:
|
|
445
|
+
x = 20
|
|
446
|
+
for i, name in enumerate(line):
|
|
447
|
+
# 如果不是最后一个且不在消息模式下,则添加分隔符
|
|
448
|
+
separator = ", " if (not is_message_list and i < len(line) - 1) else ""
|
|
449
|
+
full_text = name + separator
|
|
450
|
+
|
|
451
|
+
# 解析并绘制段
|
|
452
|
+
segments = parse_inner_legacy(full_text, default_color)
|
|
453
|
+
for text, color in segments:
|
|
454
|
+
draw.text((x, y), text, fill=color, font=font)
|
|
455
|
+
x += dummy.textlength(text, font=font)
|
|
456
|
+
|
|
457
|
+
y += line_height
|
|
458
|
+
|
|
459
|
+
return img
|
|
460
|
+
|
|
461
|
+
def generate_single_server_image(address: str, config: Config) -> Optional[Image.Image]:
|
|
462
|
+
font_path = get_font_path(config)
|
|
463
|
+
|
|
464
|
+
# 查找别名
|
|
465
|
+
alias = None
|
|
466
|
+
for s in config.msc_server_list:
|
|
467
|
+
if s.address == address:
|
|
468
|
+
alias = s.alias
|
|
469
|
+
break
|
|
470
|
+
|
|
471
|
+
# 查询
|
|
472
|
+
res = query_one_server(0, address, config)
|
|
473
|
+
status = res["status_obj"]
|
|
474
|
+
|
|
475
|
+
# 生成卡片
|
|
476
|
+
w = calculate_required_width(address, status, font_path=font_path, alias=alias)
|
|
477
|
+
w = max(w, 800)
|
|
478
|
+
|
|
479
|
+
icon, info = generate_server_card(address, status, fixed_width=w, font_path=font_path, alias=alias)
|
|
480
|
+
|
|
481
|
+
# 合并图标 + 信息
|
|
482
|
+
row_width = icon.width + info.width
|
|
483
|
+
row_height = 128
|
|
484
|
+
row_img = Image.new('RGB', (row_width, row_height), BG_COLOR)
|
|
485
|
+
row_img.paste(icon, (0, 0))
|
|
486
|
+
row_img.paste(info, (icon.width, 0))
|
|
487
|
+
|
|
488
|
+
# 玩家列表
|
|
489
|
+
player_img = None
|
|
490
|
+
if hasattr(status.players, 'sample') and status.players.sample:
|
|
491
|
+
player_img = render_player_list(status.players.sample, row_width, font_path)
|
|
492
|
+
|
|
493
|
+
# 标题和页脚
|
|
494
|
+
try:
|
|
495
|
+
font_title = ImageFont.truetype(font_path, 48)
|
|
496
|
+
font_footer = ImageFont.truetype(font_path, 24)
|
|
497
|
+
except:
|
|
498
|
+
font_title = ImageFont.load_default()
|
|
499
|
+
font_footer = ImageFont.load_default()
|
|
500
|
+
|
|
501
|
+
dummy = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
|
502
|
+
|
|
503
|
+
title_w = dummy.textlength(TITLE_TEXT, font=font_title)
|
|
504
|
+
title_h = 100
|
|
505
|
+
|
|
506
|
+
footer_text = FOOTER_TEXT_TEMPLATE.format(time=time.strftime('%Y-%m-%d %H:%M:%S'))
|
|
507
|
+
footer_w = dummy.textlength(footer_text, font=font_footer)
|
|
508
|
+
footer_h = 40
|
|
509
|
+
|
|
510
|
+
# 计算总高度
|
|
511
|
+
total_h = title_h + row_height + (player_img.height if player_img else 0) + footer_h + ROW_GAP
|
|
512
|
+
total_w = max(row_width, int(title_w + 60), int(footer_w + 40))
|
|
513
|
+
|
|
514
|
+
# 创建最终图像
|
|
515
|
+
final_img = Image.new("RGB", (total_w, total_h), BG_COLOR)
|
|
516
|
+
draw = ImageDraw.Draw(final_img)
|
|
517
|
+
|
|
518
|
+
# 绘制标题
|
|
519
|
+
draw.text(((total_w - title_w) // 2, (title_h - 48) // 2), TITLE_TEXT, fill=TEXT_COLOR, font=font_title)
|
|
520
|
+
|
|
521
|
+
# 粘贴行
|
|
522
|
+
|
|
523
|
+
x_offset = (total_w - row_width) // 2
|
|
524
|
+
final_img.paste(row_img, (x_offset, title_h))
|
|
525
|
+
|
|
526
|
+
curr_y = title_h + row_height
|
|
527
|
+
|
|
528
|
+
# Paste Player List
|
|
529
|
+
if player_img:
|
|
530
|
+
final_img.paste(player_img, (x_offset, curr_y))
|
|
531
|
+
curr_y += player_img.height
|
|
532
|
+
|
|
533
|
+
# Draw Footer
|
|
534
|
+
draw.text((total_w - footer_w - 20, total_h - footer_h + 10), footer_text, fill=FOOTER_COLOR, font=font_footer)
|
|
535
|
+
|
|
536
|
+
return final_img
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
class Server(BaseModel):
|
|
5
|
+
address: str
|
|
6
|
+
alias: Optional[str] = None
|
|
7
|
+
|
|
8
|
+
class Config(BaseModel):
|
|
9
|
+
msc_server_list: List[Server] = []
|
|
10
|
+
msc_latency_interval: float = 0.1
|
|
11
|
+
msc_latency_warmup: int = 5
|
|
12
|
+
msc_latency_count: int = 5
|
|
13
|
+
msc_latency_trim: bool = True
|
|
14
|
+
msc_show_timing_details: bool = False
|
|
15
|
+
msc_font_path: str = "minecraft.ttf"
|
|
16
|
+
msc_command_triggers: List[str] = ["查服"]
|
|
17
|
+
msc_show_player_list: bool = False
|
nonebot_plugin_mcserver_status_check-0.1.2/nonebot_plugin_mcserver_status_check/mc_renderer.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minecraft 服务器状态渲染器
|
|
3
|
+
==========================
|
|
4
|
+
|
|
5
|
+
此模块负责将 Minecraft 服务器状态数据(JSON)渲染为图像。
|
|
6
|
+
它处理颜色代码解析、MOTD 格式化、图标处理和布局计算。
|
|
7
|
+
|
|
8
|
+
主要功能:
|
|
9
|
+
- 解析 Minecraft 颜色代码 (§a, §b 等) 和 JSON 颜色格式。
|
|
10
|
+
- 将 MOTD 渲染为带有正确颜色的文本。
|
|
11
|
+
- 生成包含服务器图标、名称、MOTD、玩家数量、版本和延迟的卡片图像。
|
|
12
|
+
- 支持自动计算宽度以对齐多个服务器卡片。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
from io import BytesIO
|
|
17
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
18
|
+
|
|
19
|
+
# --- 颜色常量 ---
|
|
20
|
+
BG_COLOR = (30, 30, 30)
|
|
21
|
+
MC_COLORS = {
|
|
22
|
+
'black': (0, 0, 0), 'dark_blue': (0, 0, 170),
|
|
23
|
+
'dark_green': (0, 170, 0), 'dark_aqua': (0, 170, 170),
|
|
24
|
+
'dark_red': (170, 0, 0), 'dark_purple': (170, 0, 170),
|
|
25
|
+
'gold': (255, 170, 0), 'gray': (170, 170, 170),
|
|
26
|
+
'dark_gray': (85, 85, 85), 'blue': (85, 85, 255),
|
|
27
|
+
'green': (85, 255, 85), 'aqua': (85, 255, 255),
|
|
28
|
+
'red': (255, 85, 85), 'light_purple': (255, 85, 255),
|
|
29
|
+
'yellow': (255, 255, 85), 'white': (255, 255, 255),
|
|
30
|
+
'0': (0, 0, 0), '1': (0, 0, 170), '2': (0, 170, 0), '3': (0, 170, 170),
|
|
31
|
+
'4': (170, 0, 0), '5': (170, 0, 170), '6': (255, 170, 0), '7': (170, 170, 170),
|
|
32
|
+
'8': (85, 85, 85), '9': (85, 85, 255), 'a': (85, 255, 85), 'b': (85, 255, 255),
|
|
33
|
+
'c': (255, 85, 85), 'd': (255, 85, 255), 'e': (255, 255, 85), 'f': (255, 255, 255),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- 辅助函数 ---
|
|
38
|
+
|
|
39
|
+
def resolve_color(color_obj):
|
|
40
|
+
"""将颜色名称或十六进制代码解析为 RGB 元组。"""
|
|
41
|
+
if not color_obj: return (170, 170, 170)
|
|
42
|
+
if isinstance(color_obj, str):
|
|
43
|
+
c = color_obj.lower()
|
|
44
|
+
if c in MC_COLORS: return MC_COLORS[c]
|
|
45
|
+
if c.startswith('#') and len(c) == 7:
|
|
46
|
+
try: return tuple(int(c[i:i+2], 16) for i in (1, 3, 5))
|
|
47
|
+
except: pass
|
|
48
|
+
return (170, 170, 170)
|
|
49
|
+
|
|
50
|
+
def parse_inner_legacy(text, start_color):
|
|
51
|
+
"""解析字符串中的旧版 Minecraft 颜色代码 (§a)。"""
|
|
52
|
+
if not text: return []
|
|
53
|
+
if '§' not in text: return [(text, start_color)]
|
|
54
|
+
result = []
|
|
55
|
+
buffer = ""
|
|
56
|
+
current_color = start_color
|
|
57
|
+
i = 0
|
|
58
|
+
while i < len(text):
|
|
59
|
+
if text[i] == '§' and i + 1 < len(text):
|
|
60
|
+
if buffer:
|
|
61
|
+
result.append((buffer, current_color))
|
|
62
|
+
buffer = ""
|
|
63
|
+
code = text[i+1].lower()
|
|
64
|
+
if code in MC_COLORS: current_color = MC_COLORS[code]
|
|
65
|
+
elif code == 'r': current_color = (170, 170, 170)
|
|
66
|
+
i += 2
|
|
67
|
+
else:
|
|
68
|
+
buffer += text[i]
|
|
69
|
+
i += 1
|
|
70
|
+
if buffer: result.append((buffer, current_color))
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
def strip_mc_codes(text):
|
|
74
|
+
"""从文本中移除所有 Minecraft 颜色代码 (§a)。"""
|
|
75
|
+
if not text: return ""
|
|
76
|
+
if '§' not in text: return text
|
|
77
|
+
result = ""
|
|
78
|
+
i = 0
|
|
79
|
+
while i < len(text):
|
|
80
|
+
if text[i] == '§' and i + 1 < len(text):
|
|
81
|
+
i += 2
|
|
82
|
+
else:
|
|
83
|
+
result += text[i]
|
|
84
|
+
i += 1
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
def flatten_json_motd(data, inherited_color='gray'):
|
|
88
|
+
"""将分层的 JSON MOTD 结构展平为 (文本, 颜色) 段列表。"""
|
|
89
|
+
result = []
|
|
90
|
+
text = ""
|
|
91
|
+
if isinstance(data, dict):
|
|
92
|
+
text = data.get('text', "")
|
|
93
|
+
color_name = data.get('color', inherited_color)
|
|
94
|
+
elif isinstance(data, str):
|
|
95
|
+
text = data
|
|
96
|
+
color_name = inherited_color
|
|
97
|
+
else:
|
|
98
|
+
return []
|
|
99
|
+
base_rgb = resolve_color(color_name)
|
|
100
|
+
if text: result.extend(parse_inner_legacy(text, base_rgb))
|
|
101
|
+
if isinstance(data, dict) and 'extra' in data:
|
|
102
|
+
for item in data['extra']:
|
|
103
|
+
result.extend(flatten_json_motd(item, color_name))
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
def draw_text_with_shadow(draw, pos, text, color, font):
|
|
107
|
+
"""绘制带有阴影效果的文本。"""
|
|
108
|
+
x, y = pos
|
|
109
|
+
r, g, b = color
|
|
110
|
+
shadow_color = (r // 5, g // 5, b // 5)
|
|
111
|
+
draw.text((x + 2, y + 2), text, fill=shadow_color, font=font)
|
|
112
|
+
draw.text((x, y), text, fill=color, font=font)
|
|
113
|
+
return draw.textlength(text, font=font)
|
|
114
|
+
|
|
115
|
+
def measure_segments_width(segments, draw, font):
|
|
116
|
+
"""测量一系列文本段的总宽度。"""
|
|
117
|
+
return sum(draw.textlength(text, font=font) for text, _ in segments)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def extract_render_data(server_addr, status, alias=None):
|
|
121
|
+
"""
|
|
122
|
+
从状态对象中提取并预处理渲染所需的数据。
|
|
123
|
+
|
|
124
|
+
参数:
|
|
125
|
+
server_addr (str): 服务器地址字符串。
|
|
126
|
+
status (MixedStatus): 包含原始 JSON 响应和延迟数据的状态对象。
|
|
127
|
+
alias (str, optional): 服务器别名。
|
|
128
|
+
|
|
129
|
+
返回:
|
|
130
|
+
dict: 包含结构化渲染数据的字典,包括左侧文本、右侧统计信息和图标数据。
|
|
131
|
+
"""
|
|
132
|
+
raw = status.raw
|
|
133
|
+
|
|
134
|
+
# --- 提取右侧统计数据 (在线人数, 版本, 延迟) ---
|
|
135
|
+
players = raw.get("players", {})
|
|
136
|
+
online, max_p = players.get('online', 0), players.get('max', 0)
|
|
137
|
+
txt_players = f"{online}/{max_p}"
|
|
138
|
+
# 在线且有延迟数据时显示绿色,否则显示灰色
|
|
139
|
+
color_players = (85, 255, 85) if (online > 0 and status.latency >= 0) else (170, 170, 170)
|
|
140
|
+
|
|
141
|
+
txt_version = "Unknown"
|
|
142
|
+
if "version" in raw:
|
|
143
|
+
v = raw["version"]
|
|
144
|
+
if isinstance(v, dict): txt_version = v.get("name", "Unknown")
|
|
145
|
+
elif isinstance(v, str): txt_version = v
|
|
146
|
+
elif hasattr(status, 'version') and status.version:
|
|
147
|
+
txt_version = status.version.name
|
|
148
|
+
|
|
149
|
+
# 移除版本字符串中的颜色代码以保持整洁
|
|
150
|
+
txt_version = strip_mc_codes(txt_version)
|
|
151
|
+
|
|
152
|
+
lat = status.latency if hasattr(status, 'latency') else -1
|
|
153
|
+
fail_count = getattr(status, 'fail_count', 0)
|
|
154
|
+
|
|
155
|
+
txt_ping = f"{int(lat)}ms"
|
|
156
|
+
if fail_count > 0:
|
|
157
|
+
txt_ping = f"Loss:{fail_count} {txt_ping}"
|
|
158
|
+
|
|
159
|
+
if lat < 0: color_ping = (170, 170, 170) # 离线/未知
|
|
160
|
+
elif lat < 150: color_ping = (85, 255, 85) # 良好 (绿色)
|
|
161
|
+
elif lat < 300: color_ping = (255, 170, 0) # 一般 (金色)
|
|
162
|
+
else: color_ping = (170, 0, 0) # 较差 (红色)
|
|
163
|
+
|
|
164
|
+
# --- 提取左侧文本数据 (服务器标题, MOTD) ---
|
|
165
|
+
desc_data = raw.get("description", "")
|
|
166
|
+
motd_parts = flatten_json_motd(desc_data)
|
|
167
|
+
motd_lines = [[]]
|
|
168
|
+
for text, color in motd_parts:
|
|
169
|
+
if not text: continue
|
|
170
|
+
segs = text.split('\n')
|
|
171
|
+
for i, seg in enumerate(segs):
|
|
172
|
+
if i > 0: motd_lines.append([])
|
|
173
|
+
if seg: motd_lines[-1].append((seg, color))
|
|
174
|
+
|
|
175
|
+
# 仅保留前两行 MOTD
|
|
176
|
+
line1 = motd_lines[0] if len(motd_lines) > 0 else []
|
|
177
|
+
line2 = motd_lines[1] if len(motd_lines) > 1 else []
|
|
178
|
+
|
|
179
|
+
title_text = server_addr
|
|
180
|
+
if alias:
|
|
181
|
+
title_text = f"{alias} ({server_addr})"
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
"right": {
|
|
185
|
+
"p_txt": txt_players, "p_col": color_players,
|
|
186
|
+
"v_txt": txt_version, "v_col": (170, 170, 170),
|
|
187
|
+
"ping_txt": txt_ping, "ping_col": color_ping
|
|
188
|
+
},
|
|
189
|
+
"left": {
|
|
190
|
+
"title": title_text,
|
|
191
|
+
"line1": line1,
|
|
192
|
+
"line2": line2
|
|
193
|
+
},
|
|
194
|
+
"favicon": raw.get("favicon")
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
def calculate_required_width(server_addr, status, font_path="minecraft.ttf", alias=None):
|
|
198
|
+
"""
|
|
199
|
+
计算渲染该服务器卡片所需的最小宽度。
|
|
200
|
+
此函数用于在批量渲染前确定全局统一的宽度,以确保对齐。
|
|
201
|
+
"""
|
|
202
|
+
data = extract_render_data(server_addr, status, alias)
|
|
203
|
+
|
|
204
|
+
# 创建临时画布用于测量文本宽度
|
|
205
|
+
temp_draw = ImageDraw.Draw(Image.new('RGB', (1, 1)))
|
|
206
|
+
try:
|
|
207
|
+
font_title = ImageFont.truetype(font_path, 30)
|
|
208
|
+
font_desc = ImageFont.truetype(font_path, 26)
|
|
209
|
+
font_info = ImageFont.truetype(font_path, 26)
|
|
210
|
+
except:
|
|
211
|
+
font_title = ImageFont.load_default()
|
|
212
|
+
font_desc = ImageFont.load_default()
|
|
213
|
+
font_info = ImageFont.load_default()
|
|
214
|
+
|
|
215
|
+
# 计算右侧统计信息区域宽度 (取最大值)
|
|
216
|
+
r = data["right"]
|
|
217
|
+
w_p = temp_draw.textlength(r["p_txt"], font=font_info)
|
|
218
|
+
w_v = temp_draw.textlength(r["v_txt"], font=font_info)
|
|
219
|
+
w_ping = temp_draw.textlength(r["ping_txt"], font=font_info)
|
|
220
|
+
max_stats_w = max(w_p, w_v, w_ping) + 10
|
|
221
|
+
|
|
222
|
+
# 计算左侧文本区域宽度 (标题和 MOTD)
|
|
223
|
+
l = data["left"]
|
|
224
|
+
w_title = temp_draw.textlength(l["title"], font=font_title)
|
|
225
|
+
w_m1 = measure_segments_width(l["line1"], temp_draw, font_desc)
|
|
226
|
+
w_m2 = measure_segments_width(l["line2"], temp_draw, font_desc)
|
|
227
|
+
max_text_w = max(w_title, w_m1, w_m2)
|
|
228
|
+
|
|
229
|
+
GAP = 40 # 左右内容区域之间的间隙
|
|
230
|
+
PADDING = 40 # 左右两侧的总边距
|
|
231
|
+
|
|
232
|
+
return int(max_text_w + GAP + max_stats_w + PADDING)
|
|
233
|
+
|
|
234
|
+
def generate_server_card(server_addr, status, fixed_width=None, font_path="minecraft.ttf", alias=None):
|
|
235
|
+
"""
|
|
236
|
+
生成单个服务器的状态卡片图像。
|
|
237
|
+
|
|
238
|
+
参数:
|
|
239
|
+
server_addr (str): 服务器地址。
|
|
240
|
+
status (MixedStatus): 状态对象。
|
|
241
|
+
fixed_width (int, optional): 指定图像宽度。若为 None,则自动计算最小宽度。
|
|
242
|
+
font_path (str, optional): 字体文件路径。
|
|
243
|
+
alias (str, optional): 服务器别名。
|
|
244
|
+
|
|
245
|
+
返回:
|
|
246
|
+
tuple: (icon_image, info_image) - 返回包含图标和信息卡片的元组。
|
|
247
|
+
"""
|
|
248
|
+
# 1. 基础配置与数据提取
|
|
249
|
+
FIXED_HEIGHT = 128
|
|
250
|
+
data = extract_render_data(server_addr, status, alias)
|
|
251
|
+
|
|
252
|
+
# 2. 生成服务器图标
|
|
253
|
+
ICON_SIZE = 128
|
|
254
|
+
icon_img = Image.new('RGB', (ICON_SIZE, ICON_SIZE), (50, 50, 50))
|
|
255
|
+
if data["favicon"] and "," in data["favicon"]:
|
|
256
|
+
try:
|
|
257
|
+
b64 = data["favicon"].split(",", 1)[1]
|
|
258
|
+
real_icon = Image.open(BytesIO(base64.b64decode(b64))).resize((ICON_SIZE, ICON_SIZE), Image.Resampling.NEAREST)
|
|
259
|
+
if real_icon.mode != 'RGB': real_icon = real_icon.convert('RGB')
|
|
260
|
+
icon_img.paste(real_icon, (0, 0))
|
|
261
|
+
except: pass
|
|
262
|
+
|
|
263
|
+
# 3. 确定图像宽度
|
|
264
|
+
# 若未指定固定宽度,则自动计算所需的最小宽度,并确保不小于 800px
|
|
265
|
+
if fixed_width:
|
|
266
|
+
TOTAL_WIDTH = fixed_width
|
|
267
|
+
else:
|
|
268
|
+
TOTAL_WIDTH = calculate_required_width(server_addr, status, font_path, alias)
|
|
269
|
+
TOTAL_WIDTH = max(TOTAL_WIDTH, 800)
|
|
270
|
+
|
|
271
|
+
# 4. 创建信息卡片画布
|
|
272
|
+
info_img = Image.new('RGB', (TOTAL_WIDTH, FIXED_HEIGHT), BG_COLOR)
|
|
273
|
+
draw = ImageDraw.Draw(info_img)
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
font_title = ImageFont.truetype(font_path, 30)
|
|
277
|
+
font_desc = ImageFont.truetype(font_path, 26)
|
|
278
|
+
font_info = ImageFont.truetype(font_path, 26)
|
|
279
|
+
except:
|
|
280
|
+
font_title = ImageFont.load_default()
|
|
281
|
+
font_desc = ImageFont.load_default()
|
|
282
|
+
font_info = ImageFont.load_default()
|
|
283
|
+
|
|
284
|
+
LINE_HEIGHT = 36
|
|
285
|
+
START_Y = (FIXED_HEIGHT - (LINE_HEIGHT * 3)) // 2
|
|
286
|
+
Y1, Y2, Y3 = START_Y - 2, START_Y + LINE_HEIGHT, START_Y + LINE_HEIGHT * 2
|
|
287
|
+
|
|
288
|
+
PADDING_LEFT = 20
|
|
289
|
+
PADDING_RIGHT = 20
|
|
290
|
+
|
|
291
|
+
# 5. 绘制右侧统计信息 (始终右对齐)
|
|
292
|
+
ALIGN_X = TOTAL_WIDTH - PADDING_RIGHT
|
|
293
|
+
r = data["right"]
|
|
294
|
+
|
|
295
|
+
w_p = draw.textlength(r["p_txt"], font=font_info)
|
|
296
|
+
draw_text_with_shadow(draw, (ALIGN_X - w_p, Y1), r["p_txt"], r["p_col"], font_info)
|
|
297
|
+
|
|
298
|
+
w_v = draw.textlength(r["v_txt"], font=font_info)
|
|
299
|
+
draw_text_with_shadow(draw, (ALIGN_X - w_v, Y2), r["v_txt"], r["v_col"], font_info)
|
|
300
|
+
|
|
301
|
+
w_ping = draw.textlength(r["ping_txt"], font=font_info)
|
|
302
|
+
draw_text_with_shadow(draw, (ALIGN_X - w_ping, Y3), r["ping_txt"], r["ping_col"], font_info)
|
|
303
|
+
|
|
304
|
+
# 6. 绘制左侧文本信息 (标题和 MOTD)
|
|
305
|
+
l = data["left"]
|
|
306
|
+
TEXT_X = PADDING_LEFT
|
|
307
|
+
|
|
308
|
+
draw_text_with_shadow(draw, (TEXT_X, Y1), l["title"], (255, 255, 255), font_title)
|
|
309
|
+
|
|
310
|
+
curr_x = TEXT_X
|
|
311
|
+
for t, c in l["line1"]:
|
|
312
|
+
curr_x += draw_text_with_shadow(draw, (curr_x, Y2), t, c, font_desc)
|
|
313
|
+
|
|
314
|
+
curr_x = TEXT_X
|
|
315
|
+
for t, c in l["line2"]:
|
|
316
|
+
curr_x += draw_text_with_shadow(draw, (curr_x, Y3), t, c, font_desc)
|
|
317
|
+
|
|
318
|
+
return icon_img, info_img
|
|
Binary file
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "nonebot-plugin-mcserver-status-check"
|
|
3
|
+
version = "0.1.2"
|
|
4
|
+
description = "A vanilla-style, beautiful and efficient Minecraft server status query plugin"
|
|
5
|
+
authors = ["leiuary <leiuary@qq.com>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
homepage = "https://github.com/leiuary/nonebot-plugin-mcserver-status-check"
|
|
9
|
+
repository = "https://github.com/leiuary/nonebot-plugin-mcserver-status-check"
|
|
10
|
+
|
|
11
|
+
[tool.poetry.dependencies]
|
|
12
|
+
python = ">=3.9,<4.0"
|
|
13
|
+
nonebot2 = "^2.0.0"
|
|
14
|
+
nonebot-adapter-onebot = "^2.0.0"
|
|
15
|
+
Pillow = ">=9.1.0"
|
|
16
|
+
mcstatus = ">=11.0.0"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["poetry-core>=1.0.0"]
|
|
20
|
+
build-backend = "poetry.core.masonry.api"
|