├── .github
├── FUNDING.yml
└── workflows
│ └── pypi-publish.yml
├── .gitignore
├── LICENSE
├── README.md
├── nonebot_plugin_picmcstat
├── __init__.py
├── __main__.py
├── config.py
├── const.py
├── draw.py
├── res.py
├── res
│ ├── default.png
│ ├── dirt.png
│ └── grass_side_carried.png
└── util.py
└── pyproject.toml
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | custom: ["https://afdian.net/@lgc2333/"]
4 |
--------------------------------------------------------------------------------
/.github/workflows/pypi-publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish Python 🐍 distributions 📦 to PyPI
2 |
3 | on:
4 | release:
5 | types: [published]
6 | workflow_dispatch:
7 |
8 | jobs:
9 | build-n-publish:
10 | name: Use PDM to Build and publish Python 🐍 distributions 📦 to PyPI
11 | runs-on: ubuntu-latest
12 |
13 | permissions:
14 | # IMPORTANT: this permission is mandatory for trusted publishing
15 | id-token: write
16 |
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@master
20 | with:
21 | submodules: true
22 |
23 | - name: Setup PDM
24 | uses: pdm-project/setup-pdm@v3
25 |
26 | - name: Build and Publish distribution 📦 to PyPI
27 | run: pdm publish
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.toptal.com/developers/gitignore/api/python
2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python
3 |
4 | ### Python ###
5 | # Byte-compiled / optimized / DLL files
6 | __pycache__/
7 | *.py[cod]
8 | *$py.class
9 |
10 | # C extensions
11 | *.so
12 |
13 | # Distribution / packaging
14 | .Python
15 | build/
16 | develop-eggs/
17 | dist/
18 | downloads/
19 | eggs/
20 | .eggs/
21 | lib/
22 | lib64/
23 | parts/
24 | sdist/
25 | var/
26 | wheels/
27 | share/python-wheels/
28 | *.egg-info/
29 | .installed.cfg
30 | *.egg
31 | MANIFEST
32 |
33 | # PyInstaller
34 | # Usually these files are written by a python script from a template
35 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
36 | *.manifest
37 | *.spec
38 |
39 | # Installer logs
40 | pip-log.txt
41 | pip-delete-this-directory.txt
42 |
43 | # Unit test / coverage reports
44 | htmlcov/
45 | .tox/
46 | .nox/
47 | .coverage
48 | .coverage.*
49 | .cache
50 | nosetests.xml
51 | coverage.xml
52 | *.cover
53 | *.py,cover
54 | .hypothesis/
55 | .pytest_cache/
56 | cover/
57 |
58 | # Translations
59 | *.mo
60 | *.pot
61 |
62 | # Django stuff:
63 | *.log
64 | local_settings.py
65 | db.sqlite3
66 | db.sqlite3-journal
67 |
68 | # Flask stuff:
69 | instance/
70 | .webassets-cache
71 |
72 | # Scrapy stuff:
73 | .scrapy
74 |
75 | # Sphinx documentation
76 | docs/_build/
77 |
78 | # PyBuilder
79 | .pybuilder/
80 | target/
81 |
82 | # Jupyter Notebook
83 | .ipynb_checkpoints
84 |
85 | # IPython
86 | profile_default/
87 | ipython_config.py
88 |
89 | # pyenv
90 | # For a library or package, you might want to ignore these files since the code is
91 | # intended to run in multiple environments; otherwise, check them in:
92 | # .python-version
93 |
94 | # pipenv
95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
98 | # install all needed dependencies.
99 | #Pipfile.lock
100 |
101 | # poetry
102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
103 | # This is especially recommended for binary packages to ensure reproducibility, and is more
104 | # commonly ignored for libraries.
105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
106 | poetry.lock
107 |
108 | # pdm
109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
110 | # pdm.lock
111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
112 | # in version control.
113 | # https://pdm.fming.dev/#use-with-ide
114 | .pdm-python
115 |
116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
117 | __pypackages__/
118 |
119 | # Celery stuff
120 | celerybeat-schedule
121 | celerybeat.pid
122 |
123 | # SageMath parsed files
124 | *.sage.py
125 |
126 | # Environments
127 | .env
128 | .venv
129 | env/
130 | venv/
131 | ENV/
132 | env.bak/
133 | venv.bak/
134 |
135 | # Spyder project settings
136 | .spyderproject
137 | .spyproject
138 |
139 | # Rope project settings
140 | .ropeproject
141 |
142 | # mkdocs documentation
143 | /site
144 |
145 | # mypy
146 | .mypy_cache/
147 | .dmypy.json
148 | dmypy.json
149 |
150 | # Pyre type checker
151 | .pyre/
152 |
153 | # pytype static type analyzer
154 | .pytype/
155 |
156 | # Cython debug symbols
157 | cython_debug/
158 |
159 | # PyCharm
160 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
161 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
162 | # and can be added to the global gitignore or merged into this file. For a more nuclear
163 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
164 | #.idea/
165 |
166 | ### Python Patch ###
167 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
168 | # poetry.toml
169 |
170 | # End of https://www.toptal.com/developers/gitignore/api/python
171 |
172 | testnb2/
173 | .idea/
174 | pdm.lock
175 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 LgCookie
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.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
50 |
51 | ## 📖 介绍
52 |
53 | 插件实际上是可以展示 **玩家列表**、**Mod 端信息 以及 Mod 列表(还未测试)** 的,这里没有找到合适的例子所以没在效果图里展示出来,如果遇到问题可以发 issue
54 |
55 | 插件包体内并没有自带图片内 Unifont 字体,需要的话请参考 [这里](#字体) 安装字体
56 |
57 |
58 | 效果图
59 |
60 | 
61 | 
62 |
63 |
64 |
65 | ## 💿 安装
66 |
67 | ### 插件
68 |
69 | 以下提到的方法 任选**其一** 即可
70 |
71 |
72 | [推荐] 使用 nb-cli 安装
73 | 在 nonebot2 项目的根目录下打开命令行, 输入以下指令即可安装
74 |
75 | ```bash
76 | nb plugin install nonebot-plugin-picmcstat
77 | ```
78 |
79 |
80 |
81 |
82 | 使用包管理器安装
83 | 在 nonebot2 项目的插件目录下, 打开命令行, 根据你使用的包管理器, 输入相应的安装命令
84 |
85 |
86 | pip
87 |
88 | ```bash
89 | pip install nonebot-plugin-picmcstat
90 | ```
91 |
92 |
93 |
94 | pdm
95 |
96 | ```bash
97 | pdm add nonebot-plugin-picmcstat
98 | ```
99 |
100 |
101 |
102 | poetry
103 |
104 | ```bash
105 | poetry add nonebot-plugin-picmcstat
106 | ```
107 |
108 |
109 |
110 | conda
111 |
112 | ```bash
113 | conda install nonebot-plugin-picmcstat
114 | ```
115 |
116 |
117 |
118 | 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot]` 部分的 `plugins` 项里追加写入
119 |
120 | ```toml
121 | [tool.nonebot]
122 | plugins = [
123 | # ...
124 | "nonebot_plugin_picmcstat"
125 | ]
126 | ```
127 |
128 |
129 |
130 | ### 字体
131 |
132 | 字体文件请自行去自行去 [这里](http://ftp.gnu.org/gnu/unifont/unifont-15.0.01/unifont-15.0.01.ttf) 下载
133 | 如需将英文部分变为游戏内字体,请另外下载安装 [这个](https://resources.download.minecraft.net/3d/3d009535ec7860c29603cd66cdb4db5c8b4aefd2) 字体(请自行修改文件扩展名为 `.ttf`)
134 |
135 | 将字体文件直接安装在系统中即可
136 | 如果不行,请尝试右键字体文件点击 `为所有用户安装`
137 | 如果还是不行,请尝试修改插件字体配置
138 |
139 | ## ⚙️ 配置
140 |
141 | ### `MCSTAT_FONT` - 使用的字体名称 / 路径
142 |
143 | 默认:`["Minecraft Seven", "unifont"]`
144 |
145 | 请按需自行更改
146 |
147 | ### `MCSTAT_SHOW_ADDR` - 是否在生成的图片中显示服务器地址
148 |
149 | 默认:`False`
150 |
151 | ### `MCSTAT_SHOW_DELAY` - 是否显示测试延迟
152 |
153 | 默认:`True`
154 |
155 | ### `MCSTAT_SHOW_MODS` - 是否在生成的图片中显示 Mod 列表
156 |
157 | 默认:`False`
158 |
159 | 由于某些整合包服务器的 Mod 数量过多,导致图片生成时间过长,且容易炸内存,所以默认不显示
160 |
161 | ### `MCSTAT_REPLY_TARGET` - 是否回复指令发送者
162 |
163 | 默认:`True`
164 |
165 | ### `MCSTAT_SHORTCUTS` - 快捷指令列表
166 |
167 | 这个配置项能够帮助你简化一些查询指令
168 |
169 | 此配置项的类型是一个列表,里面的元素需要为一个特定结构的字典:
170 |
171 | - `regex` - 用于匹配指令的正则,例如 `^查服$`
172 | (注意,nb2 以 JSON 格式解析配置项,所以当你要在正则表达式里表示`\`时,你需要将其转义为`\\`)
173 | - `host` - 要查询的服务器地址,格式为 `[:端口]`,
174 | 例如 `hypixel.net` 或 `example.com:1919`
175 | - `type` - 要查询服务器的类型,`je` 表示 Java 版服,`be` 表示基岩版服
176 | - `whitelist` - (仅支持 OneBot V11 适配器)群聊白名单,只有里面列出的群号可以查询,可以不填来对所有群开放查询
177 |
178 | 最终的配置项看起来是这样子的,当你发送 `查服` 时,机器人会把 EaseCation 服务器的状态发送出来
179 |
180 | ```env
181 | MCSTAT_SHORTCUTS='
182 | [
183 | {"regex": "^查服$", "host": "asia.easecation.net", "type": "be"}
184 | ]
185 | '
186 | ```
187 |
188 | ### `MCSTAT_RESOLVE_DNS` - 是否由插件解析 DNS 记录
189 |
190 | 默认:`True`
191 |
192 | 是否由插件解析一遍 DNS 记录后再进行查询,
193 | 如果你的服务器在运行 Clash 等拦截了 DNS 解析的软件,且查询部分地址时遇到了问题,请尝试关闭此配置项
194 | 此配置项不影响 Java 服务器的 SRV 记录解析
195 |
196 | ### `MCSTAT_QUERY_TWICE` - 是否查询两遍服务器状态
197 |
198 | 默认:`True`
199 |
200 | 由于第一次测得的延迟一般不准,所以做了这个配置,
201 | 开启后每次查询时,会丢掉第一次的结果再查询一次,且使用第二次查询到的结果
202 |
203 | ### `MCSTAT_JAVA_PROTOCOL_VERSION` - Motd Java 服务器时向服务器发送的客户端协议版本
204 |
205 | 默认:`767`
206 |
207 | ## 🎉 使用
208 |
209 | 发送 `motd` 指令 查看使用指南
210 |
211 | 
212 |
213 | ## 📞 联系
214 |
215 | QQ:3076823485
216 | Telegram:[@lgc2333](https://t.me/lgc2333)
217 | 吹水群:[1105946125](https://jq.qq.com/?_wv=1027&k=Z3n1MpEp)
218 | 邮箱:
219 |
220 | ## 💡 鸣谢
221 |
222 | ### [pil-utils](https://github.com/MeetWq/pil-utils)
223 |
224 | 超好用的 Pillow 辅助库,wq 佬是叠!快去用 awa
225 |
226 | ## 💰 赞助
227 |
228 | **[赞助我](https://blog.lgc2333.top/donate)**
229 |
230 | 感谢大家的赞助!你们的赞助将是我继续创作的动力!
231 |
232 | ## 📝 更新日志
233 |
234 | ### 0.7.1
235 |
236 | - 修复文字下对齐的 Bug
237 |
238 | ### 0.7.0
239 |
240 | - 适配 pil-utils 0.2
241 | - 更改配置项 `MCSTAT_FONT` 类型为 `List[str]`(`str` 仍然受支持)
242 |
243 | ### 0.6.3
244 |
245 | - fix [#22](https://github.com/lgc-NB2Dev/nonebot-plugin-picmcstat/issues/22)
246 |
247 | ### 0.6.2
248 |
249 | - 允许自定义向 Java 服务器发送的客户端协议版本,且提高默认协议版本以解决部分服务器 Motd 渐变显示异常的问题
250 |
251 | ### 0.6.1
252 |
253 | - fix [#21](https://github.com/lgc-NB2Dev/nonebot-plugin-picmcstat/issues/21)
254 |
255 | ### 0.6.0
256 |
257 | - 适配 Pydantic V1 & V2
258 |
259 | ### 0.5.1
260 |
261 | - 修复 玩家 / Mod 列表 中出现的一些 Bug ~~果然又出 Bug 了~~
262 | - 添加配置项 `MCSTAT_SHOW_DELAY`、`MCSTAT_QUERY_TWICE`
263 |
264 | ### 0.5.0
265 |
266 | - 换用 Alconna 支持多平台
267 | - 快捷指令支持多平台(`whitelist` 依然仅支持 OneBot V11)
268 | - 添加配置项 `MCSTAT_RESOLVE_DNS`
269 | - 部分代码重构 ~~Bug 海与屎山代码又增加了~~
270 |
271 | ### 0.4.0
272 |
273 | - 修复修复无法解析中文域名 IP 的错误([#13](https://github.com/lgc-NB2Dev/nonebot-plugin-picmcstat/issues/13))
274 | - 使用 SAA 支持多适配器(shortcut 依然仅支持 OB V11)
275 | - 添加配置项 `MCSTAT_REPLY_TARGET`
276 |
277 | ### 0.3.5
278 |
279 | - 修复上个版本的小 Bug
280 |
281 | ### 0.3.4
282 |
283 | - 修复无法正常绘制 Mod 列表的情况
284 | - 增加显示 Mod 列表的配置项 (`MCSTAT_SHOW_MODS`)
285 |
286 | ### 0.3.3
287 |
288 | - 修复特殊情况下玩家列表排版错误的问题(虽然现在使用其他字体的情况下还是有点问题)
289 | - 添加显示服务器地址的配置项 (`MCSTAT_SHOW_ADDR`)
290 |
291 | ### 0.3.2
292 |
293 | - 🎉 NoneBot 2.0 🚀
294 |
295 | ### 0.3.1
296 |
297 | - 修复文本内含有 `§k` 时报错的问题
298 |
299 | ### 0.3.0
300 |
301 | - 弃用 `nonebot-plugin-imageutils`,换用 `pil-utils`
302 | - 支持了更多字体样式
303 | - 支持自定义字体
304 |
305 | ### 0.2.7
306 |
307 | - 修复 `shortcut` 的 `whitelist` 的奇怪表现
308 |
309 | ### 0.2.6
310 |
311 | - 修复 `shortcut` 中没有 `whitelist` 项会报错的问题
312 |
313 | ### 0.2.5
314 |
315 | - `shortcut` 加入 `whitelist` 项配置触发群白名单
316 |
317 | ### 0.2.4
318 |
319 | - 修复玩家列表底下的多余空行
320 |
321 | ### 0.2.3
322 |
323 | - 修复 JE 服务器 Motd 中粗体意外显示为蓝色的 bug
324 |
325 | ### 0.2.2
326 |
327 | - 修复 motd 前后留的空去不干净的问题
328 | - 优化玩家列表显示效果
329 |
330 | ### 0.2.1
331 |
332 | - 修复当最大人数为 0 时出错的问题
333 |
334 | ### 0.2.0
335 |
336 | - 加入快捷指令,详见配置项
337 | - 修复某些 JE 服无法正确显示 Motd 的问题
338 | -
339 |
340 | ### 0.1.1
341 |
342 | - 将查 JE 服时的 `游戏延迟` 字样 改为 `测试延迟`
343 |
--------------------------------------------------------------------------------
/nonebot_plugin_picmcstat/__init__.py:
--------------------------------------------------------------------------------
1 | from nonebot.plugin import PluginMetadata, inherit_supported_adapters, require
2 |
3 | require("nonebot_plugin_alconna")
4 |
5 | from . import __main__ as __main__ # noqa: E402
6 | from .config import ConfigClass # noqa: E402
7 |
8 | __version__ = "0.7.2"
9 | __plugin_meta__ = PluginMetadata(
10 | name="PicMCStat",
11 | description="将一个 Minecraft 服务器的 MOTD 信息绘制为一张图片",
12 | usage="使用 motd 指令查看使用帮助",
13 | homepage="https://github.com/lgc-NB2Dev/nonebot-plugin-picmcstat",
14 | type="application",
15 | config=ConfigClass,
16 | supported_adapters=inherit_supported_adapters("nonebot_plugin_alconna"),
17 | extra={"License": "MIT", "Author": "LgCookie"},
18 | )
19 |
--------------------------------------------------------------------------------
/nonebot_plugin_picmcstat/__main__.py:
--------------------------------------------------------------------------------
1 | from typing import NoReturn
2 |
3 | from nonebot import logger, on_command, on_regex
4 | from nonebot.adapters import Event as BaseEvent, Message
5 | from nonebot.exception import FinishedException
6 | from nonebot.params import CommandArg
7 | from nonebot.typing import T_State
8 | from nonebot_plugin_alconna.uniseg import UniMessage
9 |
10 | from .config import ShortcutType, config
11 | from .draw import ServerType, draw
12 |
13 | try:
14 | from nonebot.adapters.onebot.v11 import GroupMessageEvent as OB11GroupMessageEvent
15 | except ImportError:
16 | OB11GroupMessageEvent = None
17 |
18 |
19 | async def finish_with_query(ip: str, svr_type: ServerType) -> NoReturn:
20 | try:
21 | ret = await draw(ip, svr_type)
22 | except Exception:
23 | msg = UniMessage("出现未知错误,请检查后台输出")
24 | else:
25 | msg = UniMessage.image(raw=ret)
26 | await msg.send(reply_to=config.mcstat_reply_target)
27 | raise FinishedException
28 |
29 |
30 | motdpe_matcher = on_command(
31 | "motdpe",
32 | aliases={"motdbe", "!motdpe", "!motdpe", "!motdbe", "!motdbe"},
33 | priority=98,
34 | state={"svr_type": "be"},
35 | )
36 | motd_matcher = on_command(
37 | "motd",
38 | aliases={"!motd", "!motd", "motdje", "!motdje", "!motdje"},
39 | priority=99,
40 | state={"svr_type": "je"},
41 | )
42 |
43 |
44 | @motd_matcher.handle()
45 | @motdpe_matcher.handle()
46 | async def _(state: T_State, arg_msg: Message = CommandArg()):
47 | arg = arg_msg.extract_plain_text().strip()
48 | svr_type: ServerType = state["svr_type"]
49 | await finish_with_query(arg, svr_type)
50 |
51 |
52 | def append_shortcut_handler(shortcut: ShortcutType):
53 | async def rule(event: BaseEvent): # type: ignore[override]
54 | if not OB11GroupMessageEvent:
55 | logger.warning("快捷指令群号白名单仅可在 OneBot V11 适配器下使用")
56 | elif (wl := shortcut.whitelist) and isinstance(event, OB11GroupMessageEvent):
57 | return event.group_id in wl
58 | return True
59 |
60 | async def handler():
61 | await finish_with_query(shortcut.host, shortcut.type)
62 |
63 | on_regex(shortcut.regex, rule=rule, priority=99).append_handler(handler)
64 |
65 |
66 | def startup():
67 | if s := config.mcstat_shortcuts:
68 | for v in s:
69 | append_shortcut_handler(v)
70 |
71 |
72 | startup()
73 |
--------------------------------------------------------------------------------
/nonebot_plugin_picmcstat/config.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Optional
2 |
3 | from cookit.pyd import field_validator
4 | from nonebot import get_plugin_config
5 | from pydantic import BaseModel, Field
6 |
7 | from .const import ServerType
8 |
9 |
10 | class ShortcutType(BaseModel):
11 | regex: str
12 | host: str
13 | type: ServerType # noqa: A003
14 | whitelist: Optional[list[int]] = []
15 |
16 |
17 | class ConfigClass(BaseModel):
18 | mcstat_font: list[str] = ["Minecraft Seven", "unifont"]
19 | mcstat_show_addr: bool = False
20 | mcstat_show_delay: bool = True
21 | mcstat_show_mods: bool = False
22 | mcstat_reply_target: bool = True
23 | mcstat_shortcuts: list[ShortcutType] = Field(default_factory=list)
24 | mcstat_resolve_dns: bool = True
25 | mcstat_query_twice: bool = True
26 | mcstat_java_protocol_version: int = 767
27 |
28 | @field_validator("mcstat_font", mode="before")
29 | def transform_to_list(cls, v: Any): # noqa: N805
30 | return v if isinstance(v, list) else [v]
31 |
32 |
33 | config = get_plugin_config(ConfigClass)
34 |
--------------------------------------------------------------------------------
/nonebot_plugin_picmcstat/const.py:
--------------------------------------------------------------------------------
1 | import re
2 | from typing import Literal
3 |
4 | from mcstatus.motd.components import Formatting, MinecraftColor
5 |
6 | ServerType = Literal["je", "be"]
7 |
8 | CODE_COLOR = {
9 | "0": "#000000",
10 | "1": "#0000AA",
11 | "2": "#00AA00",
12 | "3": "#00AAAA",
13 | "4": "#AA0000",
14 | "5": "#AA00AA",
15 | "6": "#FFAA00",
16 | "7": "#AAAAAA",
17 | "8": "#555555",
18 | "9": "#5555FF",
19 | "a": "#55FF55",
20 | "b": "#55FFFF",
21 | "c": "#FF5555",
22 | "d": "#FF55FF",
23 | "e": "#FFFF55",
24 | "f": "#FFFFFF",
25 | "g": "#DDD605",
26 | }
27 | STROKE_COLOR = {
28 | "0": "#000000",
29 | "1": "#00002A",
30 | "2": "#002A00",
31 | "3": "#002A2A",
32 | "4": "#2A0000",
33 | "5": "#2A002A",
34 | "6": "#2A2A00",
35 | "7": "#2A2A2A",
36 | "8": "#151515",
37 | "9": "#15153F",
38 | "a": "#153F15",
39 | "b": "#153F3F",
40 | "c": "#3F1515",
41 | "d": "#3F153F",
42 | "e": "#3F3F15",
43 | "f": "#3F3F3F",
44 | "g": "#373501",
45 | }
46 | CODE_COLOR_BEDROCK = {**CODE_COLOR, "g": "#FFAA00"}
47 | STROKE_COLOR_BEDROCK = {**STROKE_COLOR, "g": "#2A2A00"}
48 | STYLE_BBCODE = {
49 | "l": ["[b]", "[/b]"],
50 | "m": ["[del]", "[/del]"],
51 | "n": ["[u]", "[/u]"],
52 | "o": ["[i]", "[/i]"],
53 | "k": ["[obfuscated]", "[/obfuscated]"], # placeholder
54 | }
55 | OBFUSCATED_PLACEHOLDER_REGEX = re.compile(
56 | r"\[obfuscated\](?P.*?)\[/obfuscated\]",
57 | )
58 |
59 | ENUM_CODE_COLOR = {MinecraftColor(k): v for k, v in CODE_COLOR.items()}
60 | ENUM_STROKE_COLOR = {MinecraftColor(k): v for k, v in STROKE_COLOR.items()}
61 | ENUM_CODE_COLOR_BEDROCK = {MinecraftColor(k): v for k, v in CODE_COLOR_BEDROCK.items()}
62 | ENUM_STROKE_COLOR_BEDROCK = {
63 | MinecraftColor(k): v for k, v in STROKE_COLOR_BEDROCK.items()
64 | }
65 | ENUM_STYLE_BBCODE = {Formatting(k): v for k, v in STYLE_BBCODE.items()}
66 |
67 | GAME_MODE_MAP = {"Survival": "生存", "Creative": "创造", "Adventure": "冒险"}
68 | FORMAT_CODE_REGEX = r"§[0-9abcdefgklmnor]"
69 |
--------------------------------------------------------------------------------
/nonebot_plugin_picmcstat/draw.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import socket
3 | from collections.abc import Sequence
4 | from functools import partial
5 | from io import BytesIO
6 | from typing import TYPE_CHECKING, Any, Optional, Union, cast
7 | from typing_extensions import TypeAlias
8 |
9 | from mcstatus import BedrockServer, JavaServer
10 | from mcstatus.motd import Motd
11 | from mcstatus.status_response import JavaStatusResponse
12 | from nonebot import get_driver
13 | from nonebot.log import logger
14 | from PIL.Image import Resampling
15 | from pil_utils import BuildImage, Text2Image
16 |
17 | from .config import config
18 | from .const import CODE_COLOR, GAME_MODE_MAP, STROKE_COLOR, ServerType
19 | from .res import DEFAULT_ICON_RES, DIRT_RES, GRASS_RES
20 | from .util import (
21 | BBCodeTransformer,
22 | chunks,
23 | format_mod_list,
24 | get_latency_color,
25 | resolve_ip,
26 | split_motd_lines,
27 | trim_motd,
28 | )
29 |
30 | if TYPE_CHECKING:
31 | from mcstatus.bedrock_status import BedrockStatusResponse
32 | from pil_utils.typing import ColorType
33 |
34 | MARGIN = 32
35 | MIN_WIDTH = 512
36 | TITLE_FONT_SIZE = 8 * 5
37 | EXTRA_FONT_SIZE = 8 * 4
38 | EXTRA_STROKE_WIDTH = 2
39 | STROKE_RATIO = 0.0625
40 | SPACING = 12
41 | LIST_GAP = 12
42 |
43 | JE_HEADER = "[MCJE服务器信息]"
44 | BE_HEADER = "[MCBE服务器信息]"
45 | SUCCESS_TITLE = "请求成功"
46 |
47 | ImageType: TypeAlias = Union[BuildImage, Text2Image, "ImageGrid"]
48 |
49 |
50 | def ex_default_style(text: str, color_code: str = "", **kwargs) -> Text2Image:
51 | default_kwargs = {
52 | "font_size": EXTRA_FONT_SIZE,
53 | "fill": CODE_COLOR[color_code or "f"],
54 | "font_families": config.mcstat_font,
55 | "stroke_ratio": STROKE_RATIO,
56 | "stroke_fill": STROKE_COLOR[color_code or "f"],
57 | # "spacing": EXTRA_SPACING,
58 | }
59 | default_kwargs.update(kwargs)
60 | return Text2Image.from_bbcode_text(text, **default_kwargs)
61 |
62 |
63 | def calc_offset(*pos: tuple[float, float]) -> tuple[float, float]:
64 | return (sum(x[0] for x in pos), sum(x[1] for x in pos))
65 |
66 |
67 | def draw_image_type_on(bg: BuildImage, it: ImageType, pos: tuple[float, float]):
68 | if isinstance(it, ImageGrid):
69 | it.draw_on(bg, pos)
70 | elif isinstance(it, Text2Image):
71 | it.draw_on_image(bg.image, pos)
72 | else:
73 | bg.paste(
74 | it,
75 | tuple(round(x) for x in pos), # type: ignore
76 | alpha=True,
77 | )
78 |
79 |
80 | def width(obj: ImageType) -> float:
81 | if isinstance(obj, Text2Image):
82 | return obj.longest_line
83 | return obj.width
84 |
85 |
86 | class ImageLine:
87 | def __init__(
88 | self,
89 | left: Union[ImageType, str],
90 | right: Union[ImageType, str, None] = None,
91 | gap: int = LIST_GAP,
92 | ):
93 | self.left = ex_default_style(left) if isinstance(left, str) else left
94 | self.right = (
95 | (ex_default_style(right) if isinstance(right, str) else right)
96 | if right
97 | else None
98 | )
99 | self.gap = gap
100 |
101 | @property
102 | def width(self) -> float:
103 | rw = width(self.right) if self.right else 0
104 | return width(self.left) + self.gap + rw
105 |
106 | @property
107 | def height(self) -> float:
108 | return max(self.left.height, (self.right.height if self.right else 0))
109 |
110 | @property
111 | def size(self) -> tuple[float, float]:
112 | return self.width, self.height
113 |
114 |
115 | class ImageGrid(list[ImageLine]):
116 | def __init__(
117 | self,
118 | *lines: ImageLine,
119 | spacing: int = SPACING,
120 | gap: Optional[int] = None,
121 | align_items: bool = True,
122 | ):
123 | if gap is not None:
124 | lines = tuple(ImageLine(x.left, x.right, gap=gap) for x in lines)
125 | super().__init__(lines)
126 | self.spacing = spacing
127 | self.align_items = align_items
128 |
129 | @classmethod
130 | def from_list(cls, li: Sequence[Union[ImageType, str]], **kwargs) -> "ImageGrid":
131 | return cls(
132 | *(ImageLine(*cast(tuple[Any, Any], x)) for x in chunks(li, 2)),
133 | **kwargs,
134 | )
135 |
136 | @property
137 | def width(self) -> float:
138 | return max(width(x.left) for x in self) + max(
139 | (width(x.right) + x.gap if x.right else 0) for x in self
140 | )
141 |
142 | @property
143 | def height(self) -> float:
144 | return sum(x.height for x in self) + self.spacing * (len(self) - 1)
145 |
146 | @property
147 | def size(self) -> tuple[float, float]:
148 | return self.width, self.height
149 |
150 | def append_line(self, *args, **kwargs):
151 | self.append(ImageLine(*args, **kwargs))
152 |
153 | def draw_on(self, bg: BuildImage, offset_pos: tuple[float, float]) -> None:
154 | max_lw = max(width(x.left) for x in self) if self.align_items else None
155 | y_offset = 0
156 | for line in self:
157 | line_height = line.height
158 | draw_image_type_on(
159 | bg,
160 | line.left,
161 | calc_offset(
162 | offset_pos,
163 | (0, y_offset),
164 | # (0, (line_height - line.left.height)),
165 | ),
166 | )
167 | if line.right:
168 | draw_image_type_on(
169 | bg,
170 | line.right,
171 | calc_offset(
172 | offset_pos,
173 | ((max_lw or width(line.left)) + line.gap, y_offset),
174 | # (0, (line_height - line.right.height)),
175 | ),
176 | )
177 | y_offset += line_height + self.spacing
178 |
179 | def to_image(
180 | self,
181 | background: Optional["ColorType"] = None,
182 | padding: int = 2,
183 | ) -> BuildImage:
184 | size = calc_offset(self.size, (padding * 2, padding * 2))
185 | bg = BuildImage.new(
186 | "RGBA",
187 | tuple(round(x) for x in size), # type: ignore
188 | background or (0, 0, 0, 0),
189 | )
190 | self.draw_on(bg, (padding, padding))
191 | return bg
192 |
193 |
194 | def get_header_by_svr_type(svr_type: ServerType) -> str:
195 | return JE_HEADER if svr_type == "je" else BE_HEADER
196 |
197 |
198 | def draw_bg(width: int, height: int) -> BuildImage:
199 | size = DIRT_RES.width
200 | bg = BuildImage.new("RGBA", (width, height))
201 |
202 | for hi in range(0, height, size):
203 | for wi in range(0, width, size):
204 | bg.paste(DIRT_RES if hi else GRASS_RES, (wi, hi))
205 |
206 | return bg
207 |
208 |
209 | def build_img(
210 | header1: str,
211 | header2: str,
212 | icon: Optional[BuildImage] = None,
213 | extra: Optional[Union[ImageType, str]] = None,
214 | ) -> BytesIO:
215 | if not icon:
216 | icon = DEFAULT_ICON_RES
217 | if isinstance(extra, str):
218 | extra = ex_default_style(extra)
219 |
220 | header_text_color = CODE_COLOR["f"]
221 | header_stroke_color = STROKE_COLOR["f"]
222 |
223 | header_height = 128
224 | half_header_height = int(header_height / 2)
225 |
226 | bg_width = width(extra) + MARGIN * 2 if extra else MIN_WIDTH
227 | bg_height = header_height + MARGIN * 2
228 | bg_width = max(bg_width, MIN_WIDTH)
229 | if extra:
230 | bg_height += extra.height + int(MARGIN / 2)
231 | bg = draw_bg(round(bg_width), round(bg_height))
232 |
233 | if icon.size != (header_height, header_height):
234 | icon = icon.resize_height(
235 | header_height,
236 | inside=False,
237 | resample=Resampling.NEAREST,
238 | )
239 | bg.paste(icon, (MARGIN, MARGIN), alpha=True)
240 |
241 | bg.draw_text(
242 | (
243 | header_height + MARGIN + MARGIN / 2,
244 | MARGIN - 4,
245 | bg_width - MARGIN,
246 | half_header_height + MARGIN + 4,
247 | ),
248 | header1,
249 | halign="left",
250 | fill=header_text_color,
251 | max_fontsize=TITLE_FONT_SIZE,
252 | font_families=config.mcstat_font,
253 | stroke_ratio=STROKE_RATIO,
254 | stroke_fill=header_stroke_color,
255 | )
256 | bg.draw_text(
257 | (
258 | header_height + MARGIN + MARGIN / 2,
259 | half_header_height + MARGIN - 4,
260 | bg_width - MARGIN,
261 | header_height + MARGIN + 4,
262 | ),
263 | header2,
264 | halign="left",
265 | fill=header_text_color,
266 | max_fontsize=TITLE_FONT_SIZE,
267 | font_families=config.mcstat_font,
268 | stroke_ratio=STROKE_RATIO,
269 | stroke_fill=header_stroke_color,
270 | )
271 |
272 | if extra:
273 | draw_image_type_on(
274 | bg,
275 | extra,
276 | (MARGIN, int(header_height + MARGIN + MARGIN / 2)),
277 | )
278 |
279 | return bg.convert("RGB").save("jpeg")
280 |
281 |
282 | def draw_help(svr_type: ServerType) -> BytesIO:
283 | cmd_prefix_li = list(get_driver().config.command_start)
284 | prefix = cmd_prefix_li[0] if cmd_prefix_li else ""
285 |
286 | extra_txt = f"查询Java版服务器: {prefix}motd <服务器IP>\n查询基岩版服务器: {prefix}motdpe <服务器IP>"
287 | return build_img(get_header_by_svr_type(svr_type), "使用帮助", extra=extra_txt)
288 |
289 |
290 | def draw_java(res: JavaStatusResponse, addr: str) -> BytesIO:
291 | transformer = BBCodeTransformer(bedrock=res.motd.bedrock)
292 | # there're no line spacing in Text2Image since pil-utils 0.2.0
293 | # so we split lines there then manually add the space
294 | motd = (
295 | transformer.transform(x) for x in split_motd_lines(trim_motd(res.motd.parsed))
296 | )
297 | online_percent = (
298 | f"{res.players.online / res.players.max * 100:.2f}"
299 | if res.players.max
300 | else "?.??"
301 | )
302 |
303 | mod_svr_type: Optional[str] = None
304 | mod_list: Optional[list[str]] = None
305 | if mod_info := res.raw.get("modinfo"):
306 | if tmp := mod_info.get("type"):
307 | mod_svr_type = tmp
308 | if tmp := mod_info.get("modList"):
309 | mod_list = format_mod_list(tmp)
310 |
311 | l_style = partial(ex_default_style, color_code="7")
312 | grid = ImageGrid(align_items=False)
313 | for line in motd:
314 | grid.append_line(line)
315 | if config.mcstat_show_addr:
316 | grid.append_line(l_style("测试地址: "), addr)
317 | grid.append_line(l_style("服务端名: "), res.version.name)
318 | if mod_svr_type:
319 | grid.append_line(l_style("Mod 端类型: "), mod_svr_type)
320 | grid.append_line(l_style("协议版本: "), str(res.version.protocol))
321 | grid.append_line(
322 | l_style("当前人数: "),
323 | f"{res.players.online}/{res.players.max} ({online_percent}%)",
324 | )
325 | if mod_list:
326 | grid.append_line(l_style("Mod 总数: "), str(len(mod_list)))
327 | grid.append_line(
328 | l_style("聊天签名: "),
329 | "必需" if res.enforces_secure_chat else "无需",
330 | )
331 | if config.mcstat_show_delay:
332 | grid.append_line(
333 | l_style("测试延迟: "),
334 | ex_default_style(f"{res.latency:.2f}ms", get_latency_color(res.latency)),
335 | )
336 | if mod_list and config.mcstat_show_mods:
337 | grid.append_line(
338 | l_style("Mod 列表: "),
339 | ImageGrid.from_list(mod_list),
340 | )
341 | if res.players.sample:
342 | grid.append_line(
343 | l_style("玩家列表: "),
344 | ImageGrid.from_list(
345 | [
346 | transformer.transform(Motd.parse(x.name).parsed)
347 | for x in res.players.sample
348 | ],
349 | ),
350 | )
351 |
352 | icon = (
353 | BuildImage.open(BytesIO(base64.b64decode(res.icon.split(",")[-1])))
354 | if res.icon
355 | else None
356 | )
357 | return build_img(JE_HEADER, SUCCESS_TITLE, icon=icon, extra=grid)
358 |
359 |
360 | def draw_bedrock(res: "BedrockStatusResponse", addr: str) -> BytesIO:
361 | transformer = BBCodeTransformer(bedrock=res.motd.bedrock)
362 | motd = (
363 | transformer.transform(x) for x in split_motd_lines(trim_motd(res.motd.parsed))
364 | )
365 | online_percent = (
366 | f"{int(res.players_online) / int(res.players_max) * 100:.2f}"
367 | if res.players_max
368 | else "?.??"
369 | )
370 |
371 | l_style = partial(ex_default_style, color_code="7")
372 | grid = ImageGrid(align_items=False)
373 | for line in motd:
374 | grid.append_line(line)
375 | if config.mcstat_show_addr:
376 | grid.append_line(l_style("测试地址: "), addr)
377 | grid.append_line(l_style("协议版本: "), str(res.version.protocol))
378 | grid.append_line(l_style("游戏版本: "), res.version.version)
379 | grid.append_line(
380 | l_style("当前人数: "),
381 | f"{res.players.online}/{res.players.max} ({online_percent}%)",
382 | )
383 | if res.map:
384 | grid.append_line(l_style("存档名称: "), res.map)
385 | if res.gamemode:
386 | grid.append_line(
387 | l_style("游戏模式: "),
388 | GAME_MODE_MAP.get(res.gamemode, res.gamemode),
389 | )
390 | if config.mcstat_show_delay:
391 | grid.append_line(
392 | l_style("测试延迟: "),
393 | ex_default_style(f"{res.latency:.2f}ms", get_latency_color(res.latency)),
394 | )
395 |
396 | return build_img(BE_HEADER, SUCCESS_TITLE, extra=grid)
397 |
398 |
399 | def draw_error(e: Exception, svr_type: ServerType) -> BytesIO:
400 | extra = ""
401 | if isinstance(e, TimeoutError):
402 | reason = "请求超时"
403 | elif isinstance(e, socket.gaierror):
404 | reason = "域名解析失败"
405 | extra = str(e)
406 | else:
407 | reason = "出错了!"
408 | extra = f"{e.__class__.__name__}: {e}"
409 | extra_img = ex_default_style(extra).wrap(MIN_WIDTH - MARGIN * 2) if extra else None
410 | return build_img(get_header_by_svr_type(svr_type), reason, extra=extra_img)
411 |
412 |
413 | def draw_resp(
414 | resp: Union[JavaStatusResponse, "BedrockStatusResponse"],
415 | addr: str,
416 | ) -> BytesIO:
417 | if isinstance(resp, JavaStatusResponse):
418 | return draw_java(resp, addr)
419 | return draw_bedrock(resp, addr)
420 |
421 |
422 | async def draw(ip: str, svr_type: ServerType) -> BytesIO:
423 | try:
424 | if not ip:
425 | return draw_help(svr_type)
426 |
427 | is_java = svr_type == "je"
428 | host, port = await resolve_ip(ip, is_java)
429 |
430 | svr = JavaServer(host, port) if is_java else BedrockServer(host, port)
431 | kw = {"version": config.mcstat_java_protocol_version} if is_java else {}
432 | if config.mcstat_query_twice:
433 | await svr.async_status(**kw) # 第一次延迟通常不准
434 | resp = await svr.async_status(**kw)
435 | return draw_resp(resp, ip)
436 |
437 | except Exception as e:
438 | logger.exception("获取服务器状态/画服务器状态图出错")
439 | try:
440 | return draw_error(e, svr_type)
441 | except Exception:
442 | logger.exception("画异常状态图失败")
443 | raise
444 |
--------------------------------------------------------------------------------
/nonebot_plugin_picmcstat/res.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from pil_utils import BuildImage
4 |
5 | MODULE_DIR = Path(__file__).parent
6 | RES_DIR = MODULE_DIR / "res"
7 |
8 | GRASS_RES_PATH = RES_DIR / "grass_side_carried.png"
9 | DIRT_RES_PATH = RES_DIR / "dirt.png"
10 | DEFAULT_ICON_PATH = RES_DIR / "default.png"
11 |
12 | GRASS_RES = BuildImage.open(GRASS_RES_PATH)
13 | DIRT_RES = BuildImage.open(DIRT_RES_PATH)
14 | DEFAULT_ICON_RES = BuildImage.open(DEFAULT_ICON_PATH)
15 |
--------------------------------------------------------------------------------
/nonebot_plugin_picmcstat/res/default.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-picmcstat/fcf2f66cc1c775436317ab1fee5c59599aef1b16/nonebot_plugin_picmcstat/res/default.png
--------------------------------------------------------------------------------
/nonebot_plugin_picmcstat/res/dirt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-picmcstat/fcf2f66cc1c775436317ab1fee5c59599aef1b16/nonebot_plugin_picmcstat/res/dirt.png
--------------------------------------------------------------------------------
/nonebot_plugin_picmcstat/res/grass_side_carried.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-picmcstat/fcf2f66cc1c775436317ab1fee5c59599aef1b16/nonebot_plugin_picmcstat/res/grass_side_carried.png
--------------------------------------------------------------------------------
/nonebot_plugin_picmcstat/util.py:
--------------------------------------------------------------------------------
1 | import random
2 | import re
3 | import string
4 | from collections.abc import Iterator, Sequence
5 | from typing import Optional, TypeVar, Union, cast
6 |
7 | import dns.asyncresolver
8 | import dns.name
9 | import dns.rdatatype as rd
10 | from dns.rdtypes.IN.SRV import SRV as SRVRecordAnswer # noqa: N811
11 | from mcstatus.motd.components import (
12 | Formatting,
13 | MinecraftColor,
14 | ParsedMotdComponent,
15 | WebColor,
16 | )
17 | from mcstatus.motd.transformers import PlainTransformer
18 | from nonebot import logger
19 |
20 | from .config import config
21 | from .const import (
22 | ENUM_CODE_COLOR,
23 | ENUM_CODE_COLOR_BEDROCK,
24 | ENUM_STROKE_COLOR,
25 | ENUM_STROKE_COLOR_BEDROCK,
26 | ENUM_STYLE_BBCODE,
27 | FORMAT_CODE_REGEX,
28 | OBFUSCATED_PLACEHOLDER_REGEX,
29 | STROKE_COLOR,
30 | )
31 |
32 | RANDOM_CHAR_TEMPLATE = f"{string.ascii_letters}{string.digits}!§$%&?#"
33 | WHITESPACE_EXCLUDE_NEWLINE = string.whitespace.replace("\n", "")
34 | DNS_RESOLVER = dns.asyncresolver.Resolver()
35 | DNS_RESOLVER.nameservers = [*DNS_RESOLVER.nameservers, "1.1.1.1", "1.0.0.1"]
36 |
37 | T = TypeVar("T")
38 |
39 |
40 | def get_latency_color(delay: float) -> str:
41 | if delay <= 50:
42 | return "a"
43 | if delay <= 100:
44 | return "e"
45 | if delay <= 200:
46 | return "6"
47 | return "c"
48 |
49 |
50 | def random_char(length: int) -> str:
51 | return "".join(random.choices(RANDOM_CHAR_TEMPLATE, k=length))
52 |
53 |
54 | def replace_format_code(txt: str, new_str: str = "") -> str:
55 | return re.sub(FORMAT_CODE_REGEX, new_str, txt)
56 |
57 |
58 | def format_mod_list(li: list[Union[dict, str]]) -> list[str]:
59 | def mapping_func(it: Union[dict, str]) -> Optional[str]:
60 | if isinstance(it, str):
61 | return it
62 | if isinstance(it, dict) and (name := it.get("modid")):
63 | version = it.get("version")
64 | return f"{name}-{version}" if version else name
65 | return None
66 |
67 | return sorted((x for x in map(mapping_func, li) if x), key=lambda x: x.lower())
68 |
69 |
70 | async def resolve_host(
71 | host: str,
72 | data_types: Optional[list[rd.RdataType]] = None,
73 | ) -> Optional[str]:
74 | data_types = data_types or [rd.CNAME, rd.AAAA, rd.A]
75 | for rd_type in data_types:
76 | try:
77 | resp = (await DNS_RESOLVER.resolve(host, rd_type)).response
78 | name = resp.answer[0][0].to_text() # type: ignore
79 | except Exception as e:
80 | logger.debug(
81 | f"Failed to resolve {rd_type.name} record for {host}: "
82 | f"{e.__class__.__name__}: {e}",
83 | )
84 | else:
85 | logger.debug(f"Resolved {rd_type.name} record for {host}: {name}")
86 | if rd_type is rd.CNAME:
87 | return await resolve_host(name)
88 | return name
89 | return None
90 |
91 |
92 | async def resolve_srv(host: str) -> tuple[str, int]:
93 | host = "_minecraft._tcp." + host
94 | resp = await DNS_RESOLVER.resolve(host, rd.SRV)
95 | answer = cast(SRVRecordAnswer, resp[0])
96 | return str(answer.target), int(answer.port)
97 |
98 |
99 | async def resolve_ip(ip: str, srv: bool = False) -> tuple[str, Optional[int]]:
100 | if ":" in ip:
101 | host, port = ip.split(":", maxsplit=1)
102 | else:
103 | host = ip
104 | port = None
105 |
106 | if (not port) and srv:
107 | try:
108 | host, port = await resolve_srv(host)
109 | except Exception as e:
110 | logger.debug(
111 | f"Failed to resolve SRV record for {host}: "
112 | f"{e.__class__.__name__}: {e}",
113 | )
114 | logger.debug(f"Resolved SRV record for {ip}: {host}:{port}")
115 |
116 | return (
117 | (await resolve_host(host) if config.mcstat_resolve_dns else None) or host,
118 | int(port) if port else None,
119 | )
120 |
121 |
122 | def chunks(lst: Sequence[T], n: int) -> Iterator[Sequence[T]]:
123 | for i in range(0, len(lst), n):
124 | yield lst[i : i + n]
125 |
126 |
127 | # shit code
128 | def trim_motd(motd: list[ParsedMotdComponent]) -> list[ParsedMotdComponent]:
129 | modified_motd: list[ParsedMotdComponent] = []
130 |
131 | in_content = False
132 | for comp in motd:
133 | if not isinstance(comp, str):
134 | modified_motd.append(comp)
135 | continue
136 | if not comp:
137 | continue
138 |
139 | if not in_content:
140 | if comp[0] in WHITESPACE_EXCLUDE_NEWLINE:
141 | comp = comp.lstrip(WHITESPACE_EXCLUDE_NEWLINE)
142 | if not comp:
143 | continue
144 |
145 | if not comp[0].isspace():
146 | in_content = True
147 |
148 | if "\n" not in comp:
149 | modified_motd.append(comp)
150 | continue
151 |
152 | # new line
153 | last, *inner = comp.split("\n")
154 | last = last.rstrip()
155 | if not inner:
156 | modified_motd.append(f"{last}\n")
157 | in_content = False
158 | continue
159 |
160 | for i in range(len(modified_motd) - 1, -1, -1):
161 | it = modified_motd[i]
162 | if not (isinstance(it, str) and it):
163 | continue
164 | if it[-1] in WHITESPACE_EXCLUDE_NEWLINE:
165 | modified_motd[i] = it = it.rstrip(WHITESPACE_EXCLUDE_NEWLINE)
166 | if it:
167 | break
168 |
169 | new = inner[-1].lstrip()
170 | inner = (x.strip() for x in inner[:-1])
171 | modified_motd.append("\n".join((last, *inner, new)))
172 | in_content = bool(new)
173 |
174 | return [x for x in modified_motd if x]
175 |
176 |
177 | def split_motd_lines(motd: Sequence[ParsedMotdComponent]):
178 | lines: list[list[ParsedMotdComponent]] = []
179 |
180 | current_line: list[ParsedMotdComponent] = []
181 | using_color: Union[MinecraftColor, WebColor, None] = None
182 | using_formats: list[Formatting] = []
183 |
184 | for comp in motd:
185 | if isinstance(comp, str) and "\n" in comp:
186 | # not fully tested, lazy to do
187 | str_lines = comp.split("\n")
188 |
189 | last_line = ""
190 | if len(str_lines) > 1:
191 | last_line = str_lines[-1]
192 | str_lines = str_lines[:-1]
193 |
194 | for line in str_lines:
195 | if line:
196 | current_line.append(line)
197 | current_line.append(Formatting.RESET)
198 | lines.append(current_line)
199 |
200 | current_line = []
201 | if using_color:
202 | current_line.append(using_color)
203 | if using_formats:
204 | current_line.extend(using_formats)
205 |
206 | if last_line:
207 | current_line.append(last_line)
208 |
209 | continue
210 |
211 | if isinstance(comp, (MinecraftColor, WebColor)):
212 | using_color = comp
213 |
214 | elif isinstance(comp, Formatting):
215 | if comp is Formatting.RESET:
216 | using_color = None
217 | using_formats = []
218 | else:
219 | using_formats.append(comp)
220 |
221 | current_line.append(comp)
222 |
223 | if current_line:
224 | lines.append(current_line)
225 |
226 | return lines
227 |
228 |
229 | class BBCodeTransformer(PlainTransformer):
230 | def __init__(self, *, bedrock: bool = False) -> None:
231 | self.bedrock = bedrock
232 | self.on_reset = []
233 |
234 | def transform(self, motd_components: Sequence[ParsedMotdComponent]) -> str:
235 | self.on_reset = []
236 | return super().transform(motd_components)
237 |
238 | def _format_output(self, results: list[str]) -> str:
239 | text = super()._format_output(results) + "".join(reversed(self.on_reset))
240 | return re.sub(
241 | OBFUSCATED_PLACEHOLDER_REGEX,
242 | lambda m: (random_char(len(i)) if (i := m.group("inner")) else ""),
243 | text,
244 | )
245 |
246 | def _handle_minecraft_color(self, element: MinecraftColor, /) -> str:
247 | stroke_map = ENUM_STROKE_COLOR_BEDROCK if self.bedrock else ENUM_STROKE_COLOR
248 | color_map = ENUM_CODE_COLOR_BEDROCK if self.bedrock else ENUM_CODE_COLOR
249 | self.on_reset.append("[/color][/stroke]")
250 | return f"[stroke={stroke_map[element]}][color={color_map[element]}]"
251 |
252 | def _handle_web_color(self, element: WebColor, /) -> str:
253 | self.on_reset.append("[/color][/stroke]")
254 | return f"[stroke={STROKE_COLOR['f']}][color={element.hex}]"
255 |
256 | def _handle_formatting(self, element: Formatting, /) -> str:
257 | if element is Formatting.RESET:
258 | to_return = "".join(self.on_reset)
259 | self.on_reset = []
260 | return to_return
261 | start, end = ENUM_STYLE_BBCODE[element]
262 | self.on_reset.append(end)
263 | return start
264 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "nonebot-plugin-picmcstat"
3 | dynamic = ["version"]
4 | description = "A NoneBot2 plugin generates a pic from a Minecraft server's MOTD"
5 | authors = [{ name = "LgCookie", email = "lgc2333@126.com" }]
6 | dependencies = [
7 | "nonebot2>=2.4.1",
8 | "nonebot-plugin-alconna>=0.54.2",
9 | "mcstatus>=11.1.1",
10 | "pil-utils>=0.2.2",
11 | "punycode>=0.2.1",
12 | "dnspython>=2.7.0",
13 | "cookit[pydantic]>=0.9.3",
14 | ]
15 | requires-python = ">=3.9,<4.0"
16 | readme = "README.md"
17 | license = { text = "MIT" }
18 |
19 | [project.urls]
20 | homepage = "https://github.com/lgc-NB2Dev/nonebot-plugin-picmcstat"
21 |
22 | [tool.pdm.build]
23 | includes = []
24 |
25 | [tool.pdm.version]
26 | source = "file"
27 | path = "nonebot_plugin_picmcstat/__init__.py"
28 |
29 | [build-system]
30 | requires = ["pdm-backend"]
31 | build-backend = "pdm.backend"
32 |
--------------------------------------------------------------------------------