feat(mc-server-status): 服务器列表 Web 管理页 / 数据层抽离 / mcstatus 13 适配
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user