# -*- coding: utf-8 -*- """桌面端与配置后台之间的认证和自动同步客户端。""" from __future__ import annotations import json import os import re import socket import threading import time import urllib.error import urllib.parse import urllib.request from pathlib import Path from typing import Any from app_version import APP_VERSION, release_status from runtime_paths import application_data_dir SCRIPT_DIR = application_data_dir() CONNECTION_FILE = SCRIPT_DIR / "backend_connection.json" RUNTIME_FILE = SCRIPT_DIR / "backend_runtime.json" DEFAULT_SERVER_URL = "http://xchat.zhenyangtang.com.cn" DESKTOP_SYNC_KEY = "wcrpa-v1-H3q9mT7xK2pN8cR5vL4sF6dB1yG0uJ" DESKTOP_CONFIG_PATH = "/api/v1/desktop/config" _LOCK = threading.RLock() _SENSITIVE_NAME_PARTS = ( "api_key", "apikey", "key", "token", "secret", "password", "passwd", "authorization", "cookie", "credential", "signature", "auth", ) _INLINE_SECRET_RE = re.compile( r"(?i)(--?(?:api[-_]?key|token|secret|password|passwd|authorization|auth)\s*[=:]\s*)([^\s,;]+)" ) class BackendError(RuntimeError): """后台通信或响应错误。""" class AuthenticationError(BackendError): """登录状态无效。""" def _process_is_running(pid: int) -> bool: """检查运行状态文件中的进程是否仍存在,不向进程发送终止信号。""" if pid <= 0: return False if pid == os.getpid(): return True if os.name == "nt": try: import ctypes process_query_limited_information = 0x1000 still_active = 259 kernel32 = ctypes.windll.kernel32 kernel32.OpenProcess.argtypes = [ ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong, ] kernel32.OpenProcess.restype = ctypes.c_void_p kernel32.GetExitCodeProcess.argtypes = [ ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong), ] kernel32.GetExitCodeProcess.restype = ctypes.c_int kernel32.CloseHandle.argtypes = [ctypes.c_void_p] kernel32.CloseHandle.restype = ctypes.c_int handle = kernel32.OpenProcess( process_query_limited_information, False, pid ) if not handle: return False try: exit_code = ctypes.c_ulong() if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): return False return exit_code.value == still_active finally: kernel32.CloseHandle(handle) except (AttributeError, OSError, ValueError): return False try: os.kill(pid, 0) except ProcessLookupError: return False except PermissionError: return True except OSError: return False return True def discover_local_runtime() -> dict[str, Any]: """读取并校验同项目后台发布的运行信息。""" try: info = json.loads(RUNTIME_FILE.read_text(encoding="utf-8")) url = normalize_server_url(info.get("server_url")) port = int(info.get("port", 0)) pid = int(info.get("pid", 0)) parsed = urllib.parse.urlparse(url) if ( not 1 <= port <= 65535 or parsed.port != port or not _process_is_running(pid) ): raise ValueError return { "server_url": url, "port": port, "pid": pid, "local_sync_token": str(info.get("local_sync_token") or ""), "started_at": str(info.get("started_at") or ""), } except (OSError, ValueError, TypeError, BackendError): return {} def discover_local_server_url() -> str: """读取后台发布的实际端口;文件无效时回退到默认地址。""" return str(discover_local_runtime().get("server_url") or DEFAULT_SERVER_URL) def discover_local_sync_token() -> str: return str(discover_local_runtime().get("local_sync_token") or "") def _is_local_server_url(value: Any) -> bool: try: hostname = urllib.parse.urlparse(normalize_server_url(value)).hostname except BackendError: return False return hostname in ("127.0.0.1", "localhost", "::1") def default_settings() -> dict[str, Any]: return { "server_url": discover_local_server_url(), "username": "", "access_token": "", "auto_sync": True, "sync_interval_seconds": 300, "last_version": 0, "last_sync_at": "", "last_error": "", "last_release": { "latest_version": APP_VERSION, "download_url": "", "release_notes": "", "force_upgrade": False, "updated_at": "", }, } def load_settings() -> dict[str, Any]: settings = default_settings() with _LOCK: try: saved = json.loads(CONNECTION_FILE.read_text(encoding="utf-8")) except (OSError, ValueError, TypeError): return settings if isinstance(saved, dict): settings.update({key: saved[key] for key in settings if key in saved}) settings["server_url"] = normalize_server_url(settings.get("server_url")) discovered_url = discover_local_server_url() if discovered_url != DEFAULT_SERVER_URL and _is_local_server_url(settings["server_url"]): settings["server_url"] = discovered_url settings["auto_sync"] = bool(settings.get("auto_sync", True)) if not isinstance(settings.get("last_release"), dict): settings["last_release"] = default_settings()["last_release"] try: settings["sync_interval_seconds"] = max( 60, int(settings.get("sync_interval_seconds", 300)) ) settings["last_version"] = max(0, int(settings.get("last_version", 0))) except (TypeError, ValueError): settings["sync_interval_seconds"] = 300 settings["last_version"] = 0 return settings def save_settings(settings: dict[str, Any]) -> None: data = default_settings() data.update({key: settings[key] for key in data if key in settings}) data["server_url"] = normalize_server_url(data["server_url"]) temporary = CONNECTION_FILE.with_suffix(".json.tmp") with _LOCK: temporary.write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" ) os.replace(temporary, CONNECTION_FILE) def normalize_server_url(value: Any) -> str: url = str(value or DEFAULT_SERVER_URL).strip().rstrip("/") if not url.startswith(("http://", "https://")): url = "http://" + url parsed = urllib.parse.urlparse(url) if not parsed.hostname: raise BackendError("后台地址格式不正确") return url def _development_mode_enabled(config: dict[str, Any]) -> bool: value = config.get("AI_DEVELOPMENT_MODE", False) if isinstance(value, str): return value.strip().lower() in {"1", "true", "yes", "on"} return bool(value) def _sensitive_name(name: Any) -> bool: normalized = str(name or "").strip().lower().replace("-", "_") return any(part in normalized for part in _SENSITIVE_NAME_PARTS) def redact_diagnostic_value(value: Any, *, field_name: str = "") -> Any: """Return a JSON-safe diagnostic copy with credentials removed.""" if _sensitive_name(field_name): return "[已配置,值已隐藏]" if value not in (None, "", [], {}) else "[未配置]" if isinstance(value, dict): return { str(key): redact_diagnostic_value(item, field_name=str(key)) for key, item in value.items() } if isinstance(value, list): result = [] hide_next = False for item in value: if hide_next: result.append("[值已隐藏]") hide_next = False continue result.append(redact_diagnostic_value(item)) if isinstance(item, str) and _sensitive_name(item.lstrip("-")): hide_next = True return result if isinstance(value, str): text = value if re.match(r"(?i)^bearer\s+\S+", text.strip()): return "[认证值已隐藏]" if re.match(r"(?i)^(?:sk|app)-[A-Za-z0-9_.-]{8,}$", text.strip()): return "[密钥值已隐藏]" text = _INLINE_SECRET_RE.sub(r"\1[值已隐藏]", text) if text.startswith(("http://", "https://")): return diagnostic_url(text) return text return value def diagnostic_url(value: Any) -> str: """Keep the request destination visible while hiding credentials in its query.""" url = str(value or "") try: parsed = urllib.parse.urlsplit(url) if not parsed.scheme or not parsed.netloc: return url hostname = parsed.hostname or "" if ":" in hostname and not hostname.startswith("["): hostname = f"[{hostname}]" netloc = hostname if parsed.port: netloc += f":{parsed.port}" query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) safe_query = urllib.parse.urlencode( [ (name, "[值已隐藏]" if _sensitive_name(name) else item) for name, item in query ] ) return urllib.parse.urlunsplit( (parsed.scheme, netloc, parsed.path, safe_query, "") ) except (TypeError, ValueError): return url def _config_diagnostics( config: dict[str, Any], response: dict[str, Any], request_url: str ) -> list[str]: if not _development_mode_enabled(config): return [] safe_config = redact_diagnostic_value(config) config_json = json.dumps(safe_config, ensure_ascii=False, indent=2, sort_keys=True) return [ f"[开发模式] 云端配置请求地址: {diagnostic_url(request_url)}", ( f"[开发模式] 云端配置版本: v{int(response.get('version', 0))}" f";更新时间: {response.get('updated_at') or '未提供'}" ), f"[开发模式] 获取到的云端配置(敏感值已隐藏):\n{config_json}", ] def is_configured(settings: dict[str, Any] | None = None) -> bool: current = settings or load_settings() return bool( current.get("server_url") and (current.get("access_token") or discover_local_sync_token()) ) def connection_summary() -> dict[str, Any]: settings = load_settings() return { "configured": is_configured(settings), "authenticated": bool(settings.get("access_token")), "local_discovered": bool(discover_local_sync_token()), "server_url": settings["server_url"], "username": settings.get("username", ""), "auto_sync": settings["auto_sync"], "sync_interval_seconds": settings["sync_interval_seconds"], "last_version": settings["last_version"], "last_sync_at": settings.get("last_sync_at", ""), "last_error": settings.get("last_error", ""), } def _request( method: str, server_url: str, path: str, *, token: str = "", payload: dict[str, Any] | None = None, local_sync_token: str = "", desktop_sync_key: str = "", timeout: float = 10.0, ) -> tuple[int, dict[str, Any]]: url = normalize_server_url(server_url) + path body = None headers = {"Accept": "application/json", "User-Agent": "WeCom-RPA/1.0"} if payload is not None: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") headers["Content-Type"] = "application/json; charset=utf-8" if token: headers["Authorization"] = f"Bearer {token}" if local_sync_token: headers["X-Desktop-Sync-Token"] = local_sync_token if desktop_sync_key: headers["X-Desktop-Sync-Key"] = desktop_sync_key request = urllib.request.Request(url, data=body, headers=headers, method=method) try: with urllib.request.urlopen(request, timeout=timeout) as response: raw = response.read().decode("utf-8") data = json.loads(raw) if raw else {} return int(response.status), data except urllib.error.HTTPError as exc: try: data = json.loads(exc.read().decode("utf-8")) except Exception: data = {"error": f"后台返回 HTTP {exc.code}"} message = str(data.get("error") or data.get("message") or f"HTTP {exc.code}") if exc.code in (401, 403): raise AuthenticationError(message) from exc raise BackendError(message) from exc except (urllib.error.URLError, TimeoutError, socket.timeout) as exc: reason = getattr(exc, "reason", exc) raise BackendError(f"无法连接后台:{reason}") from exc except (ValueError, TypeError) as exc: raise BackendError("后台响应不是有效 JSON") from exc def login( server_url: str, username: str, password: str, *, auto_sync: bool = True, ) -> dict[str, Any]: server_url = normalize_server_url(server_url) username = str(username or "").strip() if not username or not password: raise AuthenticationError("请输入用户名和密码") _, response = _request( "POST", server_url, "/api/v1/auth/login", payload={ "username": username, "password": password, "device_name": socket.gethostname(), }, ) token = str(response.get("access_token") or "") if not token: raise AuthenticationError("后台没有返回登录令牌") settings = load_settings() settings.update( { "server_url": server_url, "username": response.get("user", {}).get("username", username), "access_token": token, "auto_sync": bool(auto_sync), "last_error": "", } ) save_settings(settings) return response def logout(*, revoke_remote: bool = True) -> None: settings = load_settings() if revoke_remote and settings.get("access_token"): try: _request( "POST", settings["server_url"], "/api/v1/auth/logout", token=settings["access_token"], payload={}, timeout=5.0, ) except BackendError: pass settings.update( { "username": "", "access_token": "", "last_error": "", "last_version": 0, "last_sync_at": "", } ) save_settings(settings) def _apply_config_response( response: dict[str, Any], settings: dict[str, Any], *, request_url: str = "" ) -> dict[str, Any]: config = response.get("config") if not isinstance(config, dict): raise BackendError("后台没有返回有效的模型配置") version = int(response.get("version", 0)) import ai_config applied = ai_config.apply_settings(config, persist=True) release = release_status(response.get("release")) cached_release = { key: release[key] for key in ( "latest_version", "download_url", "release_notes", "force_upgrade", "updated_at", ) } settings.update( { "last_version": version, "last_sync_at": time.strftime("%Y-%m-%d %H:%M:%S"), "last_error": "", "last_release": cached_release, } ) save_settings(settings) return { "synced": True, "version": version, "applied_count": len(applied), "updated_at": response.get("updated_at", ""), "message": f"已同步云端配置 v{version}", "release": release, "local_app_version": APP_VERSION, "update_available": release["update_available"], "force_upgrade": release["force_upgrade"], "diagnostics": _config_diagnostics(config, response, request_url), } def cached_release_status() -> dict[str, Any]: """网络不可用时读取上次成功同步的升级策略。""" return release_status(load_settings().get("last_release")) def sync_config(*, force: bool = False, timeout: float = 10.0) -> dict[str, Any]: """保留账号登录和同机后台的兼容同步能力。""" settings = load_settings() if not is_configured(settings): return {"synced": False, "reason": "not_configured", "message": "尚未登录后台"} if not settings.get("auto_sync") and not force: return {"synced": False, "reason": "disabled", "message": "自动同步已关闭"} try: _, response = _request( "GET", settings["server_url"], "/api/v1/config", token=settings["access_token"], local_sync_token=( "" if settings.get("access_token") else discover_local_sync_token() ), timeout=timeout, ) return _apply_config_response( response, settings, request_url=normalize_server_url(settings["server_url"]) + "/api/v1/config", ) except Exception as exc: settings["last_error"] = str(exc) save_settings(settings) raise def sync_cloud_config(*, timeout: float = 10.0) -> dict[str, Any]: """无需用户登录,从固定云端读取桌面运行配置。""" settings = load_settings() settings.update( { "server_url": DEFAULT_SERVER_URL, "username": "", "access_token": "", "auto_sync": True, } ) try: _, response = _request( "GET", DEFAULT_SERVER_URL, DESKTOP_CONFIG_PATH, desktop_sync_key=DESKTOP_SYNC_KEY, timeout=timeout, ) return _apply_config_response( response, settings, request_url=normalize_server_url(DEFAULT_SERVER_URL) + DESKTOP_CONFIG_PATH, ) except Exception as exc: settings["last_error"] = str(exc) save_settings(settings) raise def startup_sync_config(*, timeout: float = 3.0) -> dict[str, Any]: """软件启动前先请求固定云端,使首屏直接使用服务器配置。""" return sync_cloud_config(timeout=timeout)