├── .github ├── FUNDING.yml └── workflows │ └── pypi-publish.yml ├── nonebot_plugin_picmcstat ├── res │ ├── dirt.png │ ├── default.png │ └── grass_side_carried.png ├── res.py ├── __init__.py ├── config.py ├── const.py ├── __main__.py ├── util.py └── draw.py ├── pyproject.toml ├── LICENSE ├── .gitignore └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: ["https://afdian.net/@lgc2333/"] 4 | -------------------------------------------------------------------------------- /nonebot_plugin_picmcstat/res/dirt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-picmcstat/HEAD/nonebot_plugin_picmcstat/res/dirt.png -------------------------------------------------------------------------------- /nonebot_plugin_picmcstat/res/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-picmcstat/HEAD/nonebot_plugin_picmcstat/res/default.png -------------------------------------------------------------------------------- /nonebot_plugin_picmcstat/res/grass_side_carried.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgc-NB2Dev/nonebot-plugin-picmcstat/HEAD/nonebot_plugin_picmcstat/res/grass_side_carried.png -------------------------------------------------------------------------------- /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/__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.8.1" 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 | -------------------------------------------------------------------------------- /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.3", 8 | "nonebot-plugin-alconna>=0.59.4", 9 | "mcstatus>=12.0.5", 10 | "pil-utils>=0.2.2", 11 | "punycode>=0.2.1", 12 | "dnspython>=2.7.0", 13 | "cookit[pydantic]>=0.13.0", 14 | ] 15 | requires-python = ">=3.10,<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 | [build-system] 23 | requires = ["hatchling"] 24 | build-backend = "hatchling.build" 25 | 26 | [tool.hatch.version] 27 | path = "nonebot_plugin_picmcstat/__init__.py" 28 | -------------------------------------------------------------------------------- /.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 uv 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: Install uv 24 | uses: astral-sh/setup-uv@v5 25 | 26 | - name: 'Set up Python' 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version-file: 'pyproject.toml' 30 | 31 | - name: Build and Publish distribution 📦 to PyPI 32 | run: | 33 | uv build 34 | uv publish 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /nonebot_plugin_picmcstat/config.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from cookit.pyd import field_validator, model_with_alias_generator 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: list[int] | None = [] 15 | 16 | 17 | @model_with_alias_generator(lambda x: f"mcstat_{x}") 18 | class ConfigClass(BaseModel): 19 | font: list[str] = ["Minecraft Seven", "unifont"] 20 | show_addr: bool = False 21 | show_delay: bool = True 22 | show_mods: bool = False 23 | reply_target: bool = True 24 | shortcuts: list[ShortcutType] = Field(default_factory=list) 25 | resolve_dns: bool = True 26 | resolve_dns_ipv6: bool = True 27 | query_twice: bool = True 28 | java_protocol_version: int = 772 29 | enable_auto_detect: bool = True 30 | 31 | @field_validator("font", mode="before") 32 | def transform_to_list(cls, v: Any): # noqa: N805 33 | return v if isinstance(v, list) else [v] 34 | 35 | 36 | config = get_plugin_config(ConfigClass) 37 | -------------------------------------------------------------------------------- /nonebot_plugin_picmcstat/const.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Literal, TypeAlias 3 | 4 | from mcstatus.motd.components import Formatting, MinecraftColor 5 | 6 | ServerTypeRaw: TypeAlias = Literal["je", "be"] 7 | ServerType: TypeAlias = Literal[ServerTypeRaw, "auto"] 8 | 9 | CODE_COLOR = { 10 | "0": "#000000", 11 | "1": "#0000AA", 12 | "2": "#00AA00", 13 | "3": "#00AAAA", 14 | "4": "#AA0000", 15 | "5": "#AA00AA", 16 | "6": "#FFAA00", 17 | "7": "#AAAAAA", 18 | "8": "#555555", 19 | "9": "#5555FF", 20 | "a": "#55FF55", 21 | "b": "#55FFFF", 22 | "c": "#FF5555", 23 | "d": "#FF55FF", 24 | "e": "#FFFF55", 25 | "f": "#FFFFFF", 26 | "g": "#DDD605", 27 | } 28 | STROKE_COLOR = { 29 | "0": "#000000", 30 | "1": "#00002A", 31 | "2": "#002A00", 32 | "3": "#002A2A", 33 | "4": "#2A0000", 34 | "5": "#2A002A", 35 | "6": "#2A2A00", 36 | "7": "#2A2A2A", 37 | "8": "#151515", 38 | "9": "#15153F", 39 | "a": "#153F15", 40 | "b": "#153F3F", 41 | "c": "#3F1515", 42 | "d": "#3F153F", 43 | "e": "#3F3F15", 44 | "f": "#3F3F3F", 45 | "g": "#373501", 46 | } 47 | CODE_COLOR_BEDROCK = {**CODE_COLOR, "g": "#FFAA00"} 48 | STROKE_COLOR_BEDROCK = {**STROKE_COLOR, "g": "#2A2A00"} 49 | STYLE_BBCODE = { 50 | "l": ["[b]", "[/b]"], 51 | "m": ["[del]", "[/del]"], 52 | "n": ["[u]", "[/u]"], 53 | "o": ["[i]", "[/i]"], 54 | "k": ["[obfuscated]", "[/obfuscated]"], # placeholder 55 | } 56 | OBFUSCATED_PLACEHOLDER_REGEX = re.compile( 57 | r"\[obfuscated\](?P.*?)\[/obfuscated\]", 58 | ) 59 | 60 | ENUM_CODE_COLOR = {MinecraftColor(k): v for k, v in CODE_COLOR.items()} 61 | ENUM_STROKE_COLOR = {MinecraftColor(k): v for k, v in STROKE_COLOR.items()} 62 | ENUM_CODE_COLOR_BEDROCK = {MinecraftColor(k): v for k, v in CODE_COLOR_BEDROCK.items()} 63 | ENUM_STROKE_COLOR_BEDROCK = { 64 | MinecraftColor(k): v for k, v in STROKE_COLOR_BEDROCK.items() 65 | } 66 | ENUM_STYLE_BBCODE = {Formatting(k): v for k, v in STYLE_BBCODE.items()} 67 | 68 | GAME_MODE_MAP = {"Survival": "生存", "Creative": "创造", "Adventure": "冒险"} 69 | FORMAT_CODE_REGEX = r"§[0-9abcdefgklmnor]" 70 | -------------------------------------------------------------------------------- /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, CommandWhitespace 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 | motdje_matcher = on_command( 20 | "motdje", 21 | priority=98, 22 | state={"svr_type": "je"}, 23 | ) 24 | motdpe_matcher = on_command( 25 | "motdpe", 26 | aliases={"motdbe"}, 27 | priority=98, 28 | state={"svr_type": "be"}, 29 | ) 30 | motd_matcher = on_command( 31 | "motd", 32 | priority=99, 33 | state={"svr_type": "auto" if config.enable_auto_detect else "je"}, 34 | ) 35 | 36 | 37 | async def finish_with_query(ip: str, svr_type: ServerType) -> NoReturn: 38 | try: 39 | ret = await draw(ip, svr_type) 40 | except Exception: 41 | msg = UniMessage("出现未知错误,请检查后台输出") 42 | else: 43 | msg = UniMessage.image(raw=ret) 44 | await msg.send(reply_to=config.reply_target) 45 | raise FinishedException 46 | 47 | 48 | @motd_matcher.handle() 49 | @motdje_matcher.handle() 50 | @motdpe_matcher.handle() 51 | async def _( 52 | state: T_State, 53 | arg_msg: Message = CommandArg(), 54 | space: str | None = CommandWhitespace(), 55 | ): 56 | if arg_msg and (space is None): 57 | return 58 | arg = arg_msg.extract_plain_text().strip() 59 | svr_type: ServerType = state["svr_type"] 60 | await finish_with_query(arg, svr_type) 61 | 62 | 63 | def append_shortcut_handler(shortcut: ShortcutType): 64 | async def rule(event: BaseEvent): # type: ignore[override] 65 | if not OB11GroupMessageEvent: 66 | logger.warning("快捷指令群号白名单仅可在 OneBot V11 适配器下使用") 67 | elif (wl := shortcut.whitelist) and isinstance(event, OB11GroupMessageEvent): 68 | return event.group_id in wl 69 | return True 70 | 71 | async def handler(): 72 | await finish_with_query(shortcut.host, shortcut.type) 73 | 74 | on_regex(shortcut.regex, rule=rule, priority=99).append_handler(handler) 75 | 76 | 77 | def startup(): 78 | if s := config.shortcuts: 79 | for v in s: 80 | append_shortcut_handler(v) 81 | 82 | 83 | startup() 84 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | uv 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` 表示基岩版服,`auto` 代表自动检测 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_RESOLVE_DNS_IPV6` - 是否启用 IPv6 解析 197 | 198 | 默认:`True` 199 | 200 | 是否优先使用 IPv6 地址进行查询 201 | 当启用此配置项时,会优先尝试使用 IPv6 地址进行连接,如连接失败则自动回落到 IPv4 202 | 如果你的网络环境不支持 IPv6,可以关闭此配置项以避免不必要的等待 203 | 此配置项仅在 `MCSTAT_RESOLVE_DNS` 启用时生效 204 | 205 | ### `MCSTAT_QUERY_TWICE` - 是否查询两遍服务器状态 206 | 207 | 默认:`True` 208 | 209 | 由于第一次测得的延迟一般不准,所以做了这个配置, 210 | 开启后每次查询时,会丢掉第一次的结果再查询一次,且使用第二次查询到的结果 211 | 212 | ### `MCSTAT_JAVA_PROTOCOL_VERSION` - Motd Java 服务器时向服务器发送的客户端协议版本 213 | 214 | 默认:`767` 215 | 216 | ### `MCSTAT_ENABLE_AUTO_DETECT` - 是否在使用未指定服务器类型的 `motd` 指令时自动检测 217 | 218 | 默认:`True` 219 | 220 | 如设为 `False` 将默认指定为 Java 版 221 | 222 | ## 🎉 使用 223 | 224 | 发送 `motd` 指令 查看使用指南 225 | 226 | ![usage](https://raw.githubusercontent.com/lgc-NB2Dev/readme/main/picmcstat/usage.png) 227 | 228 | ## 📞 联系 229 | 230 | QQ:3076823485 231 | Telegram:[@lgc2333](https://t.me/lgc2333) 232 | 吹水群:[168603371](https://qm.qq.com/q/EikuZ5sP4G) 233 | 邮箱: 234 | 235 | ## 💡 鸣谢 236 | 237 | ### [pil-utils](https://github.com/MeetWq/pil-utils) 238 | 239 | 超好用的 Pillow 辅助库,wq 佬是叠!快去用 awa 240 | 241 | ## 💰 赞助 242 | 243 | **[赞助我](https://blog.lgc2333.top/donate)** 244 | 245 | 感谢大家的赞助!你们的赞助将是我继续创作的动力! 246 | 247 | ## 📝 更新日志 248 | 249 | ### 0.8.1 250 | 251 | - 添加配置项 `MCSTAT_RESOLVE_DNS_IPV6`,用于禁用 IPv6 解析([#29](https://github.com/lgc-NB2Dev/nonebot-plugin-picmcstat/issues/29)) 252 | - 当 IPv6 连接失败时自动回落到 IPv4 253 | 254 | ### 0.8.0 255 | 256 | - 加入自动检测服务器类型的功能,默认启用(Thanks to [#28](https://github.com/lgc-NB2Dev/nonebot-plugin-picmcstat/pull/28)) 257 | 258 | ### 0.7.1 259 | 260 | - 修复文字下对齐的 Bug 261 | 262 | ### 0.7.0 263 | 264 | - 适配 pil-utils 0.2 265 | - 更改配置项 `MCSTAT_FONT` 类型为 `List[str]`(`str` 仍然受支持) 266 | 267 | ### 0.6.3 268 | 269 | - fix [#22](https://github.com/lgc-NB2Dev/nonebot-plugin-picmcstat/issues/22) 270 | 271 | ### 0.6.2 272 | 273 | - 允许自定义向 Java 服务器发送的客户端协议版本,且提高默认协议版本以解决部分服务器 Motd 渐变显示异常的问题 274 | 275 | ### 0.6.1 276 | 277 | - fix [#21](https://github.com/lgc-NB2Dev/nonebot-plugin-picmcstat/issues/21) 278 | 279 | ### 0.6.0 280 | 281 | - 适配 Pydantic V1 & V2 282 | 283 | ### 0.5.1 284 | 285 | - 修复 玩家 / Mod 列表 中出现的一些 Bug ~~果然又出 Bug 了~~ 286 | - 添加配置项 `MCSTAT_SHOW_DELAY`、`MCSTAT_QUERY_TWICE` 287 | 288 | ### 0.5.0 289 | 290 | - 换用 Alconna 支持多平台 291 | - 快捷指令支持多平台(`whitelist` 依然仅支持 OneBot V11) 292 | - 添加配置项 `MCSTAT_RESOLVE_DNS` 293 | - 部分代码重构 ~~Bug 海与屎山代码又增加了~~ 294 | 295 | ### 0.4.0 296 | 297 | - 修复修复无法解析中文域名 IP 的错误([#13](https://github.com/lgc-NB2Dev/nonebot-plugin-picmcstat/issues/13)) 298 | - 使用 SAA 支持多适配器(shortcut 依然仅支持 OB V11) 299 | - 添加配置项 `MCSTAT_REPLY_TARGET` 300 | 301 | ### 0.3.5 302 | 303 | - 修复上个版本的小 Bug 304 | 305 | ### 0.3.4 306 | 307 | - 修复无法正常绘制 Mod 列表的情况 308 | - 增加显示 Mod 列表的配置项 (`MCSTAT_SHOW_MODS`) 309 | 310 | ### 0.3.3 311 | 312 | - 修复特殊情况下玩家列表排版错误的问题(虽然现在使用其他字体的情况下还是有点问题) 313 | - 添加显示服务器地址的配置项 (`MCSTAT_SHOW_ADDR`) 314 | 315 | ### 0.3.2 316 | 317 | - 🎉 NoneBot 2.0 🚀 318 | 319 | ### 0.3.1 320 | 321 | - 修复文本内含有 `§k` 时报错的问题 322 | 323 | ### 0.3.0 324 | 325 | - 弃用 `nonebot-plugin-imageutils`,换用 `pil-utils` 326 | - 支持了更多字体样式 327 | - 支持自定义字体 328 | 329 | ### 0.2.7 330 | 331 | - 修复 `shortcut` 的 `whitelist` 的奇怪表现 332 | 333 | ### 0.2.6 334 | 335 | - 修复 `shortcut` 中没有 `whitelist` 项会报错的问题 336 | 337 | ### 0.2.5 338 | 339 | - `shortcut` 加入 `whitelist` 项配置触发群白名单 340 | 341 | ### 0.2.4 342 | 343 | - 修复玩家列表底下的多余空行 344 | 345 | ### 0.2.3 346 | 347 | - 修复 JE 服务器 Motd 中粗体意外显示为蓝色的 bug 348 | 349 | ### 0.2.2 350 | 351 | - 修复 motd 前后留的空去不干净的问题 352 | - 优化玩家列表显示效果 353 | 354 | ### 0.2.1 355 | 356 | - 修复当最大人数为 0 时出错的问题 357 | 358 | ### 0.2.0 359 | 360 | - 加入快捷指令,详见配置项 361 | - 修复某些 JE 服无法正确显示 Motd 的问题 362 | - 363 | 364 | ### 0.1.1 365 | 366 | - 将查 JE 服时的 `游戏延迟` 字样 改为 `测试延迟` 367 | -------------------------------------------------------------------------------- /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 TYPE_CHECKING, TypeVar, cast 6 | 7 | import dns.asyncresolver 8 | import dns.rdatatype as rd 9 | from mcstatus.motd.components import ( 10 | Formatting, 11 | MinecraftColor, 12 | ParsedMotdComponent, 13 | WebColor, 14 | ) 15 | from mcstatus.motd.transformers import PlainTransformer 16 | from nonebot import logger 17 | 18 | from .config import config 19 | from .const import ( 20 | ENUM_CODE_COLOR, 21 | ENUM_CODE_COLOR_BEDROCK, 22 | ENUM_STROKE_COLOR, 23 | ENUM_STROKE_COLOR_BEDROCK, 24 | ENUM_STYLE_BBCODE, 25 | FORMAT_CODE_REGEX, 26 | OBFUSCATED_PLACEHOLDER_REGEX, 27 | STROKE_COLOR, 28 | ) 29 | 30 | if TYPE_CHECKING: 31 | from dns.rdtypes.IN.SRV import SRV as SRVRecordAnswer # noqa: N811 32 | from mcstatus.forge_data import RawForgeDataMod 33 | 34 | RANDOM_CHAR_TEMPLATE = f"{string.ascii_letters}{string.digits}!§$%&?#" 35 | WHITESPACE_EXCLUDE_NEWLINE = string.whitespace.replace("\n", "") 36 | DNS_RESOLVER = dns.asyncresolver.Resolver() 37 | DNS_RESOLVER.nameservers = [*DNS_RESOLVER.nameservers, "1.1.1.1", "1.0.0.1"] 38 | 39 | T = TypeVar("T") 40 | 41 | 42 | def get_latency_color(delay: float) -> str: 43 | if delay <= 50: 44 | return "a" 45 | if delay <= 100: 46 | return "e" 47 | if delay <= 200: 48 | return "6" 49 | return "c" 50 | 51 | 52 | def random_char(length: int) -> str: 53 | return "".join(random.choices(RANDOM_CHAR_TEMPLATE, k=length)) 54 | 55 | 56 | def replace_format_code(txt: str, new_str: str = "") -> str: 57 | return re.sub(FORMAT_CODE_REGEX, new_str, txt) 58 | 59 | 60 | def format_mod_list(li: list["RawForgeDataMod"] | list[str]) -> list[str]: 61 | def mapping_func(it: "RawForgeDataMod | str") -> str | None: 62 | if isinstance(it, str): 63 | return it 64 | if isinstance(it, dict) and (name := (it.get("modid") or it.get("modId"))): 65 | version = it.get("version") or it.get("modmarker") 66 | return f"{name}-{version}" if version else name 67 | return None 68 | 69 | return sorted((x for x in map(mapping_func, li) if x), key=lambda x: x.lower()) 70 | 71 | 72 | async def resolve_host( 73 | host: str, 74 | data_types: list[rd.RdataType] | None = None, 75 | *, 76 | resolve_dns_ipv6: bool | None = None, 77 | ) -> str | None: 78 | if resolve_dns_ipv6 is None: 79 | resolve_dns_ipv6 = config.resolve_dns_ipv6 80 | if data_types is None: 81 | data_types = [rd.CNAME, rd.AAAA, rd.A] if resolve_dns_ipv6 else [rd.CNAME, rd.A] 82 | for rd_type in data_types: 83 | try: 84 | resp = (await DNS_RESOLVER.resolve(host, rd_type)).response 85 | name = resp.answer[0][0].to_text() # type: ignore 86 | except Exception as e: 87 | logger.debug( 88 | f"Failed to resolve {rd_type.name} record for {host}: " 89 | f"{e.__class__.__name__}: {e}", 90 | ) 91 | else: 92 | logger.debug(f"Resolved {rd_type.name} record for {host}: {name}") 93 | if rd_type is rd.CNAME: 94 | return await resolve_host(name, resolve_dns_ipv6=resolve_dns_ipv6) 95 | return name 96 | return None 97 | 98 | 99 | async def resolve_srv(host: str) -> tuple[str, int]: 100 | host = "_minecraft._tcp." + host 101 | resp = await DNS_RESOLVER.resolve(host, rd.SRV) 102 | answer = cast("SRVRecordAnswer", resp[0]) 103 | return str(answer.target), int(answer.port) 104 | 105 | 106 | async def resolve_ip( 107 | ip: str, 108 | srv: bool = False, 109 | *, 110 | resolve_dns_ipv6: bool | None = None, 111 | ) -> tuple[str, int | None]: 112 | if ":" in ip: 113 | host, port = ip.split(":", maxsplit=1) 114 | else: 115 | host = ip 116 | port = None 117 | 118 | if (not port) and srv: 119 | try: 120 | host, port = await resolve_srv(host) 121 | except Exception as e: 122 | logger.debug( 123 | f"Failed to resolve SRV record for {host}: {e.__class__.__name__}: {e}", 124 | ) 125 | logger.debug(f"Resolved SRV record for {ip}: {host}:{port}") 126 | 127 | return ( 128 | (await resolve_host(host, resolve_dns_ipv6=resolve_dns_ipv6) if config.resolve_dns else None) or host, 129 | int(port) if port else None, 130 | ) 131 | 132 | 133 | def chunks(lst: Sequence[T], n: int) -> Iterator[Sequence[T]]: 134 | for i in range(0, len(lst), n): 135 | yield lst[i : i + n] 136 | 137 | 138 | # shit code 139 | def trim_motd(motd: list[ParsedMotdComponent]) -> list[ParsedMotdComponent]: 140 | modified_motd: list[ParsedMotdComponent] = [] 141 | 142 | in_content = False 143 | for comp in motd: 144 | if not isinstance(comp, str): 145 | modified_motd.append(comp) 146 | continue 147 | if not comp: 148 | continue 149 | 150 | if not in_content: 151 | if comp[0] in WHITESPACE_EXCLUDE_NEWLINE: 152 | comp = comp.lstrip(WHITESPACE_EXCLUDE_NEWLINE) 153 | if not comp: 154 | continue 155 | 156 | if not comp[0].isspace(): 157 | in_content = True 158 | 159 | if "\n" not in comp: 160 | modified_motd.append(comp) 161 | continue 162 | 163 | # new line 164 | last, *inner = comp.split("\n") 165 | last = last.rstrip() 166 | if not inner: 167 | modified_motd.append(f"{last}\n") 168 | in_content = False 169 | continue 170 | 171 | for i in range(len(modified_motd) - 1, -1, -1): 172 | it = modified_motd[i] 173 | if not (isinstance(it, str) and it): 174 | continue 175 | if it[-1] in WHITESPACE_EXCLUDE_NEWLINE: 176 | modified_motd[i] = it = it.rstrip(WHITESPACE_EXCLUDE_NEWLINE) 177 | if it: 178 | break 179 | 180 | new = inner[-1].lstrip() 181 | inner = (x.strip() for x in inner[:-1]) 182 | modified_motd.append("\n".join((last, *inner, new))) 183 | in_content = bool(new) 184 | 185 | return [x for x in modified_motd if x] 186 | 187 | 188 | def split_motd_lines(motd: Sequence[ParsedMotdComponent]): 189 | lines: list[list[ParsedMotdComponent]] = [] 190 | 191 | current_line: list[ParsedMotdComponent] = [] 192 | using_color: MinecraftColor | WebColor | None = None 193 | using_formats: list[Formatting] = [] 194 | 195 | for comp in motd: 196 | if isinstance(comp, str) and "\n" in comp: 197 | # not fully tested, lazy to do 198 | str_lines = comp.split("\n") 199 | 200 | last_line = "" 201 | if len(str_lines) > 1: 202 | last_line = str_lines[-1] 203 | str_lines = str_lines[:-1] 204 | 205 | for line in str_lines: 206 | if line: 207 | current_line.append(line) 208 | current_line.append(Formatting.RESET) 209 | lines.append(current_line) 210 | 211 | current_line = [] 212 | if using_color: 213 | current_line.append(using_color) 214 | if using_formats: 215 | current_line.extend(using_formats) 216 | 217 | if last_line: 218 | current_line.append(last_line) 219 | 220 | continue 221 | 222 | if isinstance(comp, MinecraftColor | WebColor): 223 | using_color = comp 224 | 225 | elif isinstance(comp, Formatting): 226 | if comp is Formatting.RESET: 227 | using_color = None 228 | using_formats = [] 229 | else: 230 | using_formats.append(comp) 231 | 232 | current_line.append(comp) 233 | 234 | if current_line: 235 | lines.append(current_line) 236 | 237 | return lines 238 | 239 | 240 | class BBCodeTransformer(PlainTransformer): 241 | def __init__(self, *, bedrock: bool = False) -> None: 242 | self.bedrock = bedrock 243 | self.on_reset = [] 244 | 245 | def transform(self, motd_components: Sequence[ParsedMotdComponent]) -> str: 246 | self.on_reset = [] 247 | return super().transform(motd_components) 248 | 249 | def _format_output(self, results: list[str]) -> str: 250 | text = super()._format_output(results) + "".join(reversed(self.on_reset)) 251 | return re.sub( 252 | OBFUSCATED_PLACEHOLDER_REGEX, 253 | lambda m: (random_char(len(i)) if (i := m.group("inner")) else ""), 254 | text, 255 | ) 256 | 257 | def _handle_minecraft_color(self, element: MinecraftColor, /) -> str: 258 | stroke_map = ENUM_STROKE_COLOR_BEDROCK if self.bedrock else ENUM_STROKE_COLOR 259 | color_map = ENUM_CODE_COLOR_BEDROCK if self.bedrock else ENUM_CODE_COLOR 260 | self.on_reset.append("[/color][/stroke]") 261 | return f"[stroke={stroke_map[element]}][color={color_map[element]}]" 262 | 263 | def _handle_web_color(self, element: WebColor, /) -> str: 264 | self.on_reset.append("[/color][/stroke]") 265 | return f"[stroke={STROKE_COLOR['f']}][color={element.hex}]" 266 | 267 | def _handle_formatting(self, element: Formatting, /) -> str: 268 | if element is Formatting.RESET: 269 | to_return = "".join(self.on_reset) 270 | self.on_reset = [] 271 | return to_return 272 | start, end = ENUM_STYLE_BBCODE[element] 273 | self.on_reset.append(end) 274 | return start 275 | -------------------------------------------------------------------------------- /nonebot_plugin_picmcstat/draw.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import errno 3 | import socket 4 | from collections.abc import Sequence 5 | from functools import partial 6 | from io import BytesIO 7 | from typing import TYPE_CHECKING, Any, Optional, TypeAlias, Union, cast 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, ServerTypeRaw 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.responses 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 | AUTO_HEADER = "[MC服务器信息]" 46 | SUCCESS_TITLE = "请求成功" 47 | DEFAULT_ERR_TITLE = "出错了!" 48 | 49 | ImageType: TypeAlias = Union[BuildImage, Text2Image, "ImageGrid"] 50 | 51 | 52 | def ex_default_style(text: str, color_code: str = "", **kwargs) -> Text2Image: 53 | default_kwargs = { 54 | "font_size": EXTRA_FONT_SIZE, 55 | "fill": CODE_COLOR[color_code or "f"], 56 | "font_families": config.font, 57 | "stroke_ratio": STROKE_RATIO, 58 | "stroke_fill": STROKE_COLOR[color_code or "f"], 59 | # "spacing": EXTRA_SPACING, 60 | } 61 | default_kwargs.update(kwargs) 62 | return Text2Image.from_bbcode_text(text, **default_kwargs) 63 | 64 | 65 | def calc_offset(*pos: tuple[float, float]) -> tuple[float, float]: 66 | return (sum(x[0] for x in pos), sum(x[1] for x in pos)) 67 | 68 | 69 | def draw_image_type_on(bg: BuildImage, it: ImageType, pos: tuple[float, float]): 70 | if isinstance(it, ImageGrid): 71 | it.draw_on(bg, pos) 72 | elif isinstance(it, Text2Image): 73 | it.draw_on_image(bg.image, pos) 74 | else: 75 | bg.paste( 76 | it, 77 | tuple(round(x) for x in pos), # type: ignore 78 | alpha=True, 79 | ) 80 | 81 | 82 | def width(obj: ImageType) -> float: 83 | if isinstance(obj, Text2Image): 84 | return obj.longest_line 85 | return obj.width 86 | 87 | 88 | class ImageLine: 89 | def __init__( 90 | self, 91 | left: ImageType | str, 92 | right: ImageType | str | None = None, 93 | gap: int = LIST_GAP, 94 | ): 95 | self.left = ex_default_style(left) if isinstance(left, str) else left 96 | self.right = ( 97 | (ex_default_style(right) if isinstance(right, str) else right) 98 | if right 99 | else None 100 | ) 101 | self.gap = gap 102 | 103 | @property 104 | def width(self) -> float: 105 | rw = width(self.right) if self.right else 0 106 | return width(self.left) + self.gap + rw 107 | 108 | @property 109 | def height(self) -> float: 110 | return max(self.left.height, (self.right.height if self.right else 0)) 111 | 112 | @property 113 | def size(self) -> tuple[float, float]: 114 | return self.width, self.height 115 | 116 | 117 | class ImageGrid(list[ImageLine]): 118 | def __init__( 119 | self, 120 | *lines: ImageLine, 121 | spacing: int = SPACING, 122 | gap: int | None = None, 123 | align_items: bool = True, 124 | ): 125 | if gap is not None: 126 | lines = tuple(ImageLine(x.left, x.right, gap=gap) for x in lines) 127 | super().__init__(lines) 128 | self.spacing = spacing 129 | self.align_items = align_items 130 | 131 | @classmethod 132 | def from_list(cls, li: Sequence[ImageType | str], **kwargs) -> "ImageGrid": 133 | return cls( 134 | *(ImageLine(*cast("tuple[Any, Any]", x)) for x in chunks(li, 2)), 135 | **kwargs, 136 | ) 137 | 138 | @property 139 | def width(self) -> float: 140 | return max(x.width for x in self) 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 | if svr_type == "je": 196 | return JE_HEADER 197 | if svr_type == "be": 198 | return BE_HEADER 199 | return AUTO_HEADER 200 | 201 | 202 | def draw_bg(width: int, height: int) -> BuildImage: 203 | size = DIRT_RES.width 204 | bg = BuildImage.new("RGBA", (width, height)) 205 | 206 | for hi in range(0, height, size): 207 | for wi in range(0, width, size): 208 | bg.paste(DIRT_RES if hi else GRASS_RES, (wi, hi)) 209 | 210 | return bg 211 | 212 | 213 | def build_img( 214 | header1: str, 215 | header2: str, 216 | icon: BuildImage | None = None, 217 | extra: ImageType | str | None = None, 218 | ) -> BytesIO: 219 | if not icon: 220 | icon = DEFAULT_ICON_RES 221 | if isinstance(extra, str): 222 | extra = ex_default_style(extra) 223 | 224 | header_text_color = CODE_COLOR["f"] 225 | header_stroke_color = STROKE_COLOR["f"] 226 | 227 | header_height = 128 228 | half_header_height = int(header_height / 2) 229 | 230 | bg_width = width(extra) + MARGIN * 2 if extra else MIN_WIDTH 231 | bg_height = header_height + MARGIN * 2 232 | bg_width = max(bg_width, MIN_WIDTH) 233 | if extra: 234 | bg_height += extra.height + int(MARGIN / 2) 235 | bg = draw_bg(round(bg_width), round(bg_height)) 236 | 237 | if icon.size != (header_height, header_height): 238 | icon = icon.resize_height( 239 | header_height, 240 | inside=False, 241 | resample=Resampling.NEAREST, 242 | ) 243 | bg.paste(icon, (MARGIN, MARGIN), alpha=True) 244 | 245 | bg.draw_text( 246 | ( 247 | header_height + MARGIN + MARGIN / 2, 248 | MARGIN - 4, 249 | bg_width - MARGIN, 250 | half_header_height + MARGIN + 4, 251 | ), 252 | header1, 253 | halign="left", 254 | fill=header_text_color, 255 | max_fontsize=TITLE_FONT_SIZE, 256 | font_families=config.font, 257 | stroke_ratio=STROKE_RATIO, 258 | stroke_fill=header_stroke_color, 259 | ) 260 | bg.draw_text( 261 | ( 262 | header_height + MARGIN + MARGIN / 2, 263 | half_header_height + MARGIN - 4, 264 | bg_width - MARGIN, 265 | header_height + MARGIN + 4, 266 | ), 267 | header2, 268 | halign="left", 269 | fill=header_text_color, 270 | max_fontsize=TITLE_FONT_SIZE, 271 | font_families=config.font, 272 | stroke_ratio=STROKE_RATIO, 273 | stroke_fill=header_stroke_color, 274 | ) 275 | 276 | if extra: 277 | draw_image_type_on( 278 | bg, 279 | extra, 280 | (MARGIN, int(header_height + MARGIN + MARGIN / 2)), 281 | ) 282 | 283 | return bg.convert("RGB").save("jpeg") 284 | 285 | 286 | def draw_help(svr_type: ServerType) -> BytesIO: 287 | cmd_prefix_li = list(get_driver().config.command_start) 288 | prefix = cmd_prefix_li[0] if cmd_prefix_li else "" 289 | 290 | extra_txt = [ 291 | f"查询Java版服务器: {prefix}motdje <服务器IP>", 292 | f"查询基岩版服务器: {prefix}motdpe <服务器IP>", 293 | ] 294 | if config.enable_auto_detect: 295 | extra_txt.append(f"自动检测服务器类型: {prefix}motd <服务器IP>") 296 | extra = ImageGrid() 297 | for x in extra_txt: 298 | extra.append_line(x) 299 | return build_img(get_header_by_svr_type(svr_type), "使用帮助", extra=extra) 300 | 301 | 302 | def draw_java(res: JavaStatusResponse, addr: str) -> BytesIO: 303 | transformer = BBCodeTransformer(bedrock=res.motd.bedrock) 304 | # there're no line spacing in Text2Image since pil-utils 0.2.0 305 | # so we split lines there then manually add the space 306 | motd = [ 307 | transformer.transform(x) for x in split_motd_lines(trim_motd(res.motd.parsed)) 308 | ] 309 | online_percent = ( 310 | f"{res.players.online / res.players.max * 100:.2f}" 311 | if res.players.max 312 | else "?.??" 313 | ) 314 | 315 | mod_svr_type: str | None = None 316 | mod_list: list[str] | None = None 317 | if mod_info := res.raw.get("modinfo"): 318 | if tmp := mod_info.get("type"): 319 | mod_svr_type = tmp 320 | if tmp := (mod_info.get("mods") or mod_info.get("modList")): 321 | mod_list = format_mod_list(tmp) 322 | 323 | l_style = partial(ex_default_style, color_code="7") 324 | grid = ImageGrid(align_items=False) 325 | for line in motd: 326 | grid.append_line(line) 327 | if config.show_addr: 328 | grid.append_line(l_style("测试地址: "), addr) 329 | grid.append_line(l_style("服务端名: "), res.version.name) 330 | if mod_svr_type: 331 | grid.append_line(l_style("Mod 端类型: "), mod_svr_type) 332 | grid.append_line(l_style("协议版本: "), str(res.version.protocol)) 333 | grid.append_line( 334 | l_style("当前人数: "), 335 | f"{res.players.online}/{res.players.max} ({online_percent}%)", 336 | ) 337 | if mod_list: 338 | grid.append_line(l_style("Mod 总数: "), str(len(mod_list))) 339 | grid.append_line( 340 | l_style("聊天签名: "), 341 | "必需" if res.enforces_secure_chat else "无需", 342 | ) 343 | if config.show_delay: 344 | grid.append_line( 345 | l_style("测试延迟: "), 346 | ex_default_style(f"{res.latency:.2f}ms", get_latency_color(res.latency)), 347 | ) 348 | if mod_list and config.show_mods: 349 | grid.append_line( 350 | l_style("Mod 列表: "), 351 | ImageGrid.from_list(mod_list), 352 | ) 353 | if res.players.sample: 354 | grid.append_line( 355 | l_style("玩家列表: "), 356 | ImageGrid.from_list( 357 | [ 358 | transformer.transform(Motd.parse(x.name).parsed) 359 | for x in res.players.sample 360 | ], 361 | ), 362 | ) 363 | 364 | icon = ( 365 | BuildImage.open(BytesIO(base64.b64decode(res.icon.split(",")[-1]))) 366 | if res.icon 367 | else None 368 | ) 369 | return build_img(JE_HEADER, SUCCESS_TITLE, icon=icon, extra=grid) 370 | 371 | 372 | def draw_bedrock(res: "BedrockStatusResponse", addr: str) -> BytesIO: 373 | transformer = BBCodeTransformer(bedrock=res.motd.bedrock) 374 | motd = ( 375 | transformer.transform(x) for x in split_motd_lines(trim_motd(res.motd.parsed)) 376 | ) 377 | online_percent = ( 378 | f"{int(res.players.online) / int(res.players.max) * 100:.2f}" 379 | if res.players.max 380 | else "?.??" 381 | ) 382 | 383 | l_style = partial(ex_default_style, color_code="7") 384 | grid = ImageGrid(align_items=False) 385 | for line in motd: 386 | grid.append_line(line) 387 | if config.show_addr: 388 | grid.append_line(l_style("测试地址: "), addr) 389 | grid.append_line(l_style("协议版本: "), str(res.version.protocol)) 390 | grid.append_line(l_style("游戏版本: "), res.version.version) 391 | grid.append_line( 392 | l_style("当前人数: "), 393 | f"{res.players.online}/{res.players.max} ({online_percent}%)", 394 | ) 395 | if res.map_name: 396 | grid.append_line(l_style("存档名称: "), res.map_name) 397 | if res.gamemode: 398 | grid.append_line( 399 | l_style("游戏模式: "), 400 | GAME_MODE_MAP.get(res.gamemode, res.gamemode), 401 | ) 402 | if config.show_delay: 403 | grid.append_line( 404 | l_style("测试延迟: "), 405 | ex_default_style(f"{res.latency:.2f}ms", get_latency_color(res.latency)), 406 | ) 407 | 408 | return build_img(BE_HEADER, SUCCESS_TITLE, extra=grid) 409 | 410 | 411 | def parse_error(e: Exception) -> tuple[str, str]: 412 | if isinstance(e, TimeoutError): 413 | return "请求超时", "" 414 | if isinstance(e, socket.gaierror): 415 | return "域名解析失败", str(e) 416 | return DEFAULT_ERR_TITLE, f"{e.__class__.__name__}: {e}" 417 | 418 | 419 | def join_strings(*s: str, sp: str = ":") -> str: 420 | return sp.join(x for x in s if x) 421 | 422 | 423 | def draw_error( 424 | svr_type: ServerType, 425 | *e: Exception | tuple[str, Exception], 426 | title: str | None = None, 427 | ) -> BytesIO: 428 | if len(e) == 1 and (not title): 429 | title, extra = ( 430 | (x[0], join_strings(*parse_error(x[1]))) 431 | if isinstance((x := e[0]), tuple) 432 | else parse_error(x) 433 | ) 434 | extras = [extra] if extra else None 435 | else: 436 | extras = [ 437 | ( 438 | join_strings(x[0], *parse_error(x[1])) 439 | if isinstance(x, tuple) 440 | else join_strings(*parse_error(x)) 441 | ) 442 | for x in e 443 | ] 444 | if not title: 445 | title = DEFAULT_ERR_TITLE 446 | if extras: 447 | lines = [ 448 | ImageLine(ex_default_style(x).wrap((MIN_WIDTH * 1.5) - MARGIN * 2)) 449 | for x in extras 450 | ] 451 | extra_img = ImageGrid(*lines) 452 | else: 453 | extra_img = None 454 | return build_img(get_header_by_svr_type(svr_type), title, extra=extra_img) 455 | 456 | 457 | def draw_resp( 458 | resp: Union[JavaStatusResponse, "BedrockStatusResponse"], 459 | addr: str, 460 | ) -> BytesIO: 461 | if isinstance(resp, JavaStatusResponse): 462 | return draw_java(resp, addr) 463 | return draw_bedrock(resp, addr) 464 | 465 | 466 | def is_ipv6_unreachable_error(e: BaseException) -> bool: 467 | """Check if exception is due to IPv6 network being unreachable.""" 468 | if isinstance(e, OSError) and e.errno in ( 469 | errno.ENETUNREACH, 470 | errno.EHOSTUNREACH, 471 | errno.EADDRNOTAVAIL, 472 | ): 473 | return True 474 | if e.__cause__: 475 | return is_ipv6_unreachable_error(e.__cause__) 476 | return False 477 | 478 | 479 | async def draw(ip: str, svr_type: ServerType) -> BytesIO: 480 | async def _inner(t: ServerTypeRaw, *, resolve_dns_ipv6: bool | None = None) -> BytesIO: 481 | is_java = t == "je" 482 | host, port = await resolve_ip(ip, is_java, resolve_dns_ipv6=resolve_dns_ipv6) 483 | 484 | svr = JavaServer(host, port) if is_java else BedrockServer(host, port) 485 | kw = {"version": config.java_protocol_version} if is_java else {} 486 | if config.query_twice: 487 | await svr.async_status(**kw) # 第一次延迟通常不准 488 | resp = await svr.async_status(**kw) 489 | return draw_resp(resp, ip) 490 | 491 | async def _inner_with_fallback(t: ServerTypeRaw) -> BytesIO: 492 | # If IPv6 is disabled, just use IPv4 493 | if not config.resolve_dns_ipv6: 494 | return await _inner(t, resolve_dns_ipv6=False) 495 | 496 | # Try IPv6 first, fall back to IPv4 if unreachable 497 | try: 498 | return await _inner(t, resolve_dns_ipv6=True) 499 | except Exception as e: 500 | if is_ipv6_unreachable_error(e): 501 | logger.debug( 502 | f"IPv6 connection failed with {e.__class__.__name__}, " 503 | "falling back to IPv4", 504 | ) 505 | return await _inner(t, resolve_dns_ipv6=False) 506 | raise 507 | 508 | try: 509 | if not ip: 510 | return draw_help(svr_type) 511 | 512 | if svr_type != "auto": 513 | return await _inner_with_fallback(svr_type) 514 | 515 | # auto 516 | try: 517 | return await _inner_with_fallback("je") 518 | except Exception as e: 519 | logger.exception("获取JE服务器状态/画服务器状态图出错") 520 | je_exc = e 521 | try: 522 | return await _inner_with_fallback("be") 523 | except Exception as e: 524 | logger.exception("获取BE服务器状态/画服务器状态图出错") 525 | be_exc = e 526 | return draw_error( 527 | svr_type, 528 | ("JE", je_exc), 529 | ("BE", be_exc), 530 | title="所有尝试皆出错", 531 | ) 532 | 533 | except Exception as e: 534 | logger.exception("获取服务器状态/画服务器状态图出错") 535 | try: 536 | return draw_error(svr_type, e) 537 | except Exception: 538 | logger.exception("画异常状态图失败") 539 | raise 540 | --------------------------------------------------------------------------------