├── .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 |
4 | 5 | 6 | NoneBotPluginLogo 7 | 8 | 9 |

10 | NoneBotPluginText 11 |

12 | 13 | # NoneBot-Plugin-PicMCStat 14 | 15 | _✨ Minecraft 服务器 MOTD 查询 图片版 ✨_ 16 | 17 | python 18 | 19 | pdm-managed 20 | 21 | 22 | wakatime 23 | 24 | 25 |
26 | 27 | 28 | Pydantic Version 1 Or 2 29 | 30 | 31 | license 32 | 33 | 34 | pypi 35 | 36 | 37 | pypi download 38 | 39 | 40 |
41 | 42 | 43 | NoneBot Registry 44 | 45 | 46 | Supported Adapters 47 | 48 | 49 |
50 | 51 | ## 📖 介绍 52 | 53 | 插件实际上是可以展示 **玩家列表**、**Mod 端信息 以及 Mod 列表(还未测试)** 的,这里没有找到合适的例子所以没在效果图里展示出来,如果遇到问题可以发 issue 54 | 55 | 插件包体内并没有自带图片内 Unifont 字体,需要的话请参考 [这里](#字体) 安装字体 56 | 57 |
58 | 效果图 59 | 60 | ![example](https://raw.githubusercontent.com/lgc-NB2Dev/readme/main/picmcstat/example.png) 61 | ![example](https://raw.githubusercontent.com/lgc-NB2Dev/readme/main/picmcstat/example_je.png) 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 | ![usage](https://raw.githubusercontent.com/lgc-NB2Dev/readme/main/picmcstat/usage.png) 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 | --------------------------------------------------------------------------------