From 814c1994b69ff8e9c1d97b2677418e7c2d8dc76a Mon Sep 17 00:00:00 2001 From: sans Date: Tue, 22 Sep 2026 14:23:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(mc-server-status):=20=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E5=88=97=E8=A1=A8=20Web=20=E7=AE=A1=E7=90=86=E9=A1=B5?= =?UTF-8?q?=20/=20=E6=95=B0=E6=8D=AE=E5=B1=82=E6=8A=BD=E7=A6=BB=20/=20mcst?= =?UTF-8?q?atus=2013=20=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - services/servers.py:校验 + 读写 + 增删改收敛成一份数据层(顶层不 import nonebot, 可脱离运行时单测),群命令与 Web API 都只经它改数据,校验不再各写一套 - web_hub.py + /hub/mc_server_status 页面:按群列出服务器,抽屉新增/编辑(改名)/ 测试连接,行内删除(删空一台连带删群);每条路由自带 require_admin - handlers/server.py 改为薄封装,只在需要命令特有提示时自己判断 - services/mc.py 的图标属性名兼容 mcstatus ≥13 的 icon(旧版叫 favicon, 读错会把整条查询打成"查询失败") - tests/test_mc_servers.py:数据层归一/改名/删群/坏文件兜底 + 图标解码两版属性名 Co-Authored-By: Claude Code --- .../__init__.py | 13 + .../nonebot_plugin_mc_server_status/config.py | 13 +- .../handlers/server.py | 51 ++- .../services/mc.py | 27 +- .../services/servers.py | 252 +++++++++++++ .../web_hub.py | 159 ++++++++ .../src/plugins/mc_server_status/index.tsx | 339 +++++++++++++++++ requirements.txt | 2 +- tests/test_mc_servers.py | 340 ++++++++++++++++++ 9 files changed, 1150 insertions(+), 46 deletions(-) create mode 100644 hexi/plugins/nonebot_plugin_mc_server_status/services/servers.py create mode 100644 hexi/plugins/nonebot_plugin_mc_server_status/web_hub.py create mode 100644 hexi/web/src/plugins/mc_server_status/index.tsx create mode 100644 tests/test_mc_servers.py diff --git a/hexi/plugins/nonebot_plugin_mc_server_status/__init__.py b/hexi/plugins/nonebot_plugin_mc_server_status/__init__.py index 20e0f01..4e09670 100644 --- a/hexi/plugins/nonebot_plugin_mc_server_status/__init__.py +++ b/hexi/plugins/nonebot_plugin_mc_server_status/__init__.py @@ -38,4 +38,17 @@ __plugin_meta__ = PluginMetadata( """, ) +from . import web_hub as _web_hub # noqa: E402 from .handlers import add_server, del_server, list_all, test_server, xinxi # noqa: E402,F401 + +# 注册到统一 Web 管理台(/hub):hub 启动时挂载 /api/mc_server_status, +# 前端页面 hexi/web/src/plugins/mc_server_status/(服务器数据的增删改) +from hexi.web_hub.web_plugin_registry import register_web_plugin # noqa: E402 + +register_web_plugin( + "mc_server_status", + "MC服务器", + "server", + lambda: _web_hub.build_admin_app(), + module_name=__name__, +) diff --git a/hexi/plugins/nonebot_plugin_mc_server_status/config.py b/hexi/plugins/nonebot_plugin_mc_server_status/config.py index 364ce1b..7ee6727 100644 --- a/hexi/plugins/nonebot_plugin_mc_server_status/config.py +++ b/hexi/plugins/nonebot_plugin_mc_server_status/config.py @@ -1,4 +1,3 @@ -from json import dump, load from os import makedirs from pathlib import Path from typing import List, Optional @@ -7,6 +6,8 @@ from nonebot import get_bot, get_bots, get_driver, get_plugin_config from nonebot.adapters import Bot from pydantic import BaseModel +from .services import servers as store + # 数据目录:项目 hexi/data/ 下(与其它插件数据统一) DATA_DIR = Path(__file__).resolve().parents[2] / "data" @@ -40,18 +41,16 @@ async def on_startup(): save_file() else: load_file() + # 群命令与 Web 管理台都改 var.group_list,保存时整表落盘(见 services/servers.py) def load_file(): - with open(DATA_DIR / pc.mc_status_data_filename, "r", encoding="utf-8") as r: - tmp_data = load(r) - for i in tmp_data: - var.group_list[int(i)] = tmp_data[i] + """数据读写都在 services/servers.py(群命令与 Web 管理台共用同一份校验)。""" + var.group_list.update(store.load_servers(pc.mc_status_data_filename)) def save_file(): - with open(DATA_DIR / pc.mc_status_data_filename, "w", encoding="utf-8") as w: - dump(var.group_list, w, indent=4, ensure_ascii=False) + store.save_servers(pc.mc_status_data_filename, var.group_list) # qq机器人连接时执行 diff --git a/hexi/plugins/nonebot_plugin_mc_server_status/handlers/server.py b/hexi/plugins/nonebot_plugin_mc_server_status/handlers/server.py index a9ba02b..5fdb783 100644 --- a/hexi/plugins/nonebot_plugin_mc_server_status/handlers/server.py +++ b/hexi/plugins/nonebot_plugin_mc_server_status/handlers/server.py @@ -14,7 +14,8 @@ from nonebot.log import logger from nonebot.params import RegexGroup from nonebot.plugin import PluginMetadata -from ..config import pc, save_file, var +from ..config import pc, var +from ..services import servers as store from ..services.mc import check_mc_status __plugin_meta__ = PluginMetadata( @@ -76,18 +77,13 @@ async def _(mp=RegexGroup()): return group = int(mp[1]) new_server_name = mp[2] - server_host = mp[3] - server_type = mp[4].lower() - if server_type not in ["js", "bds"]: - await add_server.finish("类型请填js或bds") - if group not in var.group_list: - var.group_list[group] = {new_server_name: [server_host, server_type]} - else: - for server_name in var.group_list[group]: - if new_server_name == server_name: - await add_server.finish("有同名服务器啦!") - var.group_list[group][new_server_name] = [server_host, server_type] - save_file() + if new_server_name in var.group_list.get(group, {}): + await add_server.finish("有同名服务器啦!") + # 校验 + 落盘都在 services/servers.py(与 Web 管理台同一套) + try: + store.set_server(group, new_server_name, mp[3], mp[4]) + except store.ServerError as e: + await add_server.finish("类型请填js或bds" if "类型请填" in str(e) else str(e)) await add_server.finish("添加成功") @@ -96,18 +92,11 @@ async def _(mp=RegexGroup()): if not mp[0]: await del_server.finish(f"删除服务器 [群号] [名称]") return - group = int(mp[1]) - name = mp[2] - if group not in var.group_list: - await del_server.finish("这个群没有添加服务器") - if name in var.group_list[group]: - var.group_list[group].pop(name) - if not var.group_list[group]: - var.group_list.pop(group) - save_file() - await del_server.finish("删除成功") - else: - await del_server.finish("没找到该名称的服务器") + try: + store.delete_server(int(mp[1]), mp[2]) + except store.ServerError as e: + await del_server.finish(str(e)) + await del_server.finish("删除成功") @list_all.handle() @@ -129,9 +118,9 @@ async def _(mp=RegexGroup()): if not mp[0]: await test_server.finish("测试服务器 [服务器地址] [类型]\n类型写js或bds,js是Java服务器,bds是基岩服务器") return - server_host = mp[1] - server_type = mp[2].lower() - if server_type not in ["js", "bds"]: - await add_server.finish("类型请填js或bds") - msg = await check_mc_status("测试", server_host, server_type) - await list_all.finish(msg) + try: + server_type = store.normalize_type(mp[2]) + except store.ServerError as e: + await test_server.finish(str(e)) + msg = await check_mc_status("测试", mp[1], server_type) + await test_server.finish(msg) diff --git a/hexi/plugins/nonebot_plugin_mc_server_status/services/mc.py b/hexi/plugins/nonebot_plugin_mc_server_status/services/mc.py index 994e5b9..24d9710 100644 --- a/hexi/plugins/nonebot_plugin_mc_server_status/services/mc.py +++ b/hexi/plugins/nonebot_plugin_mc_server_status/services/mc.py @@ -5,15 +5,31 @@ from __future__ import annotations from base64 import b64decode from io import BytesIO from re import findall -from typing import Union from mcstatus import BedrockServer, JavaServer from nonebot.adapters.onebot.v11 import Message, MessageSegment as MS +def _server_icon(status) -> bytes | None: + """取服务器图标字节;没有图标或格式不认识就返回 None。 + + 属性名跨版本换过:mcstatus ≥13 叫 `icon`,11.x/12.x 叫 `favicon` + (13.0.0 的 changelog:`JavaStatusResponse.favicon` → `icon`,值没变)。 + 值可能是完整的 data URI,也可能是不带前缀的裸 base64,两种都认。 + """ + raw = getattr(status, "icon", None) or getattr(status, "favicon", None) + if not raw: + return None + data = raw.split("base64,", 1)[-1] if "base64," in raw else raw + try: + return b64decode(data) + except Exception: # noqa: BLE001 — 图标坏了不影响其它字段 + return None + + async def check_mc_status( name: str, host: str, server_type: str -) -> Union[str, Message]: +) -> str | Message: try: if server_type == "js": js = await JavaServer.async_lookup(host, timeout=2) @@ -38,11 +54,8 @@ async def check_mc_status( else: player_list = "没人在线" latency = round(status.latency) - if status.favicon: - aa, bb = status.favicon.split("base64,") - icon = MS.image(BytesIO(b64decode(bb))) + "\n" - else: - icon = "" + icon_data = _server_icon(status) + icon = MS.image(BytesIO(icon_data)) + "\n" if icon_data else "" msg = ( icon + f"名称:{name}\n版本:{version}\n在线:{online}\n延迟:{latency}ms\n在线列表:\n{player_list}" diff --git a/hexi/plugins/nonebot_plugin_mc_server_status/services/servers.py b/hexi/plugins/nonebot_plugin_mc_server_status/services/servers.py new file mode 100644 index 0000000..08a4f39 --- /dev/null +++ b/hexi/plugins/nonebot_plugin_mc_server_status/services/servers.py @@ -0,0 +1,252 @@ +"""MC 服务器状态 —— 服务器列表数据层(校验 + 读写 + 增删改)。 + +内存态就是 `config.var.group_list`,形态是 `{int 群号: {服务器名: [地址, 类型]}}` +(类型 `js`=Java / `bds`=基岩)。群命令(handlers/server.py)与 Web 管理台 +(web_hub.py)都只经这里改数据,校验只有这一份,两边不会各自跑偏。 + +本模块顶层**不 import nonebot**,运行态 config 到函数里再延迟 import +(见 `_runtime()`),这样数据层能脱离 NoneBot 运行时被单测加载。 +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +try: # 单测里可能拿不到 nonebot.logger,退化成标准库 + from nonebot.log import logger +except Exception: # noqa: BLE001 — 拿不到日志不影响数据层可用 + logger = logging.getLogger(__name__) + +#: 服务器类型 → 中文说明(排序即前端下拉顺序) +SERVER_TYPES: dict[str, str] = {"js": "Java 版", "bds": "基岩版"} + +#: 地址里写了端口才连得快,否则 Java 版要多探几轮(上游加的注释,保留) +ADDRESS_HINT = "地址带端口查得更快,如 mc.example.com:25565、mc.example.com:19132" + +MAX_NAME_LEN = 24 +MAX_GROUP_LEN = 12 +MAX_ADDRESS_LEN = 64 + + +class ServerError(ValueError): + """校验失败 / 目标不存在,`str(e)` 就是给用户看的话术。""" + + +def _runtime() -> Any: + """取运行态 `config` 模块(数据目录、配置、内存态都在那边)。 + + 到调用时才 import:本模块要能在"插件包还没初始化"的场景下被加载, + 只有真正读写数据时才需要 config(单测按包结构加载本模块,见 tests/test_mc_servers.py)。 + """ + from .. import config + + return config + + +# ───────────────────────── 校验 / 归一化 ───────────────────────── + + +def normalize_type(value: Any, default: str = "js") -> str: + """类型归一:`js`/`java` → js,`bds`/`be`/`bedrock` → bds;空值取 default。""" + if value is None or str(value).strip() == "": + return default + text = str(value).strip().lower() + if text in ("js", "java"): + return "js" + if text in ("bds", "be", "bedrock"): + return "bds" + raise ServerError("类型请填 js 或 bds(js = Java 版,bds = 基岩版)") + + +def normalize_address(value: Any) -> str: + """地址归一:去空白、去协议前缀与尾部斜杠;`bds` 默认端口 19132 不写进数据里。""" + text = str(value or "").strip() + if not text: + raise ServerError("服务器地址不能为空") + for prefix in ("minecraft://", "mc://", "http://", "https://"): + if text.lower().startswith(prefix): + text = text[len(prefix) :] + break + text = text.strip().strip("/").strip() + if not text: + raise ServerError("服务器地址不能为空") + if any(ch.isspace() for ch in text) or "/" in text: + raise ServerError("服务器地址里不能有空格或斜杠") + if len(text) > MAX_ADDRESS_LEN: + raise ServerError(f"服务器地址太长了(最多 {MAX_ADDRESS_LEN} 个字符)") + if text.startswith(":") or text.endswith(":"): + raise ServerError("服务器地址的端口不能为空") + return text + + +def normalize_name(value: Any) -> str: + """服务器名归一:去首尾空白、单双引号;名字里有空格是允许的。""" + text = str(value or "").strip() + if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'“”": + text = text[1:-1].strip() + if not text: + raise ServerError("服务器名称不能为空") + if len(text) > MAX_NAME_LEN: + raise ServerError(f"服务器名称太长了(最多 {MAX_NAME_LEN} 个字符)") + return text + + +def parse_group_id(value: Any) -> int: + """群号:必须是纯数字(DataStore/命令层都按 int 存)。""" + text = str(value if value is not None else "").strip() + if not text: + raise ServerError("群号不能为空") + if len(text) > MAX_GROUP_LEN: + raise ServerError("群号看上去不对(最多 12 位数字)") + if not text.isdigit(): + raise ServerError("群号必须是纯数字") + if not int(text): + raise ServerError("群号看上去不对") + return int(text) + + +# ───────────────────────── 数据读写 ───────────────────────── + + +def data_path(filename: str) -> Path: + return _runtime().DATA_DIR / filename + + +def load_servers(filename: str) -> dict[int, dict[str, list[str]]]: + """读数据文件;文件不存在/内容坏了按"空数据 + 警告"处理,绝不让插件起不来。""" + path = data_path(filename) + if not path.exists(): + return {} + try: + with open(path, encoding="utf-8") as f: + raw = json.load(f) + except Exception as e: # noqa: BLE001 + logger.warning(f"MC 服务器数据读取失败(按空数据处理):{path} {e!r}") + return {} + + data: dict[int, dict[str, list[str]]] = {} + if not isinstance(raw, dict): + logger.warning(f"MC 服务器数据格式不是对象,已忽略:{path}") + return data + for group_key, servers in raw.items(): + try: + group_id = int(group_key) + except (TypeError, ValueError): + logger.warning(f"MC 服务器数据里有个非数字群号,已忽略:{group_key!r}") + continue + if not isinstance(servers, dict): + continue + clean: dict[str, list[str]] = {} + for name, value in servers.items(): + if isinstance(value, (list, tuple)) and len(value) >= 2: + clean[str(name)] = [str(value[0]), str(value[1])] + data[group_id] = clean + return data + + +def save_servers(filename: str, data: dict[int, dict[str, list[str]]]) -> None: + """整表落盘(先写 .tmp 再替换,避免写一半断电留下半截 JSON)。""" + path = data_path(filename) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4, ensure_ascii=False) + tmp.replace(path) + + +def _state() -> tuple[Any, dict[int, dict[str, list[str]]]]: + config = _runtime() + return config, config.var.group_list + + +def snapshot() -> dict[int, dict[str, list[str]]]: + """整表深拷贝(给 Web 只读接口用,避免调用方误改内存态)。""" + _, groups = _state() + return {gid: {name: list(value) for name, value in servers.items()} for gid, servers in groups.items()} + + +def mutate(func, *args, **kwargs): + """校验通过后才落盘;冲突类错误不要走到这里(改内存前就该抛)。""" + config, groups = _state() + result = func(groups, *args, **kwargs) + save_servers(config.pc.mc_status_data_filename, groups) + return result + + +# ───────────────────────── 增删改 ───────────────────────── + + +def _upsert( + groups: dict[int, dict[str, list[str]]], + group_id: int, + old_name: str | None, + name: str, + address: str, + server_type: str, +) -> bool: + servers = groups.setdefault(group_id, {}) + if old_name and old_name != name: + # 改名:先查旧名在不在、新名会不会撞,确认没问题再动数据 + if old_name not in servers: + raise ServerError(f"找不到要改名的服务器「{old_name}」") + if name in servers: + raise ServerError(f"群里已经有一个叫「{name}」的服务器了") + servers.pop(old_name) + servers[name] = [address, server_type] + return True + + created = name not in servers + servers[name] = [address, server_type] + return created + + +def set_server( + group_id: int, + name: str, + address: str, + server_type: str, + old_name: str | None = None, +) -> bool: + """新增/更新一台服务器,返回是否新建。`old_name` 传了且与 name 不同即为改名。""" + group_id = parse_group_id(group_id) + name = normalize_name(name) + address = normalize_address(address) + server_type = normalize_type(server_type) + old = normalize_name(old_name) if old_name else None + return mutate(_upsert, group_id, old, name, address, server_type) + + +def _delete( + groups: dict[int, dict[str, list[str]]], group_id: int, name: str | None +) -> None: + if group_id not in groups: + raise ServerError(f"群 {group_id} 还没有配置服务器") + if name is None: # 整个群一起删 + groups.pop(group_id) + return + if name not in groups[group_id]: + raise ServerError(f"群里没有叫「{name}」的服务器") + groups[group_id].pop(name) + if not groups[group_id]: # 删空了就把群一起收掉,别留空壳 + groups.pop(group_id) + + +def delete_server(group_id: int, name: str) -> None: + """删掉某群里的一台服务器(删空了连带删群)。""" + group_id = parse_group_id(group_id) + mutate(_delete, group_id, normalize_name(name)) + + +def delete_group(group_id: int) -> None: + """删掉整个群的服务器配置。""" + group_id = parse_group_id(group_id) + mutate(_delete, group_id, None) + + +def lookup(group_id: int, name: str) -> list[str] | None: + """按群号 + 服务器名取 `[地址, 类型]`(给 Web 的「测试连接」用)。""" + _, groups = _state() + return groups.get(int(group_id), {}).get(name) diff --git a/hexi/plugins/nonebot_plugin_mc_server_status/web_hub.py b/hexi/plugins/nonebot_plugin_mc_server_status/web_hub.py new file mode 100644 index 0000000..eeb98f7 --- /dev/null +++ b/hexi/plugins/nonebot_plugin_mc_server_status/web_hub.py @@ -0,0 +1,159 @@ +"""MC 服务器信息查询 —— Web API 子应用(挂载到 /api/mc_server_status)。 + +服务器数据(`hexi/data/mc_status_data.json`:`{群号: {名字: [地址, 类型]}}`) +的唯一 Web 读写入口:列出 / 新增 / 编辑(含改名)/ 删除 / 测试连接。 +写操作全部落在 services/servers.py(校验只有那一份),和群里的 +`添加服务器`/`删除服务器` 命令改的是同一份内存态 + 同一个文件。 + +鉴权走 hexi.web_hub.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。 +**每条路由都要自带 `dependencies=[auth]`** —— mount 层没有兜底,漏一条就是匿名可访问。 +前端页面:hexi/web/src/plugins/mc_server_status/index.tsx。 +""" + +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.responses import JSONResponse + +from hexi.web_hub.web_auth import require_admin + +from .services import mc as mc_service +from .services import servers as store + +API = require_admin + + +def _ok(data=None, msg: str = "ok") -> JSONResponse: + return JSONResponse({"status": 0, "msg": msg, "data": data}) + + +def _fail(msg: str, status: int = 400) -> JSONResponse: + return JSONResponse({"status": status, "msg": msg}) + + +async def _group_names() -> dict[str, str]: + """群号 → 群名;拿不到 bot(未连接)时返回空表,前端只显示群号。""" + try: + from nonebot import get_adapter + from nonebot.adapters.onebot.v11 import Adapter + + bots = get_adapter(Adapter).bots + bot = next(iter(bots.values()), None) + if bot is None: + return {} + return { + str(g["group_id"]): g.get("group_name") or "" + for g in await bot.get_group_list() + } + except Exception: # noqa: BLE001 — 未连接/适配器未加载都按拿不到处理 + return {} + + +def _payload() -> dict: + """整表视图:群列表(含群名与在线标记)+ 类型选项。""" + groups = store.snapshot() + return { + "groups": [ + { + "group_id": str(gid), + "servers": [ + {"name": name, "address": value[0], "type": value[1]} + for name, value in sorted(servers.items()) + ], + } + for gid, servers in sorted(groups.items()) + ], + "types": store.SERVER_TYPES, + "address_hint": store.ADDRESS_HINT, + } + + +async def _payload_with_names() -> dict: + """跑一次 payload 再把群名补上(两条路由共用的收尾)。""" + data = _payload() + names = await _group_names() + for row in data["groups"]: + row["group_name"] = names.get(row["group_id"], "") + row["online"] = row["group_id"] in names + data["bot_online"] = bool(names) + return data + + +def build_admin_app() -> FastAPI | None: + """构建 MC 服务器数据管理 API 子应用(挂载到 /api/mc_server_status)。""" + app = FastAPI(title="MC Server Status API") + auth = require_admin + + @app.get("/overview", response_class=JSONResponse, dependencies=[auth]) + async def overview(): + """一次拿全:所有群 + 服务器列表 + 类型选项。""" + return _ok(await _payload_with_names()) + + @app.post("/server", response_class=JSONResponse, dependencies=[auth]) + async def save_server(data: dict): + """新增/更新一台服务器;带 old_name 且与 name 不同即为改名。""" + try: + group_id = store.parse_group_id(data.get("group_id")) + name = store.normalize_name(data.get("name")) + created = store.set_server( + group_id, + name, + str(data.get("address") or ""), + str(data.get("type") or ""), + old_name=str(data.get("old_name") or "").strip() or None, + ) + except store.ServerError as e: + return _fail(str(e)) + return _ok( + await _payload_with_names(), + f"服务器「{name}」已{'添加' if created else '保存'}", + ) + + @app.delete("/server/{group_id}/{name}", response_class=JSONResponse, dependencies=[auth]) + async def remove_server(group_id: str, name: str): + """删掉某群里的一台服务器(删空了连带删群)。""" + try: + store.delete_server(group_id, name) + except store.ServerError as e: + return _fail(str(e)) + return _ok(await _payload_with_names(), f"服务器「{name}」已删除") + + @app.delete("/group/{group_id}", response_class=JSONResponse, dependencies=[auth]) + async def remove_group(group_id: str): + """删掉整个群的服务器配置。""" + try: + store.delete_group(group_id) + except store.ServerError as e: + return _fail(str(e)) + return _ok(await _payload_with_names(), f"群 {group_id} 的服务器配置已删除") + + @app.post("/test", response_class=JSONResponse, dependencies=[auth]) + async def test_server(data: dict): + """测试连接:给了群号+名字就测存量那台,否则测临时填的地址(不落库)。 + + 对不上的地址走 mcstatus 自己的 2 秒超时,返回的是查询失败文案而不是报错。 + """ + group_id = str(data.get("group_id") or "").strip() + name = str(data.get("name") or "").strip() + address = str(data.get("address") or "").strip() + server_type = str(data.get("type") or "").strip() + + if group_id.isdigit() and name: + found = store.lookup(int(group_id), name) + if found: + address, server_type = found[0], found[1] + + if not address: + return _fail("先填服务器地址") + try: + address = store.normalize_address(address) + server_type = store.normalize_type(server_type) + except store.ServerError as e: + return _fail(str(e)) + + message = await mc_service.check_mc_status(name or "测试", address, server_type) + # check_mc_status 正常返回 str;带图标的 Java 服返回 Message(Web 侧只要纯文本) + text = str(message) if not isinstance(message, str) else message + return _ok({"ok": "查询失败" not in text, "message": text}, "测试完成") + + return app diff --git a/hexi/web/src/plugins/mc_server_status/index.tsx b/hexi/web/src/plugins/mc_server_status/index.tsx new file mode 100644 index 0000000..3c396dd --- /dev/null +++ b/hexi/web/src/plugins/mc_server_status/index.tsx @@ -0,0 +1,339 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Button, Card, Drawer, Input, Label, ListBox, Select, Spinner, TextField, toast, useOverlayState, +} from '@heroui/react' +import { Pencil, Plus, Signal, TrashBin } from '@gravity-ui/icons' +import { api, hubGroups } from '../../api/client' +import { PageHead, selectCls, tdCls, thCls } from '../../components/ui' + +// 服务器数据的增删改(后端 hexi/plugins/nonebot_plugin_mc_server_status/web_hub.py, +// 和群里的「添加服务器 / 删除服务器」命令改的是同一份数据) +const mcs = api('mc_server_status') + +function data(res: any) { + if (res && typeof res === 'object' && typeof res.status === 'number') { + if (res.status !== 0) throw new Error(res.msg || '请求失败') + return res.data + } + return res +} + +interface ServerRow { name: string; address: string; type: string } +interface GroupRow { + group_id: string + group_name: string + online: boolean + servers: ServerRow[] +} +interface Overview { + groups: GroupRow[] + types: Record + address_hint: string + bot_online: boolean +} +interface GroupOption { group_id: number; group_name: string } + +interface Draft { + group_id: string + /** 原服务器名:编辑时为改名前的名字,新增时为空 */ + old_name: string + name: string + address: string + type: string +} + +const TYPE_LABEL: Record = { js: 'Java 版', bds: '基岩版' } +// 跟后端 services/servers.py 的 SERVER_TYPES 对齐;接口没回来时用它兜底 +const FALLBACK_TYPES = ['js', 'bds'] +const EMPTY = '-' + +const typeLabel = (t: string) => TYPE_LABEL[t] || t + +export default function McServerStatusPage() { + const [ov, setOv] = useState(null) + const [err, setErr] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [testing, setTesting] = useState(false) + const [testResult, setTestResult] = useState('') + const [draft, setDraft] = useState(null) + const [botGroups, setBotGroups] = useState([]) + const drawer = useOverlayState() + + const load = useCallback(async () => { + setLoading(true); setErr('') + try { + setOv(await data(await mcs.get('overview'))) + } catch (e: any) { + setErr(e.message || '加载失败') + } finally { + setLoading(false) + } + }, []) + useEffect(() => { load() }, [load]) + // 群列表只用来给「群号」下拉填名字;bot 没连上时退回手输群号 + useEffect(() => { hubGroups().then(d => setBotGroups((d && d.items) || [])).catch(() => setBotGroups([])) }, []) + + const types = ov?.types && Object.keys(ov.types).length ? Object.keys(ov.types) : FALLBACK_TYPES + + const openNew = (group_id = '') => { + setTestResult('') + setDraft({ group_id, old_name: '', name: '', address: '', type: types[0] || 'js' }) + drawer.open() + } + const openEdit = (group_id: string, server: ServerRow) => { + setTestResult('') + setDraft({ group_id, old_name: server.name, name: server.name, address: server.address, type: server.type }) + drawer.open() + } + + const isNew = !draft?.old_name + const renamed = !!draft && !!draft.old_name && draft.old_name !== draft.name + + const save = async () => { + if (!draft) return + if (!/^\d+$/.test(draft.group_id)) { toast.danger('群号必须是纯数字'); return } + setSaving(true) + try { + const res = data(await mcs.post('server', { + group_id: draft.group_id, + old_name: draft.old_name, + name: draft.name, + address: draft.address, + type: draft.type, + })) + toast.success(res?.msg || '已保存') + drawer.close() + await load() + } catch (e: any) { toast.danger(e.message || '保存失败') } finally { setSaving(false) } + } + + /** 测试连接:编辑时测存量那台(后端按群号+名字取地址),新增时测当前填的地址,都不落库 */ + const testConnection = async () => { + if (!draft) return + setTesting(true); setTestResult('') + try { + const d = data(await mcs.post('test', { + group_id: draft.group_id, name: draft.old_name, address: draft.address, type: draft.type, + })) + const msg = (d && d.message) || '' + setTestResult(msg) + if (d && d.ok) { + const version = msg.match(/版本:(\S+)/) + toast.success(version ? `连上了 · 版本 ${version[1]}` : '连上了') + } else { + toast.danger(msg.split('\n')[0] || '连不上') + } + } catch (e: any) { + setTestResult(e.message || '测试失败') + toast.danger(e.message || '测试失败') + } finally { setTesting(false) } + } + + const removeServer = async (group: GroupRow, server: ServerRow) => { + const last = group.servers.length === 1 + const tip = last + ? `确定删除「${server.name}」?这是 ${group.group_name || group.group_id} 的最后一台,删完这个群也会一并移除。` + : `确定删除「${server.name}」(${server.address})?` + if (!window.confirm(tip)) return + try { + const res = data(await mcs.del(`server/${encodeURIComponent(group.group_id)}/${encodeURIComponent(server.name)}`)) + toast.success(res?.msg || '已删除') + await load() + } catch (e: any) { toast.danger(e.message || '删除失败') } + } + + const removeGroup = async (group: GroupRow) => { + if (!window.confirm(`确定删除 ${group.group_name || group.group_id} 的全部 ${group.servers.length} 台服务器配置?`)) return + try { + const res = data(await mcs.del(`group/${encodeURIComponent(group.group_id)}`)) + toast.success(res?.msg || '已删除') + await load() + } catch (e: any) { toast.danger(e.message || '删除失败') } + } + + if (loading && !ov) return
+ + return ( + <> +
+ + 按群配置服务器,「信息」命令查状态 + +
+ } + /> + {err &&

{err}

} + {ov && !ov.bot_online && ( +

bot 未连接,群名显示不出来(只显示群号,不影响编辑)

+ )} + + {ov && ( + + + + 服务器列表 + + {ov.groups.length} 个群 · {ov.groups.reduce((n, g) => n + g.servers.length, 0)} 台服务器 + + + + + + + + + + + + + + + + {ov.groups.map(g => g.servers.map((s, i) => ( + + {/* 群号 / 群名只在每组第一行显示,靠 rowSpan 合并不重复刷屏 */} + {i === 0 && ( + + )} + + + + + + )))} + {!ov.groups.length && ( + + + + )} + +
群服务器名称地址类型
+ + {g.group_name ? `${g.group_name}(${g.group_id})` : g.group_id} + + {!g.online && 不在群列表} +
+ + +
+
{s.name}{s.address || EMPTY}{typeLabel(s.type)} + + +
+ 还没有配置任何服务器,点右上角「新增服务器」(群里发「添加服务器」也一样) +
+
+
+ )} + + + + + + + + + {isNew ? '新增服务器' : `编辑「${draft?.old_name}」`} + + + + + {draft && ( +
+ + + {isNew && botGroups.length > 0 ? ( + + ) : ( + setDraft({ ...draft, group_id: e.target.value.trim() })} + disabled={!isNew} + placeholder="872490448" + /> + )} + + + + + setDraft({ ...draft, name: e.target.value })} + placeholder="EMDS" + /> + + + + + setDraft({ ...draft, address: e.target.value })} + placeholder="mc.example.com:25565" + /> + +

{ov?.address_hint}

+ + +

基岩版不写端口时按默认 19132 查

+ +
+ + + {isNew ? '测当前填的地址,不落库' : '测已保存的地址(改了地址先保存再测)'} + +
+ {testResult && ( +
+                        {testResult}
+                      
+ )} +
+ )} +
+ + + + +
+
+
+
+ + ) +} diff --git a/requirements.txt b/requirements.txt index 81aea37..c2561d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,7 +26,7 @@ cloudscraper>=1.2.71 # picfinder_take curl-cffi>=0.14.0 # bf_bot (BF6) lxml>=5.4.0 # picfinder_take beautifulsoup4>=4.13.3 # steam_info -mcstatus>=11.1.1 # mc_server_status +mcstatus>=13.0.1 # mc_server_status(13 把 JavaStatusResponse.favicon 改名成了 icon) # --- 文本 / 系统 --- jieba>=0.42.1 # learning_chat diff --git a/tests/test_mc_servers.py b/tests/test_mc_servers.py new file mode 100644 index 0000000..c5bc656 --- /dev/null +++ b/tests/test_mc_servers.py @@ -0,0 +1,340 @@ +"""MC 服务器数据层 (services/servers.py) 单元测试 + +servers.py 顶层不 import nonebot(运行态 config 是运行时才取),但 config.py +里有 `from .services import ...` 这类相对导入,所以这里**按包结构**用 importlib +加载(而不是像 test_video_policy.py 那样裸加载单文件):造一棵一次性的包骨架 +塞进 sys.modules,真正的插件包 `nonebot_plugin_mc_server_status` 全程没被 +NoneBot 加载过。 + +**每个用例重建一份 servers/config 模块**:增删改落在 `config.var.group_list` +上,直接复用进程里已有的 config 会把正在跑的 bot 的内存态改写掉(测试污染运行 +数据),所以每次都用全新的模块对象 + tmp_path 下的数据文件。 +""" + +import base64 +import importlib.util +import json +import sys +import types +from pathlib import Path + +import pytest + +_PLUGIN_DIR = ( + Path(__file__).resolve().parents[1] + / "hexi" + / "plugins" + / "nonebot_plugin_mc_server_status" +) +pytestmark = pytest.mark.skipif( + not (_PLUGIN_DIR / "config.py").exists(), reason="MC 插件不存在" +) + +_PKG = "mc_status_testpkg" +_SERVERS = f"{_PKG}.services.servers" +_CONFIG = f"{_PKG}.config" + + +def _load_by_path(name: str, path: Path): + spec = importlib.util.spec_from_file_location( + name, path, submodule_search_locations=[] if path.is_dir() else None + ) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +class _StubDriver: + """config.py 的 @driver.on_startup / on_bot_connect 只登记回调,测试里不触发。""" + + def __getattr__(self, _name): + def _decorator(*_args, **_kwargs): + return lambda func: func + + return _decorator + + +def _init_nonebot_stubs(): + """config.py 顶层要 get_driver()/get_plugin_config()/nonebot.log,没有运行时就得打桩。 + + 先真 `import nonebot` 再把这几个入口换掉(而不是塞一个假包进 sys.modules): + 假包会让 python 用它的 `__path__` 去找 `nonebot.adapters`,反而找不到真子模块。 + """ + import nonebot + + pkg = types.ModuleType("nonebot.log") + pkg.logger = types.SimpleNamespace( # type: ignore[attr-defined] + debug=lambda *a, **k: None, + info=lambda *a, **k: None, + warning=lambda *a, **k: None, + error=lambda *a, **k: None, + ) + saved = { + "get_driver": nonebot.get_driver, # type: ignore[attr-defined] + "get_plugin_config": nonebot.get_plugin_config, # type: ignore[attr-defined] + "get_bot": nonebot.get_bot, # type: ignore[attr-defined] + "get_bots": nonebot.get_bots, # type: ignore[attr-defined] + "log": sys.modules.get("nonebot.log"), + } + nonebot.get_driver = lambda: _StubDriver() # type: ignore[attr-defined] + nonebot.get_plugin_config = lambda *_a, **_k: types.SimpleNamespace( # type: ignore[attr-defined] + mc_status_data_filename="mc_status_data.json" + ) + nonebot.get_bot = lambda *_a, **_k: None # type: ignore[attr-defined] + nonebot.get_bots = lambda: {} # type: ignore[attr-defined] + sys.modules["nonebot.log"] = pkg + + def restore() -> None: + nonebot.get_driver = saved["get_driver"] # type: ignore[attr-defined] + nonebot.get_plugin_config = saved["get_plugin_config"] # type: ignore[attr-defined] + nonebot.get_bot = saved["get_bot"] # type: ignore[attr-defined] + nonebot.get_bots = saved["get_bots"] # type: ignore[attr-defined] + if saved["log"] is None: + sys.modules.pop("nonebot.log", None) + else: + sys.modules["nonebot.log"] = saved["log"] + + return restore + + +@pytest.fixture +def mcs(tmp_path): + """一套干净的数据层:一次性包骨架 + 全新 servers/config 模块 + 临时数据文件。""" + pkg = types.ModuleType(_PKG) + pkg.__path__ = [str(_PLUGIN_DIR)] # type: ignore[attr-defined] + sys.modules[_PKG] = pkg + + services = types.ModuleType(f"{_PKG}.services") + services.__path__ = [str(_PLUGIN_DIR / "services")] # type: ignore[attr-defined] + sys.modules[f"{_PKG}.services"] = services + + restore = _init_nonebot_stubs() + try: + module = _load_by_path(_SERVERS, _PLUGIN_DIR / "services" / "servers.py") + runtime = _load_by_path(_CONFIG, _PLUGIN_DIR / "config.py") + finally: + restore() + + # config.py 的 on_startup 不在测试里跑,内存态与数据文件名手动准备 + runtime.var.group_list.clear() + runtime.pc.mc_status_data_filename = str(tmp_path / "mc_status_data.json") + try: + yield module + finally: + for name in (_SERVERS, _CONFIG, f"{_PKG}.services", _PKG): + sys.modules.pop(name, None) + + +@pytest.fixture +def groups(mcs): + return mcs._runtime().var.group_list + + +@pytest.fixture +def mc_module(): + """加载 services/mc.py(只测纯函数 _server_icon,不联网)。 + + mc.py 在模块顶层 import 了 OneBot 的消息类型,这里给个占位桩 —— 测试只关心 + 图标解码,不会真的构造 Message。 + """ + pkg = types.ModuleType(_PKG) + pkg.__path__ = [str(_PLUGIN_DIR)] # type: ignore[attr-defined] + sys.modules[_PKG] = pkg + services = types.ModuleType(f"{_PKG}.services") + services.__path__ = [str(_PLUGIN_DIR / "services")] # type: ignore[attr-defined] + sys.modules[f"{_PKG}.services"] = services + + adapter = types.ModuleType("nonebot.adapters.onebot.v11") + adapter.Message = type("Message", (), {}) + adapter.MessageSegment = types.SimpleNamespace(image=lambda *_a, **_k: object()) + saved = sys.modules.get("nonebot.adapters.onebot.v11") + sys.modules["nonebot.adapters.onebot.v11"] = adapter + try: + module = _load_by_path( + f"{_PKG}.services.mc", _PLUGIN_DIR / "services" / "mc.py" + ) + finally: + if saved is None: + sys.modules.pop("nonebot.adapters.onebot.v11", None) + else: + sys.modules["nonebot.adapters.onebot.v11"] = saved + try: + yield module + finally: + for name in (f"{_PKG}.services.mc", f"{_PKG}.services", _PKG): + sys.modules.pop(name, None) + + +# ───────────────────────── 服务器图标(mcstatus 跨版本) ───────────────────────── + +_PNG = b"\x89PNG\r\n\x1a\n" + b"fake-image-bytes" + + +def test_server_icon_accepts_new_and_old_attribute_names(mc_module): + """mcstatus ≥13 是 icon,11/12 是 favicon —— 两种都要认(线上 13.0.1 踩过一次)。""" + b64 = base64.b64encode(_PNG).decode() + new_status = types.SimpleNamespace(icon=f"data:image/png;base64,{b64}") + old_status = types.SimpleNamespace(favicon=f"data:image/png;base64,{b64}") + assert mc_module._server_icon(new_status) == _PNG + assert mc_module._server_icon(old_status) == _PNG + + +def test_server_icon_handles_bare_base64_and_missing(mc_module): + b64 = base64.b64encode(_PNG).decode() + assert mc_module._server_icon(types.SimpleNamespace(icon=b64)) == _PNG # 无 data URI 前缀 + assert mc_module._server_icon(types.SimpleNamespace(icon=None)) is None + assert mc_module._server_icon(types.SimpleNamespace()) is None # 两个属性都没有 + assert mc_module._server_icon(types.SimpleNamespace(icon="不是 base64!!")) is None + + +def _file(mcs) -> dict: + return json.loads(Path(mcs._runtime().pc.mc_status_data_filename).read_text(encoding="utf-8")) + + +# ───────────────────────── 校验 / 归一化 ───────────────────────── + + +def test_normalize_type_aliases_and_errors(mcs): + assert mcs.normalize_type("js") == "js" + assert mcs.normalize_type("JS") == "js" + assert mcs.normalize_type(" Java ") == "js" + assert mcs.normalize_type("bds") == "bds" + assert mcs.normalize_type("bedrock") == "bds" + assert mcs.normalize_type("") == "js" # 空值取默认 + assert mcs.normalize_type(None, "bds") == "bds" + with pytest.raises(mcs.ServerError): + mcs.normalize_type("java版") + + +def test_normalize_address_strips_scheme_and_rejects_junk(mcs): + assert mcs.normalize_address(" mc.example.com:25565 ") == "mc.example.com:25565" + assert mcs.normalize_address("minecraft://mc.example.com") == "mc.example.com" + assert mcs.normalize_address("http://mc.example.com/") == "mc.example.com" + for bad in ("", " ", "mc example.com", "mc.example.com/foo", ":25565", "host:"): + with pytest.raises(mcs.ServerError): + mcs.normalize_address(bad) + + +def test_normalize_name_keeps_spaces_and_strips_quotes(mcs): + assert mcs.normalize_name("亡者世界") == "亡者世界" + assert mcs.normalize_name(" 我 的 服 ") == "我 的 服" + assert mcs.normalize_name('"EMDS"') == "EMDS" + with pytest.raises(mcs.ServerError): + mcs.normalize_name(" ") + with pytest.raises(mcs.ServerError): + mcs.normalize_name("x" * (mcs.MAX_NAME_LEN + 2)) + + +def test_parse_group_id(mcs): + assert mcs.parse_group_id("872490448") == 872490448 + assert mcs.parse_group_id(304484502) == 304484502 + for bad in ("", " ", "abc", "872490448.0", "0", 1234567890123): + with pytest.raises(mcs.ServerError): + mcs.parse_group_id(bad) + + +# ───────────────────────── 增 / 改 ───────────────────────── + + +def test_add_writes_file_and_memory(mcs, groups): + assert mcs.set_server(872490448, "EMDS", "sansenhoshi.top:29985", "js") is True + assert groups[872490448]["EMDS"] == ["sansenhoshi.top:29985", "js"] + assert _file(mcs) == {"872490448": {"EMDS": ["sansenhoshi.top:29985", "js"]}} + + +def test_update_same_name_keeps_position(mcs, groups): + mcs.set_server(872490448, "EMDS", "a.example.com:1", "js") + mcs.set_server(872490448, "星门", "b.example.com:2", "bds") + assert mcs.set_server(872490448, "EMDS", "c.example.com:3", "js") is False # 同名 = 更新 + assert list(groups[872490448]) == ["EMDS", "星门"] # 顺序不变 + assert groups[872490448]["EMDS"] == ["c.example.com:3", "js"] + + +def test_rename_moves_entry(mcs, groups): + mcs.set_server(872490448, "旧名", "mc.example.com:25565", "js") + assert mcs.set_server(872490448, "新名", "mc.example.com:25565", "js", old_name="旧名") is True + assert list(groups[872490448]) == ["新名"] + assert _file(mcs)["872490448"]["新名"] == ["mc.example.com:25565", "js"] + + +def test_rename_conflict_is_rejected_without_touching_data(mcs, groups): + mcs.set_server(872490448, "A", "a.example.com:1", "js") + mcs.set_server(872490448, "B", "b.example.com:2", "js") + with pytest.raises(mcs.ServerError): + mcs.set_server(872490448, "B", "c.example.com:3", "js", old_name="A") + assert groups[872490448] == {"A": ["a.example.com:1", "js"], "B": ["b.example.com:2", "js"]} + + +def test_rename_missing_source_is_rejected(mcs, groups): + mcs.set_server(872490448, "A", "a.example.com:1", "js") + with pytest.raises(mcs.ServerError): + mcs.set_server(872490448, "B", "b.example.com:2", "js", old_name="不存在") + assert list(groups[872490448]) == ["A"] + + +def test_invalid_input_does_not_write_file(mcs, groups, tmp_path): + with pytest.raises(mcs.ServerError): + mcs.set_server(872490448, "A", "a.example.com:1", "不是类型") + assert groups == {} + assert not (tmp_path / "mc_status_data.json").exists() + + +# ───────────────────────── 删 ───────────────────────── + + +def test_delete_server_and_cleanup_empty_group(mcs, groups): + mcs.set_server(872490448, "A", "a.example.com:1", "js") + mcs.set_server(872490448, "B", "b.example.com:2", "js") + mcs.set_server(304484502, "C", "c.example.com:3", "bds") + + mcs.delete_server(872490448, "A") + assert list(groups[872490448]) == ["B"] + mcs.delete_server(872490448, "B") + assert 872490448 not in groups # 删空连带删群 + assert _file(mcs) == {"304484502": {"C": ["c.example.com:3", "bds"]}} + + +def test_delete_unknown_targets(mcs, groups): + with pytest.raises(mcs.ServerError): + mcs.delete_server(872490448, "没有这个群") + mcs.set_server(872490448, "A", "a.example.com:1", "js") + with pytest.raises(mcs.ServerError): + mcs.delete_server(872490448, "没有这台") + + +def test_delete_group(mcs, groups): + mcs.set_server(872490448, "A", "a.example.com:1", "js") + mcs.delete_group(872490448) + assert groups == {} + with pytest.raises(mcs.ServerError): + mcs.delete_group(872490448) + + +# ───────────────────────── 读 ───────────────────────── + + +def test_load_servers_handles_missing_broken_and_partial(mcs, tmp_path): + path = tmp_path / "mc_status_data.json" + assert mcs.load_servers(str(path)) == {} # 文件不存在 + + path.write_text("{ 坏掉的 json", encoding="utf-8") + assert mcs.load_servers(str(path)) == {} # 坏文件不炸 + + path.write_text(json.dumps({"8123": {"A": ["a:1", "js"], "B": "不是数组"}}), encoding="utf-8") + assert mcs.load_servers(str(path)) == {8123: {"A": ["a:1", "js"]}} + + +def test_snapshot_is_a_copy(mcs, groups): + mcs.set_server(872490448, "A", "a.example.com:1", "js") + snap = mcs.snapshot() + snap[872490448]["A"][0] = "改坏了" + snap[872490448]["新"] = ["x:1", "js"] + assert groups[872490448] == {"A": ["a.example.com:1", "js"]} + + +def test_lookup(mcs, groups): + mcs.set_server(872490448, "A", "a.example.com:1", "js") + assert mcs.lookup(872490448, "A") == ["a.example.com:1", "js"] + assert mcs.lookup(872490448, "没有这台") is None + assert mcs.lookup(999, "A") is None