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:
2026-09-22 14:23:46 +08:00
co-authored by Claude Code
parent 4badcfcf32
commit 814c1994b6
9 changed files with 1150 additions and 46 deletions
@@ -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 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 os import makedirs
from pathlib import Path from pathlib import Path
from typing import List, Optional 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 nonebot.adapters import Bot
from pydantic import BaseModel from pydantic import BaseModel
from .services import servers as store
# 数据目录:项目 hexi/data/ 下(与其它插件数据统一) # 数据目录:项目 hexi/data/ 下(与其它插件数据统一)
DATA_DIR = Path(__file__).resolve().parents[2] / "data" DATA_DIR = Path(__file__).resolve().parents[2] / "data"
@@ -40,18 +41,16 @@ async def on_startup():
save_file() save_file()
else: else:
load_file() load_file()
# 群命令与 Web 管理台都改 var.group_list,保存时整表落盘(见 services/servers.py)
def load_file(): def load_file():
with open(DATA_DIR / pc.mc_status_data_filename, "r", encoding="utf-8") as r: """数据读写都在 services/servers.py(群命令与 Web 管理台共用同一份校验)。"""
tmp_data = load(r) var.group_list.update(store.load_servers(pc.mc_status_data_filename))
for i in tmp_data:
var.group_list[int(i)] = tmp_data[i]
def save_file(): def save_file():
with open(DATA_DIR / pc.mc_status_data_filename, "w", encoding="utf-8") as w: store.save_servers(pc.mc_status_data_filename, var.group_list)
dump(var.group_list, w, indent=4, ensure_ascii=False)
# qq机器人连接时执行 # qq机器人连接时执行
@@ -14,7 +14,8 @@ from nonebot.log import logger
from nonebot.params import RegexGroup from nonebot.params import RegexGroup
from nonebot.plugin import PluginMetadata 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 from ..services.mc import check_mc_status
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
@@ -76,18 +77,13 @@ async def _(mp=RegexGroup()):
return return
group = int(mp[1]) group = int(mp[1])
new_server_name = mp[2] new_server_name = mp[2]
server_host = mp[3] if new_server_name in var.group_list.get(group, {}):
server_type = mp[4].lower() await add_server.finish("有同名服务器啦!")
if server_type not in ["js", "bds"]: # 校验 + 落盘都在 services/servers.py(与 Web 管理台同一套)
await add_server.finish("类型请填js或bds") try:
if group not in var.group_list: store.set_server(group, new_server_name, mp[3], mp[4])
var.group_list[group] = {new_server_name: [server_host, server_type]} except store.ServerError as e:
else: await add_server.finish("类型请填js或bds" if "类型请填" in str(e) else str(e))
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()
await add_server.finish("添加成功") await add_server.finish("添加成功")
@@ -96,18 +92,11 @@ async def _(mp=RegexGroup()):
if not mp[0]: if not mp[0]:
await del_server.finish(f"删除服务器 [群号] [名称]") await del_server.finish(f"删除服务器 [群号] [名称]")
return return
group = int(mp[1]) try:
name = mp[2] store.delete_server(int(mp[1]), mp[2])
if group not in var.group_list: except store.ServerError as e:
await del_server.finish("这个群没有添加服务器") await del_server.finish(str(e))
if name in var.group_list[group]: await del_server.finish("删除成功")
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("没找到该名称的服务器")
@list_all.handle() @list_all.handle()
@@ -129,9 +118,9 @@ async def _(mp=RegexGroup()):
if not mp[0]: if not mp[0]:
await test_server.finish("测试服务器 [服务器地址] [类型]\n类型写js或bds,js是Java服务器,bds是基岩服务器") await test_server.finish("测试服务器 [服务器地址] [类型]\n类型写js或bds,js是Java服务器,bds是基岩服务器")
return return
server_host = mp[1] try:
server_type = mp[2].lower() server_type = store.normalize_type(mp[2])
if server_type not in ["js", "bds"]: except store.ServerError as e:
await add_server.finish("类型请填js或bds") await test_server.finish(str(e))
msg = await check_mc_status("测试", server_host, server_type) msg = await check_mc_status("测试", mp[1], server_type)
await list_all.finish(msg) await test_server.finish(msg)
@@ -5,15 +5,31 @@ from __future__ import annotations
from base64 import b64decode from base64 import b64decode
from io import BytesIO from io import BytesIO
from re import findall from re import findall
from typing import Union
from mcstatus import BedrockServer, JavaServer from mcstatus import BedrockServer, JavaServer
from nonebot.adapters.onebot.v11 import Message, MessageSegment as MS 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( async def check_mc_status(
name: str, host: str, server_type: str name: str, host: str, server_type: str
) -> Union[str, Message]: ) -> str | Message:
try: try:
if server_type == "js": if server_type == "js":
js = await JavaServer.async_lookup(host, timeout=2) js = await JavaServer.async_lookup(host, timeout=2)
@@ -38,11 +54,8 @@ async def check_mc_status(
else: else:
player_list = "没人在线" player_list = "没人在线"
latency = round(status.latency) latency = round(status.latency)
if status.favicon: icon_data = _server_icon(status)
aa, bb = status.favicon.split("base64,") icon = MS.image(BytesIO(icon_data)) + "\n" if icon_data else ""
icon = MS.image(BytesIO(b64decode(bb))) + "\n"
else:
icon = ""
msg = ( msg = (
icon icon
+ f"名称:{name}\n版本:{version}\n在线:{online}\n延迟:{latency}ms\n在线列表:\n{player_list}" + 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
View File
@@ -26,7 +26,7 @@ cloudscraper>=1.2.71 # picfinder_take
curl-cffi>=0.14.0 # bf_bot (BF6) curl-cffi>=0.14.0 # bf_bot (BF6)
lxml>=5.4.0 # picfinder_take lxml>=5.4.0 # picfinder_take
beautifulsoup4>=4.13.3 # steam_info 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 jieba>=0.42.1 # learning_chat
+340
View File
@@ -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