Files
kefu/wechat_rpa/backend_client.py
T
2026-07-31 11:48:16 +08:00

552 lines
18 KiB
Python

# -*- 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)