diff --git a/pyproject.toml b/pyproject.toml index 08c3a4b..a0eddc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nonebot-plugin-mimo-console" -version = "0.1.2" +version = "0.1.3" 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.2" +current_version = "0.1.3" commit = true message = "chore: 发布 v{new_version}" tag = true diff --git a/src/nonebot_plugin_mimo_console/api.py b/src/nonebot_plugin_mimo_console/api.py index d179977..405a6a2 100644 --- a/src/nonebot_plugin_mimo_console/api.py +++ b/src/nonebot_plugin_mimo_console/api.py @@ -7,6 +7,7 @@ from collections import defaultdict, deque from typing import Annotated, Any, Literal +import httpx from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, status from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -19,7 +20,14 @@ from .security import AuthError, Session from .state import ConsoleState from .store import StoreError, _clean_output, build_self_update_command -from .version import PACKAGE_GIT_URL, PACKAGE_NAME, get_installed_version +from .version import ( + GITHUB_PROXY_PRESETS, + PACKAGE_NAME, + get_installed_version, + normalize_github_proxy, + resolve_git_url, + resolve_version_url, +) class SetupBody(BaseModel): @@ -45,6 +53,10 @@ class PluginActionBody(BaseModel): action: Literal["install", "update", "uninstall"] +class GithubProxyBody(BaseModel): + proxy: str = Field(default="", max_length=512) + + class AttemptLimiter: def __init__(self, limit: int = 10, window: int = 300) -> None: self.limit = limit @@ -312,9 +324,65 @@ async def system_version( session: Annotated[Session, Depends(require_session)], force: bool = Query(default=False), ) -> dict[str, Any]: - await state.release_cache.fetch(force=force) + await state.release_cache.fetch(force=force, proxy=state.config.mimo_console_github_proxy) return state.release_cache.snapshot(get_installed_version()) + @router.get("/api/system/github-proxy") + async def get_github_proxy( + session: Annotated[Session, Depends(require_session)], + ) -> dict[str, Any]: + return { + "proxy": state.config.mimo_console_github_proxy, + "presets": list(GITHUB_PROXY_PRESETS), + } + + @router.put("/api/system/github-proxy") + async def set_github_proxy( + body: GithubProxyBody, + session: Annotated[Session, Depends(require_session)], + ) -> dict[str, Any]: + try: + proxy = normalize_github_proxy(body.proxy) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + environment = str(getattr(get_driver().config, "environment", "prod")) + path = locate_env_file(state.config.project_root(), environment) + try: + await asyncio.to_thread( + update_env, path, {"MIMO_CONSOLE_GITHUB_PROXY": proxy}, state.backup_dir + ) + except (OSError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + # 热更新内存配置,无需重启即可生效 + state.config.mimo_console_github_proxy = proxy + logger.info(f"[Mimo Console] GitHub 加速已设置为:{proxy or '直连'}") + return {"ok": True, "proxy": proxy} + + @router.post("/api/system/github-proxy/test") + async def test_github_proxy( + body: GithubProxyBody, + session: Annotated[Session, Depends(require_session)], + ) -> dict[str, Any]: + try: + 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() + try: + async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client: + response = await client.get(url, headers={"User-Agent": PACKAGE_NAME}) + latency = int((time.perf_counter() - started) * 1000) + except (httpx.HTTPError, OSError) as exc: + return {"ok": False, "latency_ms": None, "detail": f"连接失败:{exc}"} + if response.status_code != 200: + return { + "ok": False, + "latency_ms": latency, + "detail": f"加速地址返回 HTTP {response.status_code}", + } + return {"ok": True, "latency_ms": latency, "detail": ""} + @router.post("/api/system/update") async def system_update( session: Annotated[Session, Depends(require_session)], @@ -324,7 +392,7 @@ async def system_update( command = build_self_update_command( state.config.project_root(), PACKAGE_NAME, - PACKAGE_GIT_URL, + resolve_git_url(state.config.mimo_console_github_proxy), ) env = os.environ.copy() env.update({"NO_COLOR": "1", "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"}) diff --git a/src/nonebot_plugin_mimo_console/config.py b/src/nonebot_plugin_mimo_console/config.py index 73b1a47..a7a693d 100644 --- a/src/nonebot_plugin_mimo_console/config.py +++ b/src/nonebot_plugin_mimo_console/config.py @@ -4,6 +4,8 @@ from pydantic import BaseModel, Field, field_validator +from .version import normalize_github_proxy + class ConsoleConfig(BaseModel): """NoneBot 环境变量中的控制台配置。""" @@ -16,6 +18,7 @@ class ConsoleConfig(BaseModel): mimo_console_store_cache_seconds: int = Field(default=600, ge=60, le=86400) mimo_console_package_timeout: int = Field(default=300, ge=60, le=1800) mimo_console_background_url: str | None = None + mimo_console_github_proxy: str = "" @field_validator("mimo_console_path") @classmethod @@ -23,5 +26,10 @@ def normalize_path(cls, value: str) -> str: path = "/" + value.strip().strip("/") return path if path != "/" else "/mimo-console" + @field_validator("mimo_console_github_proxy") + @classmethod + def normalize_proxy(cls, value: str) -> str: + return normalize_github_proxy(value) + def project_root(self) -> Path: return (self.mimo_console_project_root or Path.cwd()).expanduser().resolve() diff --git a/src/nonebot_plugin_mimo_console/static/assets/app.js b/src/nonebot_plugin_mimo_console/static/assets/app.js index acaa0de..d708aa7 100644 --- a/src/nonebot_plugin_mimo_console/static/assets/app.js +++ b/src/nonebot_plugin_mimo_console/static/assets/app.js @@ -28,6 +28,7 @@ const state = { background: { source: "default", url: "" }, theme: null, version: null, + proxy: { proxy: "", presets: [] }, }; const $ = (selector, context = document) => context.querySelector(selector); @@ -186,6 +187,8 @@ function bindEvents() { $("#theme-reset").addEventListener("click", resetTheme); $("#restart-button").addEventListener("click", restartNonebot); $("#check-update-btn").addEventListener("click", checkUpdate); + $("#proxy-save").addEventListener("click", saveProxySettings); + $("#proxy-test-btn").addEventListener("click", testProxySettings); $$(".view-toggle button").forEach((button) => button.addEventListener("click", () => { $$(".view-toggle button").forEach((item) => item.classList.remove("active")); button.classList.add("active"); @@ -242,7 +245,7 @@ function enterApp(username) { clearTimers(); state.timers.push(setInterval(() => loadDashboard(false), 5000)); state.timers.push(setInterval(loadLogs, 2000)); - Promise.allSettled([loadDashboard(), loadPlugins(), loadLogs(), loadBackground(), loadVersion()]); + Promise.allSettled([loadDashboard(), loadPlugins(), loadLogs(), loadBackground(), loadVersion(), loadProxySettings()]); } async function signOut(requestLogout) { @@ -1146,6 +1149,106 @@ function renderVersion(data) { $("#version-current").textContent = data.current ? `v${data.current}` : "--"; } +const PROXY_CUSTOM_VALUE = "__custom__"; + +function presetLabel(url) { + return url.includes("cnb.cool") ? "CNB 镜像仓库" : "GitHub 加速代理"; +} + +async function loadProxySettings() { + try { + state.proxy = await api("/system/github-proxy"); + renderProxySettings(); + } catch (_) { /* 读取失败不影响使用 */ } +} + +function renderProxySettings() { + const container = $("#proxy-modes"); + if (!container) return; + const presets = state.proxy.presets || []; + const current = state.proxy.proxy || ""; + const options = [ + { value: "", title: "不使用 GitHub 加速", desc: "直连 GitHub" }, + ...presets.map((url) => ({ value: url, title: url, desc: presetLabel(url) })), + { value: PROXY_CUSTOM_VALUE, title: "自定义", desc: "填入自定义加速前缀或镜像仓库地址" }, + ]; + container.innerHTML = options + .map( + (opt) => ``, + ) + .join(""); + const mode = current === "" ? "" : presets.includes(current) ? current : PROXY_CUSTOM_VALUE; + $$('input[name="gh-proxy"]', container).forEach((input) => { + input.checked = input.value === mode; + }); + $("#proxy-custom-field").classList.toggle("hidden", mode !== PROXY_CUSTOM_VALUE); + if (mode === PROXY_CUSTOM_VALUE) $("#proxy-custom-input").value = current; + container.onchange = () => { + const checked = $('input[name="gh-proxy"]:checked', container); + $("#proxy-custom-field").classList.toggle( + "hidden", + !checked || checked.value !== PROXY_CUSTOM_VALUE, + ); + }; + updateProxyStatus(); +} + +function selectedProxyValue() { + const checked = $('input[name="gh-proxy"]:checked'); + if (!checked) return ""; + if (checked.value === PROXY_CUSTOM_VALUE) return ($("#proxy-custom-input").value || "").trim(); + return checked.value; +} + +function updateProxyStatus() { + const current = state.proxy.proxy || ""; + $("#proxy-status").textContent = current ? `当前:${current}` : "当前:直连"; +} + +async function saveProxySettings() { + const button = $("#proxy-save"); + button.disabled = true; + try { + const data = await api("/system/github-proxy", { + method: "PUT", + body: JSON.stringify({ proxy: selectedProxyValue() }), + }); + state.proxy.proxy = data.proxy || ""; + renderProxySettings(); + toast("GitHub 加速设置已保存,立即生效"); + } catch (error) { + toast(error.message, "error"); + } finally { + button.disabled = false; + } +} + +async function testProxySettings() { + const button = $("#proxy-test-btn"); + const original = button.textContent; + button.disabled = true; + button.textContent = "测试中…"; + try { + const data = await api("/system/github-proxy/test", { + method: "POST", + body: JSON.stringify({ proxy: selectedProxyValue() }), + }); + if (data.ok) { + toast(`连通正常(${data.latency_ms}ms)`); + } else { + toast(data.detail || "连接失败", "error"); + } + } catch (error) { + toast(error.message, "error"); + } finally { + button.disabled = false; + button.textContent = original; + } +} + async function checkUpdate() { const button = $("#check-update-btn"); const original = button.textContent; diff --git a/src/nonebot_plugin_mimo_console/static/assets/styles.css b/src/nonebot_plugin_mimo_console/static/assets/styles.css index 7575a69..fbcb826 100644 --- a/src/nonebot_plugin_mimo_console/static/assets/styles.css +++ b/src/nonebot_plugin_mimo_console/static/assets/styles.css @@ -770,6 +770,13 @@ a { color: inherit; text-decoration: none; } .bot-chip strong { display: block; font-size: 12px; } .bot-chip span, .empty-mini { color: var(--muted); font-size: 11px; } .recent-panel { margin-top: 12px; } +.proxy-panel { margin-top: 12px; padding: 22px; } +.proxy-panel .proxy-hint { margin: -8px 0 16px; font-size: 13px; line-height: 1.6; } +.proxy-panel .bg-modes { grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); } +.proxy-panel .bg-mode-text { min-width: 0; } +.proxy-panel .bg-mode-text strong { overflow-wrap: anywhere; word-break: break-all; line-height: 1.45; } +.proxy-panel .bg-actions { align-items: center; } +.proxy-panel #proxy-status { font-size: 12px; overflow-wrap: anywhere; } .recent-logs { overflow: hidden; border: 1px solid var(--line); @@ -1719,6 +1726,7 @@ a { color: inherit; text-decoration: none; } .metric, .dash-grid > .panel, .recent-panel, +.proxy-panel, .toolbar, .tabs, .plugin-card, diff --git a/src/nonebot_plugin_mimo_console/static/index.html b/src/nonebot_plugin_mimo_console/static/index.html index 6ef8a54..a52b7ea 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.2
+
Mimo Console · v0.1.3
@@ -231,6 +231,25 @@

最近日志

正在等待日志…
+ +
+
+

GitHub 加速

+ +
+

用于控制台自身的版本检测与在线更新,保存后立即生效。

+
+ +
+ + +
+
@@ -498,6 +517,6 @@

插件详情

- + diff --git a/src/nonebot_plugin_mimo_console/version.py b/src/nonebot_plugin_mimo_console/version.py index 70cb357..9fede5b 100644 --- a/src/nonebot_plugin_mimo_console/version.py +++ b/src/nonebot_plugin_mimo_console/version.py @@ -12,14 +12,73 @@ MASTER_PYPROJECT_URL = ( "https://raw.githubusercontent.com/MimoKit/nonebot-plugin-mimo-console/master/pyproject.toml" ) +# CNB 镜像仓库(完整仓库地址,与代理前缀二选一)。 +CNB_MIRROR_REPO = "https://cnb.cool/MimokitStudio/nonebot-plugin-mimo-console" +# 供 WebUI 选择的 GitHub 加速项:gh-proxy 风格前缀 + CNB 镜像;空字符串表示直连。 +GITHUB_PROXY_PRESETS: tuple[str, ...] = ( + "https://edgeone.gh-proxy.com", + "https://hk.gh-proxy.com", + "https://gh-proxy.com", + "https://gh.llkk.cc", + CNB_MIRROR_REPO, +) _TAG_RE = re.compile(r"^v?(\d+\.\d+\.\d+[A-Za-z0-9.+~-]*)$") _PYPROJECT_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE) +_PROXY_SCHEME_RE = re.compile(r"^https?://", re.IGNORECASE) class VersionError(RuntimeError): """版本检测相关错误。""" +def normalize_github_proxy(value: str) -> str: + """校验并规范化 GitHub 加速地址(代理前缀或镜像仓库),空串表示直连;非法值抛 ValueError。""" + text = (value or "").strip().rstrip("/") + if not text: + return "" + if ( + not _PROXY_SCHEME_RE.match(text) + or any(char.isspace() for char in text) + or "@" in text + or "#" in text + ): + raise ValueError("加速地址必须是合法的 http/https URL") + return text + + +def is_mirror_repo(value: str) -> bool: + """该加速地址是否为完整镜像仓库地址(而非 gh-proxy 风格的代理前缀)。""" + text = value.rstrip("/") + return text.endswith(PACKAGE_NAME) or text.endswith(f"{PACKAGE_NAME}.git") + + +def apply_github_proxy(url: str, proxy: str) -> str: + """把 GitHub URL 改写成走加速代理前缀的形式;代理为空时原样返回。""" + prefix = normalize_github_proxy(proxy) + return f"{prefix}/{url}" if prefix else url + + +def resolve_git_url(proxy: str) -> str: + """按加速配置返回自更新实际使用的 git 仓库地址。""" + prefix = normalize_github_proxy(proxy) + if not prefix: + return PACKAGE_GIT_URL + if is_mirror_repo(prefix): + return prefix + return f"{prefix}/{PACKAGE_GIT_URL}" + + +def resolve_version_url(proxy: str) -> str: + """按加速配置返回版本检测实际读取的 pyproject 地址。""" + 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}" + + def get_installed_version() -> str: """返回当前安装的本插件版本,无法确定时返回空串。""" try: @@ -58,14 +117,15 @@ def __init__(self, cache_seconds: int = 1800) -> None: self._latest: str = "" self._fetched_at: float = 0.0 - async def fetch(self, force: bool = False) -> str: + 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) try: async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: response = await client.get( - MASTER_PYPROJECT_URL, + url, headers={"User-Agent": PACKAGE_NAME}, ) response.raise_for_status() diff --git a/tests/test_version.py b/tests/test_version.py index 7967b67..d52de86 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -18,6 +18,10 @@ is_newer = version.is_newer get_installed_version = version.get_installed_version LatestReleaseCache = version.LatestReleaseCache +normalize_github_proxy = version.normalize_github_proxy +is_mirror_repo = version.is_mirror_repo +resolve_git_url = version.resolve_git_url +resolve_version_url = version.resolve_version_url class NormalizeTagTests(unittest.TestCase): @@ -93,5 +97,51 @@ def test_snapshot_no_update_when_installed_unknown(self) -> None: self.assertFalse(snap["has_update"]) +class GithubProxyTests(unittest.TestCase): + def test_normalize_empty_means_direct(self) -> None: + self.assertEqual(normalize_github_proxy(""), "") + self.assertEqual(normalize_github_proxy(" "), "") + + def test_normalize_strips_trailing_slash(self) -> None: + self.assertEqual(normalize_github_proxy("https://gh-proxy.com/"), "https://gh-proxy.com") + + def test_normalize_rejects_invalid(self) -> None: + for value in ("ftp://x", "https://a b", "https://user@host", "not-a-url", "https://x/#f"): + with self.subTest(value=value), self.assertRaises(ValueError): + normalize_github_proxy(value) + + def test_is_mirror_repo(self) -> None: + self.assertTrue(is_mirror_repo(version.CNB_MIRROR_REPO)) + self.assertTrue(is_mirror_repo(f"{version.CNB_MIRROR_REPO}.git")) + self.assertFalse(is_mirror_repo("https://gh-proxy.com")) + + def test_resolve_git_url_direct(self) -> None: + self.assertEqual(resolve_git_url(""), version.PACKAGE_GIT_URL) + + def test_resolve_git_url_prefix(self) -> None: + self.assertEqual( + resolve_git_url("https://gh-proxy.com"), + f"https://gh-proxy.com/{version.PACKAGE_GIT_URL}", + ) + + def test_resolve_git_url_mirror(self) -> None: + self.assertEqual(resolve_git_url(version.CNB_MIRROR_REPO), version.CNB_MIRROR_REPO) + + def test_resolve_version_url_direct(self) -> None: + self.assertEqual(resolve_version_url(""), version.MASTER_PYPROJECT_URL) + + def test_resolve_version_url_prefix(self) -> None: + self.assertEqual( + resolve_version_url("https://gh-proxy.com/"), + 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", + ) + + if __name__ == "__main__": unittest.main() diff --git a/uv.lock b/uv.lock index 2706a0d..1d925e1 100644 --- a/uv.lock +++ b/uv.lock @@ -832,7 +832,7 @@ wheels = [ [[package]] name = "nonebot-plugin-mimo-console" -version = "0.1.2" +version = "0.1.3" source = { editable = "." } dependencies = [ { name = "httpx" },