From f9bf4f71b82cbed063b3408d7963e2dbd7ac7d73 Mon Sep 17 00:00:00 2001 From: yiwuerxin <72354522+yiwuerxin@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:56:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=92=E4=BB=B6=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=A6=81=E7=94=A8/=E5=90=AF=E7=94=A8?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=EF=BC=8CCNB=20=E9=95=9C=E5=83=8F=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20git=20=E6=B5=85=E5=85=8B=E9=9A=86=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增禁用插件功能:插件卡片与详情抽屉显示「运行中/已禁用」徽标, 详情抽屉内可一键禁用/启用;基于 run_preprocessor 抛 IgnoredException 在响应器级拦截事件,后台任务不受影响; 禁用列表持久化于 localstore 的 disabled_plugins.json, 重启后仍生效;控制台自身禁止禁用 - 新增 PUT /api/plugins/disabled 接口与 DisabledStore 存储, GET /api/plugins 返回 disabled 标记 - 修复 CNB 镜像版本检测:CNB 无匿名 raw 直链,改用 git 浅克隆 (--depth 1 --filter=blob:none --sparse)读取 pyproject.toml, 镜像连通性测试改用 git ls-remote,实测可正确返回版本号 - 版本号 0.1.3 -> 0.1.5,新增 tests/test_disabled.py --- pyproject.toml | 4 +- src/nonebot_plugin_mimo_console/__init__.py | 17 +++++ src/nonebot_plugin_mimo_console/api.py | 48 +++++++++++++- src/nonebot_plugin_mimo_console/disabled.py | 47 +++++++++++++ src/nonebot_plugin_mimo_console/state.py | 2 + .../static/assets/app.js | 44 +++++++++++-- .../static/assets/styles.css | 5 ++ .../static/index.html | 6 +- src/nonebot_plugin_mimo_console/version.py | 64 +++++++++++++++--- tests/test_disabled.py | 66 +++++++++++++++++++ tests/test_version.py | 46 +++++++++++-- uv.lock | 2 +- 12 files changed, 326 insertions(+), 25 deletions(-) create mode 100644 src/nonebot_plugin_mimo_console/disabled.py create mode 100644 tests/test_disabled.py diff --git a/pyproject.toml b/pyproject.toml index a0eddc6..6708c13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nonebot-plugin-mimo-console" -version = "0.1.3" +version = "0.1.5" description = "A glass-style WebUI management console for NoneBot2" readme = "README.md" requires-python = ">=3.10" @@ -54,7 +54,7 @@ requires = ["uv_build>=0.10.0,<0.12.0"] build-backend = "uv_build" [tool.bumpversion] -current_version = "0.1.3" +current_version = "0.1.5" commit = true message = "chore: 发布 v{new_version}" tag = true diff --git a/src/nonebot_plugin_mimo_console/__init__.py b/src/nonebot_plugin_mimo_console/__init__.py index badad3c..6c15de8 100644 --- a/src/nonebot_plugin_mimo_console/__init__.py +++ b/src/nonebot_plugin_mimo_console/__init__.py @@ -5,12 +5,16 @@ from fastapi.staticfiles import StaticFiles from nonebot import get_app, get_driver, get_plugin_config, logger, on_command, require +from nonebot.exception import IgnoredException +from nonebot.matcher import Matcher +from nonebot.message import run_preprocessor from nonebot.permission import SUPERUSER from nonebot.plugin import PluginMetadata from .api import create_router from .background import BackgroundStore from .config import ConsoleConfig +from .disabled import DisabledStore from .log_buffer import LogBuffer from .security import AuthStore from .state import ConsoleState @@ -56,8 +60,21 @@ default_url=console_config.mimo_console_background_url, ), release_cache=LatestReleaseCache(), + disabled=DisabledStore( + localstore.get_plugin_data_file("disabled_plugins.json"), + protected=("nonebot_plugin_mimo_console",), + ), ) + +@run_preprocessor +async def _disabled_plugin_guard(matcher: Matcher) -> None: + """被控制台禁用的插件,其响应器直接忽略事件(后台任务不受影响)。""" + plugin_name = matcher.plugin_name or "" + if plugin_name and console_state.disabled.is_disabled(plugin_name): + raise IgnoredException(f"插件 {plugin_name} 已被 Mimo Console 禁用") + + app = get_app() app.include_router(create_router(console_state), prefix=console_path) app.mount( diff --git a/src/nonebot_plugin_mimo_console/api.py b/src/nonebot_plugin_mimo_console/api.py index 405a6a2..b88634b 100644 --- a/src/nonebot_plugin_mimo_console/api.py +++ b/src/nonebot_plugin_mimo_console/api.py @@ -24,7 +24,9 @@ GITHUB_PROXY_PRESETS, PACKAGE_NAME, get_installed_version, + is_mirror_repo, normalize_github_proxy, + probe_mirror_repo, resolve_git_url, resolve_version_url, ) @@ -53,6 +55,11 @@ class PluginActionBody(BaseModel): action: Literal["install", "update", "uninstall"] +class PluginDisabledBody(BaseModel): + plugin: str = Field(min_length=1, max_length=128) + disabled: bool + + class GithubProxyBody(BaseModel): proxy: str = Field(default="", max_length=512) @@ -156,7 +163,33 @@ async def dashboard( async def plugins( session: Annotated[Session, Depends(require_session)], ) -> dict[str, Any]: - return {"items": await asyncio.to_thread(plugin_snapshot)} + disabled = state.disabled.names + items = await asyncio.to_thread(plugin_snapshot) + for item in items: + item["disabled"] = item["name"] in disabled + return {"items": items} + + @router.put("/api/plugins/disabled") + async def set_plugin_disabled( + body: PluginDisabledBody, + session: Annotated[Session, Depends(require_session)], + ) -> dict[str, Any]: + name = body.plugin.strip() + loaded = {item["name"] for item in await asyncio.to_thread(plugin_snapshot)} + if name not in loaded: + raise HTTPException(status_code=404, detail="插件未加载或不存在") + try: + await asyncio.to_thread(state.disabled.set, name, body.disabled) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + action = "禁用" if body.disabled else "启用" + logger.warning(f"[Mimo Console] 已{action}插件:{name}") + return { + "ok": True, + "plugin": name, + "disabled": body.disabled, + "disabled_plugins": sorted(state.disabled.names), + } @router.get("/api/store/plugins") async def store_plugins( @@ -367,8 +400,19 @@ async def test_github_proxy( proxy = normalize_github_proxy(body.proxy) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - url = resolve_version_url(proxy) started = time.perf_counter() + if is_mirror_repo(proxy): + # 镜像仓库无 raw 直链,用 git ls-remote 探测可达性 + reachable = await probe_mirror_repo(proxy) + latency = int((time.perf_counter() - started) * 1000) + if not reachable: + return { + "ok": False, + "latency_ms": None, + "detail": "镜像仓库无法通过 git 匿名访问", + } + return {"ok": True, "latency_ms": latency, "detail": ""} + url = resolve_version_url(proxy) try: async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client: response = await client.get(url, headers={"User-Agent": PACKAGE_NAME}) diff --git a/src/nonebot_plugin_mimo_console/disabled.py b/src/nonebot_plugin_mimo_console/disabled.py new file mode 100644 index 0000000..27ab90c --- /dev/null +++ b/src/nonebot_plugin_mimo_console/disabled.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +class DisabledStore: + """被禁用插件名的持久化存储(JSON 数组文件)。""" + + def __init__(self, data_file: Path, protected: tuple[str, ...] = ()) -> None: + self.data_file = data_file + self._protected = set(protected) + self._names: set[str] = self._load() + + def _load(self) -> set[str]: + try: + data = json.loads(self.data_file.read_text(encoding="utf-8")) + except (OSError, ValueError): + return set() + if not isinstance(data, list): + return set() + return {str(name) for name in data if name} + + def _save(self) -> None: + self.data_file.parent.mkdir(parents=True, exist_ok=True) + temp = self.data_file.with_suffix(self.data_file.suffix + ".tmp") + temp.write_text( + json.dumps(sorted(self._names), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + temp.replace(self.data_file) + + @property + def names(self) -> set[str]: + return set(self._names) + + def is_disabled(self, name: str) -> bool: + return name in self._names + + def set(self, name: str, disabled: bool) -> None: + if name in self._protected: + raise ValueError("不能禁用控制台自身") + if disabled: + self._names.add(name) + else: + self._names.discard(name) + self._save() diff --git a/src/nonebot_plugin_mimo_console/state.py b/src/nonebot_plugin_mimo_console/state.py index afb3400..55dc4ad 100644 --- a/src/nonebot_plugin_mimo_console/state.py +++ b/src/nonebot_plugin_mimo_console/state.py @@ -5,6 +5,7 @@ from .background import BackgroundStore from .config import ConsoleConfig +from .disabled import DisabledStore from .log_buffer import LogBuffer from .security import AuthStore from .store import PluginStore @@ -21,5 +22,6 @@ class ConsoleState: store: PluginStore background: BackgroundStore release_cache: LatestReleaseCache + disabled: DisabledStore setup_token: str | None = None log_sink_id: int | None = None diff --git a/src/nonebot_plugin_mimo_console/static/assets/app.js b/src/nonebot_plugin_mimo_console/static/assets/app.js index d708aa7..7c77242 100644 --- a/src/nonebot_plugin_mimo_console/static/assets/app.js +++ b/src/nonebot_plugin_mimo_console/static/assets/app.js @@ -425,6 +425,9 @@ function renderPlugins() { } function loadedPluginHtml(item, index) { + const statusBadge = item.disabled + ? '已禁用' + : '运行中'; return `
${pluginAvatarHtml(item)} @@ -432,7 +435,7 @@ function loadedPluginHtml(item, index) {

${escapeHtml(item.title)}

${escapeHtml(item.module)}
- 运行中 + ${statusBadge}

${escapeHtml(item.description)}

@@ -584,7 +587,9 @@ function openLoadedDetail(item) { setDetailAvatar(item); $("#detail-title").textContent = item.title || item.name; $("#detail-module").textContent = item.module || ""; - $("#detail-badges").innerHTML = '运行中'; + $("#detail-badges").innerHTML = item.disabled + ? '已禁用' + : '运行中'; const homepage = safeUrl(item.homepage); $("#detail-body").innerHTML = `
@@ -604,12 +609,41 @@ function openLoadedDetail(item) { ${item.path ? `

路径

${escapeHtml(item.path)}

` : ""} ${homepage ? `` : ""} `; - $("#detail-actions").innerHTML = homepage - ? `打开主页` - : ``; + const isSelf = item.module === "nonebot_plugin_mimo_console"; + const toggleButton = isSelf + ? "" + : ``; + $("#detail-actions").innerHTML = [ + homepage ? `打开主页` : "", + toggleButton, + ].join(""); + const toggle = $("#detail-toggle-disabled"); + if (toggle) toggle.addEventListener("click", () => togglePluginDisabled(item)); showDetail(true); } +async function togglePluginDisabled(item) { + const next = !item.disabled; + if ( + next + && !window.confirm( + `确定禁用插件「${item.title || item.name}」?\n禁用后它的响应器不再处理消息(插件后台任务不受影响),可随时重新启用。`, + ) + ) return; + try { + await api("/plugins/disabled", { + method: "PUT", + body: JSON.stringify({ plugin: item.name, disabled: next }), + }); + item.disabled = next; + renderPlugins(); + openLoadedDetail(item); + toast(next ? `已禁用 ${item.title || item.name}` : `已启用 ${item.title || item.name}`); + } catch (error) { + toast(error.message, "error"); + } +} + async function openStoreDetail(item) { if (!item) return; state.detailSource = "store"; diff --git a/src/nonebot_plugin_mimo_console/static/assets/styles.css b/src/nonebot_plugin_mimo_console/static/assets/styles.css index fbcb826..d537448 100644 --- a/src/nonebot_plugin_mimo_console/static/assets/styles.css +++ b/src/nonebot_plugin_mimo_console/static/assets/styles.css @@ -1077,6 +1077,11 @@ a { color: inherit; text-decoration: none; } background: rgba(110, 217, 168, 0.1); color: var(--green); } +.badge.disabled { + border: 1px solid var(--line); + background: var(--surface-soft); + color: var(--muted); +} .badge.official { border: 1px solid rgba(117, 139, 153, 0.2); background: rgba(117, 139, 153, 0.1); diff --git a/src/nonebot_plugin_mimo_console/static/index.html b/src/nonebot_plugin_mimo_console/static/index.html index a52b7ea..10e5cf7 100644 --- a/src/nonebot_plugin_mimo_console/static/index.html +++ b/src/nonebot_plugin_mimo_console/static/index.html @@ -23,7 +23,7 @@ - +
@@ -115,7 +115,7 @@

欢迎回来

-
Mimo Console · v0.1.3
+
Mimo Console · v0.1.5
@@ -517,6 +517,6 @@

插件详情

- + diff --git a/src/nonebot_plugin_mimo_console/version.py b/src/nonebot_plugin_mimo_console/version.py index 9fede5b..47e264e 100644 --- a/src/nonebot_plugin_mimo_console/version.py +++ b/src/nonebot_plugin_mimo_console/version.py @@ -1,7 +1,10 @@ from __future__ import annotations +import asyncio import re +import tempfile import time +from pathlib import Path from typing import Any import httpx @@ -69,14 +72,53 @@ def resolve_git_url(proxy: str) -> str: def resolve_version_url(proxy: str) -> str: - """按加速配置返回版本检测实际读取的 pyproject 地址。""" + """按加速配置返回版本检测实际读取的 pyproject 地址(仅前缀代理;镜像仓库走 git 协议)。""" prefix = normalize_github_proxy(proxy) - if not prefix: - return MASTER_PYPROJECT_URL - if is_mirror_repo(prefix): - repo = prefix.removesuffix(".git") - return f"{repo}/-/raw/master/pyproject.toml" - return f"{prefix}/{MASTER_PYPROJECT_URL}" + return f"{prefix}/{MASTER_PYPROJECT_URL}" if prefix else MASTER_PYPROJECT_URL + + +async def run_git(args: list[str], timeout: int) -> tuple[int, bytes]: + """运行 git 子进程,返回 (returncode, 输出);异常或超时返回 (-1, b"")。""" + try: + process = await asyncio.create_subprocess_exec( + *args, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + output, _ = await asyncio.wait_for(process.communicate(), timeout=timeout) + except (OSError, asyncio.TimeoutError): + return -1, b"" + return process.returncode or 0, output + + +async def probe_mirror_repo(repo_url: str, timeout: int = 10) -> bool: + """git ls-remote 探测镜像仓库是否匿名可达。""" + code, _ = await run_git(["git", "ls-remote", repo_url, "HEAD"], timeout) + return code == 0 + + +async def fetch_mirror_version(repo_url: str, timeout: int = 30) -> str: + """镜像仓库没有 GitHub 式 raw 直链,用稀疏浅克隆只拉 pyproject.toml 读版本号。""" + with tempfile.TemporaryDirectory(prefix="mimo-mirror-") as tmp: + code, _ = await run_git( + ["git", "clone", "--depth", "1", "--filter=blob:none", "--sparse", repo_url, tmp], + timeout, + ) + if code != 0: + return "" + code, _ = await run_git( + ["git", "-C", tmp, "sparse-checkout", "set", "--no-cone", "/pyproject.toml"], + timeout, + ) + if code != 0: + return "" + try: + text = Path(tmp, "pyproject.toml").read_text(encoding="utf-8") + except OSError: + return "" + match = _PYPROJECT_VERSION_RE.search(text) + return match.group(1) if match else "" def get_installed_version() -> str: @@ -121,7 +163,13 @@ async def fetch(self, force: bool = False, proxy: str = "") -> str: fresh = self._latest != "" and time.time() - self._fetched_at < self.cache_seconds if fresh and not force: return self._latest - url = resolve_version_url(proxy) + prefix = normalize_github_proxy(proxy) + if is_mirror_repo(prefix): + # 镜像仓库无 raw 直链,走 git 浅克隆;失败保留上次缓存的版本号 + self._latest = await fetch_mirror_version(prefix) or self._latest + self._fetched_at = time.time() + return self._latest + url = resolve_version_url(prefix) try: async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: response = await client.get( diff --git a/tests/test_disabled.py b/tests/test_disabled.py new file mode 100644 index 0000000..f8c7a81 --- /dev/null +++ b/tests/test_disabled.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "src" / "nonebot_plugin_mimo_console" / "disabled.py" +spec = importlib.util.spec_from_file_location("mimo_console_disabled_test", MODULE_PATH) +if spec is None or spec.loader is None: + raise RuntimeError("cannot load disabled module") +disabled_module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = disabled_module +spec.loader.exec_module(disabled_module) + +DisabledStore = disabled_module.DisabledStore + + +class DisabledStoreTests(unittest.TestCase): + def _store(self, protected: tuple[str, ...] = ()) -> tuple[DisabledStore, Path]: + tmp = Path(tempfile.mkdtemp(prefix="mimo-disabled-test-")) + return DisabledStore(tmp / "disabled_plugins.json", protected=protected), tmp + + def test_empty_by_default(self) -> None: + store, _ = self._store() + self.assertEqual(store.names, set()) + self.assertFalse(store.is_disabled("some_plugin")) + + def test_set_and_persist(self) -> None: + store, tmp = self._store() + store.set("some_plugin", True) + self.assertTrue(store.is_disabled("some_plugin")) + reloaded = DisabledStore(tmp / "disabled_plugins.json") + self.assertTrue(reloaded.is_disabled("some_plugin")) + + def test_enable_removes_name(self) -> None: + store, _ = self._store() + store.set("some_plugin", True) + store.set("some_plugin", False) + self.assertFalse(store.is_disabled("some_plugin")) + + def test_protected_rejected(self) -> None: + store, _ = self._store(protected=("nonebot_plugin_mimo_console",)) + with self.assertRaises(ValueError): + store.set("nonebot_plugin_mimo_console", True) + self.assertFalse(store.is_disabled("nonebot_plugin_mimo_console")) + + def test_corrupt_file_treated_as_empty(self) -> None: + store, tmp = self._store() + (tmp / "disabled_plugins.json").write_text("not json", encoding="utf-8") + reloaded = DisabledStore(tmp / "disabled_plugins.json") + self.assertEqual(reloaded.names, set()) + + def test_saved_file_is_sorted_json_list(self) -> None: + store, tmp = self._store() + store.set("b_plugin", True) + store.set("a_plugin", True) + data = json.loads((tmp / "disabled_plugins.json").read_text(encoding="utf-8")) + self.assertEqual(data, ["a_plugin", "b_plugin"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_version.py b/tests/test_version.py index d52de86..2c2a4f9 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,7 +1,10 @@ from __future__ import annotations +import asyncio import importlib.util +import subprocess import sys +import tempfile import unittest from pathlib import Path @@ -22,6 +25,8 @@ is_mirror_repo = version.is_mirror_repo resolve_git_url = version.resolve_git_url resolve_version_url = version.resolve_version_url +fetch_mirror_version = version.fetch_mirror_version +probe_mirror_repo = version.probe_mirror_repo class NormalizeTagTests(unittest.TestCase): @@ -136,11 +141,44 @@ def test_resolve_version_url_prefix(self) -> None: f"https://gh-proxy.com/{version.MASTER_PYPROJECT_URL}", ) - def test_resolve_version_url_mirror(self) -> None: - self.assertEqual( - resolve_version_url(version.CNB_MIRROR_REPO), - f"{version.CNB_MIRROR_REPO}/-/raw/master/pyproject.toml", + +class MirrorRepoTests(unittest.TestCase): + def _make_repo(self, version_text: str) -> str: + """建一个本地 git 仓库冒充镜像,返回其路径(git clone 可直接用)。""" + repo = tempfile.mkdtemp(prefix="mimo-test-repo-") + subprocess.run(["git", "init", "-q", repo], check=True) + Path(repo, "pyproject.toml").write_text( + f'[project]\nversion = "{version_text}"\n', encoding="utf-8" + ) + subprocess.run(["git", "-C", repo, "add", "pyproject.toml"], check=True) + subprocess.run( + [ + "git", + "-C", + repo, + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-qm", + "init", + ], + check=True, ) + return repo + + def test_fetch_mirror_version_reads_pyproject(self) -> None: + repo = self._make_repo("9.9.9") + self.assertEqual(asyncio.run(fetch_mirror_version(repo)), "9.9.9") + + def test_fetch_mirror_version_bad_repo_returns_empty(self) -> None: + self.assertEqual(asyncio.run(fetch_mirror_version("/nonexistent/repo", timeout=10)), "") + + def test_probe_mirror_repo(self) -> None: + repo = self._make_repo("1.0.0") + self.assertTrue(asyncio.run(probe_mirror_repo(repo))) + self.assertFalse(asyncio.run(probe_mirror_repo("/nonexistent/repo", timeout=10))) if __name__ == "__main__": diff --git a/uv.lock b/uv.lock index 1d925e1..5faf152 100644 --- a/uv.lock +++ b/uv.lock @@ -832,7 +832,7 @@ wheels = [ [[package]] name = "nonebot-plugin-mimo-console" -version = "0.1.3" +version = "0.1.5" source = { editable = "." } dependencies = [ { name = "httpx" },