Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/nonebot_plugin_mimo_console/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
48 changes: 46 additions & 2 deletions src/nonebot_plugin_mimo_console/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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})
Expand Down
47 changes: 47 additions & 0 deletions src/nonebot_plugin_mimo_console/disabled.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 2 additions & 0 deletions src/nonebot_plugin_mimo_console/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
44 changes: 39 additions & 5 deletions src/nonebot_plugin_mimo_console/static/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -425,14 +425,17 @@ function renderPlugins() {
}

function loadedPluginHtml(item, index) {
const statusBadge = item.disabled
? '<span class="badge disabled">已禁用</span>'
: '<span class="badge loaded">运行中</span>';
return `<article class="plugin-card" data-detail-source="loaded" data-detail-index="${index}" tabindex="0" role="button">
<div class="plugin-card-head">
${pluginAvatarHtml(item)}
<div class="plugin-title">
<h3>${escapeHtml(item.title)}</h3>
<div class="module">${escapeHtml(item.module)}</div>
</div>
<span class="badge loaded">运行中</span>
${statusBadge}
</div>
<p class="desc">${escapeHtml(item.description)}</p>
<div class="plugin-meta">
Expand Down Expand Up @@ -584,7 +587,9 @@ function openLoadedDetail(item) {
setDetailAvatar(item);
$("#detail-title").textContent = item.title || item.name;
$("#detail-module").textContent = item.module || "";
$("#detail-badges").innerHTML = '<span class="badge loaded">运行中</span>';
$("#detail-badges").innerHTML = item.disabled
? '<span class="badge disabled">已禁用</span>'
: '<span class="badge loaded">运行中</span>';
const homepage = safeUrl(item.homepage);
$("#detail-body").innerHTML = `
<div class="detail-section">
Expand All @@ -604,12 +609,41 @@ function openLoadedDetail(item) {
${item.path ? `<div class="detail-section"><h3>路径</h3><p class="detail-desc mono">${escapeHtml(item.path)}</p></div>` : ""}
${homepage ? `<div class="detail-section"><h3>链接</h3><div class="detail-links"><a href="${homepage}" target="_blank" rel="noreferrer"><span>主页 / 文档</span><span>↗</span></a></div></div>` : ""}
`;
$("#detail-actions").innerHTML = homepage
? `<a class="btn btn-primary" href="${homepage}" target="_blank" rel="noreferrer">打开主页</a>`
: `<button class="btn btn-ghost" type="button" disabled>无主页</button>`;
const isSelf = item.module === "nonebot_plugin_mimo_console";
const toggleButton = isSelf
? ""
: `<button class="btn ${item.disabled ? "btn-secondary" : "btn-danger"}" id="detail-toggle-disabled" type="button">${item.disabled ? "启用插件" : "禁用插件"}</button>`;
$("#detail-actions").innerHTML = [
homepage ? `<a class="btn btn-primary" href="${homepage}" target="_blank" rel="noreferrer">打开主页</a>` : "",
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";
Expand Down
5 changes: 5 additions & 0 deletions src/nonebot_plugin_mimo_console/static/assets/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions src/nonebot_plugin_mimo_console/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./assets/styles.css?v=0.6.3">
<link rel="stylesheet" href="./assets/styles.css?v=0.6.4">
</head>
<body>
<div class="bg-glow glow-a"></div>
Expand Down Expand Up @@ -115,7 +115,7 @@ <h2 id="auth-title">欢迎回来</h2>
</div>
<button id="logout-button" class="icon-btn logout-btn" title="退出登录"></button>
</div>
<div class="version">Mimo Console · v0.1.3</div>
<div class="version">Mimo Console · v0.1.5</div>
</div>
</aside>

Expand Down Expand Up @@ -517,6 +517,6 @@ <h2 id="detail-title">插件详情</h2>
</div>

<div id="toast-stack" class="toast-stack" aria-live="polite"></div>
<script src="./assets/app.js?v=0.6.2" defer></script>
<script src="./assets/app.js?v=0.6.3" defer></script>
</body>
</html>
64 changes: 56 additions & 8 deletions src/nonebot_plugin_mimo_console/version.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading