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:
@@ -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__,
|
||||
)
|
||||
|
||||
@@ -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机器人连接时执行
|
||||
|
||||
@@ -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:
|
||||
if new_server_name in var.group_list.get(group, {}):
|
||||
await add_server.finish("有同名服务器啦!")
|
||||
var.group_list[group][new_server_name] = [server_host, server_type]
|
||||
save_file()
|
||||
# 校验 + 落盘都在 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()
|
||||
try:
|
||||
store.delete_server(int(mp[1]), mp[2])
|
||||
except store.ServerError as e:
|
||||
await del_server.finish(str(e))
|
||||
await del_server.finish("删除成功")
|
||||
else:
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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<string, string>
|
||||
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<string, string> = { 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<Overview | null>(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<Draft | null>(null)
|
||||
const [botGroups, setBotGroups] = useState<GroupOption[]>([])
|
||||
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 <div className="flex justify-center py-20"><Spinner /></div>
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<PageHead
|
||||
title="MC服务器"
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-slate-400">按群配置服务器,「信息」命令查状态</span>
|
||||
<Button size="sm" onPress={() => openNew()}>
|
||||
<Plus className="size-4" />新增服务器
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{err && <p className="text-xs text-red-500">{err}</p>}
|
||||
{ov && !ov.bot_online && (
|
||||
<p className="text-xs text-amber-600">bot 未连接,群名显示不出来(只显示群号,不影响编辑)</p>
|
||||
)}
|
||||
|
||||
{ov && (
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header>
|
||||
<Card.Title>
|
||||
服务器列表
|
||||
<span className="ml-2 text-xs font-normal text-slate-400">
|
||||
{ov.groups.length} 个群 · {ov.groups.reduce((n, g) => n + g.servers.length, 0)} 台服务器
|
||||
</span>
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={thCls}>群</th>
|
||||
<th className={thCls}>服务器名称</th>
|
||||
<th className={thCls}>地址</th>
|
||||
<th className={thCls}>类型</th>
|
||||
<th className={thCls}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ov.groups.map(g => g.servers.map((s, i) => (
|
||||
<tr key={`${g.group_id}-${s.name}`} className="transition-colors hover:bg-slate-50">
|
||||
{/* 群号 / 群名只在每组第一行显示,靠 rowSpan 合并不重复刷屏 */}
|
||||
{i === 0 && (
|
||||
<td className={tdCls + ' align-top whitespace-nowrap'} rowSpan={g.servers.length}>
|
||||
<span className="text-slate-700">
|
||||
{g.group_name ? `${g.group_name}(${g.group_id})` : g.group_id}
|
||||
</span>
|
||||
{!g.online && <span className="ml-2 text-xs text-slate-400">不在群列表</span>}
|
||||
<div className="mt-1 flex gap-2">
|
||||
<button className="text-xs text-indigo-600 hover:underline" onClick={() => openNew(g.group_id)}>加服务器</button>
|
||||
<button className="text-xs text-red-500 hover:underline" onClick={() => removeGroup(g)}>删整组</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td className={tdCls}>{s.name}</td>
|
||||
<td className={tdCls + ' font-mono text-xs'}>{s.address || EMPTY}</td>
|
||||
<td className={tdCls + ' whitespace-nowrap'}>{typeLabel(s.type)}</td>
|
||||
<td className={tdCls + ' whitespace-nowrap text-right'}>
|
||||
<Button size="sm" variant="tertiary" onPress={() => openEdit(g.group_id, s)}>
|
||||
<Pencil className="size-4" />编辑
|
||||
</Button>
|
||||
<Button size="sm" variant="tertiary" className="text-red-500" onPress={() => removeServer(g, s)}>
|
||||
<TrashBin className="size-4" />删除
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)))}
|
||||
{!ov.groups.length && (
|
||||
<tr>
|
||||
<td colSpan={5} className={tdCls + ' text-center text-slate-400'}>
|
||||
还没有配置任何服务器,点右上角「新增服务器」(群里发「添加服务器」也一样)
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Drawer state={drawer}>
|
||||
<Drawer.Backdrop>
|
||||
<Drawer.Content placement="right">
|
||||
<Drawer.Dialog className="w-[560px] max-w-[88vw]">
|
||||
<Drawer.Header>
|
||||
<Drawer.Heading>
|
||||
{isNew ? '新增服务器' : `编辑「${draft?.old_name}」`}
|
||||
</Drawer.Heading>
|
||||
<Drawer.CloseTrigger />
|
||||
</Drawer.Header>
|
||||
<Drawer.Body>
|
||||
{draft && (
|
||||
<div className="space-y-5">
|
||||
<TextField>
|
||||
<Label>群号</Label>
|
||||
{isNew && botGroups.length > 0 ? (
|
||||
<Select
|
||||
selectedKey={draft.group_id}
|
||||
onSelectionChange={k => k && setDraft({ ...draft, group_id: String(k) })}
|
||||
>
|
||||
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>
|
||||
{botGroups.map(g => (
|
||||
<ListBox.Item key={g.group_id} id={String(g.group_id)}>
|
||||
{`${g.group_name}(${g.group_id})`}
|
||||
</ListBox.Item>
|
||||
))}
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={draft.group_id}
|
||||
onChange={e => setDraft({ ...draft, group_id: e.target.value.trim() })}
|
||||
disabled={!isNew}
|
||||
placeholder="872490448"
|
||||
/>
|
||||
)}
|
||||
</TextField>
|
||||
|
||||
<TextField>
|
||||
<Label>服务器名称 <span className="text-xs text-slate-400">群里显示的名字,改这里就是改名</span></Label>
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={e => setDraft({ ...draft, name: e.target.value })}
|
||||
placeholder="EMDS"
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<TextField>
|
||||
<Label>服务器地址 <span className="text-xs text-slate-400">主机:端口</span></Label>
|
||||
<Input
|
||||
value={draft.address}
|
||||
onChange={e => setDraft({ ...draft, address: e.target.value })}
|
||||
placeholder="mc.example.com:25565"
|
||||
/>
|
||||
</TextField>
|
||||
<p className="-mt-3 text-xs text-slate-400">{ov?.address_hint}</p>
|
||||
|
||||
<Select selectedKey={draft.type} onSelectionChange={k => k && setDraft({ ...draft, type: String(k) })}>
|
||||
<Label>类型</Label>
|
||||
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>
|
||||
{types.map(t => <ListBox.Item key={t} id={t}>{typeLabel(t)}</ListBox.Item>)}
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
<p className="-mt-3 text-xs text-slate-400">基岩版不写端口时按默认 19132 查</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button size="sm" variant="tertiary" isDisabled={testing} onPress={testConnection}>
|
||||
<Signal className="size-4" />{testing ? '测试中…' : '测试连接'}
|
||||
</Button>
|
||||
<span className="text-xs text-slate-400">
|
||||
{isNew ? '测当前填的地址,不落库' : '测已保存的地址(改了地址先保存再测)'}
|
||||
</span>
|
||||
</div>
|
||||
{testResult && (
|
||||
<pre className="max-h-44 overflow-auto whitespace-pre-wrap rounded-lg bg-slate-50 p-3 text-xs text-slate-600">
|
||||
{testResult}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer.Body>
|
||||
<Drawer.Footer>
|
||||
<Button variant="tertiary" onPress={drawer.close}>取消</Button>
|
||||
<Button isDisabled={saving} onPress={save}>
|
||||
{saving ? '保存中…' : renamed ? '保存(改名)' : '保存'}
|
||||
</Button>
|
||||
</Drawer.Footer>
|
||||
</Drawer.Dialog>
|
||||
</Drawer.Content>
|
||||
</Drawer.Backdrop>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user