"""account-manager CLI 集成(仅 subprocess,不 import 兄弟 skill 内部模块)。 业务首启默认 ``ensure-web``(有则用、无则 add-web);登记 ≠ 站点已登录。 """ from __future__ import annotations import json import logging import os import subprocess import sys from typing import Any, Dict, List, Optional, Tuple from jiangchang_skill_core import config from jiangchang_skill_core.runtime_env import get_sibling_skills_root from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM from util.logging import mask_text logger = logging.getLogger(__name__) PLACEHOLDER_PLATFORM = TARGET_PLATFORM ACCOUNT_SETUP_MESSAGE = ( f"未能取得可用的 {PLACEHOLDER_PLATFORM} 账号(ensure-web 失败),所以还没有打开浏览器。" f"请确认已安装 account-manager,并检查 platform={PLACEHOLDER_PLATFORM} 账号状态后重试。" ) LEASE_BUSY_MESSAGE = ( "该平台下已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。" ) DEFAULT_AUTH_STRATEGY = "qr_code_manual" DEFAULT_ACCOUNT_LABEL = f"{PLACEHOLDER_PLATFORM} 默认" DEFAULT_START_URL = "https://www.example.com/" class AccountManagerError(Exception): def __init__(self, code: str, message: str) -> None: super().__init__(message) self.code = code self.message = message def mask_login_id(login_id: str) -> str: return mask_text(login_id) def format_account_label(account: dict) -> str: """人读账号标识:优先 login_id / 备注,最后才用数字 id。""" for key in ("login_id", "account_label", "label"): v = account.get(key) if v is not None and str(v).strip(): return str(v).strip() for key in ("id", "account_id"): v = account.get(key) if v is not None and str(v).strip(): return str(v).strip() return "" def _is_placeholder_config(raw: str) -> bool: s = (raw or "").strip() if not s: return True if s.startswith("#"): return True low = s.lower() if low in ("default-account", "none", "null", "n/a", "-"): return True return False def resolve_account_selectors_from_config() -> Tuple[Optional[str], Optional[str]]: """从配置解析 (account_id, login_id)。 - ``DEFAULT_ACCOUNT_ID``:仅纯数字视为账号主键;其它非空非占位当作 login_id - ``DEFAULT_LOGIN_ID``:手机号/用户名(可覆盖上一项推导的 login_id) - 两者皆空:返回 (None, None) → 调用方走按平台 ensure-web """ account_id: Optional[str] = None login_id: Optional[str] = None aid_raw = (config.get("DEFAULT_ACCOUNT_ID") or "").strip() if not _is_placeholder_config(aid_raw): if aid_raw.isdigit(): account_id = aid_raw else: login_id = aid_raw logger.info("default_account_id_treated_as_login_id value=%s", aid_raw) lid_raw = (config.get("DEFAULT_LOGIN_ID") or "").strip() if not _is_placeholder_config(lid_raw): login_id = lid_raw if account_id: return account_id, None return None, login_id def _resolve_account_manager_main() -> str: """解析 account-manager CLI 入口路径。 优先级:ACCOUNT_MANAGER_ROOT → get_sibling_skills_root → 开发机兜底。 末尾 dev 路径仅用于模板/本机调试,复制到真实 skill 后不要依赖个人机器绝对路径。 """ env_root = (config.get("ACCOUNT_MANAGER_ROOT") or os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip() if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")): return os.path.join(os.path.abspath(env_root), "scripts", "main.py") scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) skills_root = get_sibling_skills_root(scripts_dir) candidate = os.path.join(skills_root, "account-manager", "scripts", "main.py") if os.path.isfile(candidate): return candidate # 开发环境兜底:仅模板/本机调试;生产依赖宿主注入的路径变量,勿硬编码个人目录 dev = r"D:\OpenClaw\client-commons\account-manager\scripts\main.py" if os.path.isfile(dev): return dev raise AccountManagerError( "ACCOUNT_NOT_FOUND", "未找到 account-manager,请配置 ACCOUNT_MANAGER_ROOT 或安装 account-manager 技能。", ) def _parse_last_json(stdout: str) -> Any: lines = [ln.strip() for ln in (stdout or "").splitlines() if ln.strip()] for raw in reversed(lines): if raw.startswith("ERROR:"): continue try: return json.loads(raw) except json.JSONDecodeError: continue raise AccountManagerError("UNKNOWN_ERROR", "stdout 中没有可解析的 JSON。") def _run_argv(argv_suffix: List[str]) -> subprocess.CompletedProcess[str]: main_py = _resolve_account_manager_main() env = os.environ.copy() return subprocess.run( [sys.executable, main_py, *argv_suffix], capture_output=True, text=True, encoding="utf-8", errors="replace", env=env, ) def _normalize_error_code(raw: str) -> str: code = (raw or "").strip() if code.startswith("ERROR:"): code = code[6:] return code def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]: if payload.get("success") is False: err = payload.get("error") if isinstance(payload.get("error"), dict) else {} code = _normalize_error_code(str(err.get("code") or "")) message = str(err.get("message") or "") if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code: raise AccountManagerError("LEASE_CONFLICT", message or LEASE_BUSY_MESSAGE) if code == "AMBIGUOUS_ACCOUNT" or "AMBIGUOUS_ACCOUNT" in code: raise AccountManagerError( "AMBIGUOUS_ACCOUNT", message or "匹配到多条账号,请改用数字 id 或更精确的登录标识。", ) if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code: raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。") raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。") if not isinstance(payload, dict): raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。") if not payload.get("profile_dir"): raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回的账号缺少 profile_dir。") return payload def _ensure_web_with_lease( platform: str, *, login_id: Optional[str] = None, ) -> Dict[str, Any]: start_url = (config.get("TARGET_BASE_URL") or DEFAULT_START_URL).strip() or DEFAULT_START_URL lid = (login_id or "").strip() or None label = f"{platform} {lid}" if lid else DEFAULT_ACCOUNT_LABEL argv = [ "account", "ensure-web", "--platform", platform, "--url", start_url, "--auth-strategy", DEFAULT_AUTH_STRATEGY, "--label", label, "--lease", "--holder", LEASE_HOLDER, "--ttl-sec", LEASE_TTL_SEC, "--purpose", "rpa", ] if lid: argv.extend(["--login-id", lid]) proc = _run_argv(argv) out = proc.stdout or "" if proc.returncode != 0 and not out.strip(): raise AccountManagerError( "ENSURE_WEB_FAILED", (proc.stderr or "").strip() or "ensure-web 子进程失败", ) payload = _parse_last_json(out) if isinstance(payload, dict) and payload.get("success") is False: return _validate_pick_payload(payload) if not isinstance(payload, dict): raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。") return _validate_pick_payload(payload) def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]: proc = _run_argv(["account", "get", str(account_id)]) out = proc.stdout or "" if proc.returncode != 0 or out.startswith("ERROR:"): raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。") raw = _parse_last_json(out) if isinstance(raw, dict) and "data" in raw and isinstance(raw["data"], dict): data = raw["data"] elif isinstance(raw, dict): data = raw else: raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。") platform_key = str(data.get("platform_key") or "").lower() if platform_key != platform.lower(): raise AccountManagerError( "ACCOUNT_NOT_FOUND", f"账号 {account_id} 不是 {platform} 平台账号。", ) profile_dir = str(data.get("profile_dir") or "").strip() if not profile_dir: raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。") data["profile_dir"] = profile_dir data["lease_token"] = "" return data def pick_web_account( platform: str, account_id: Optional[str] = None, login_id: Optional[str] = None, ) -> dict: """获取网页账号(profile_dir + lease_token)。 优先级:数字 account_id → login_id(ensure-web --login-id)→ 按平台 ensure-web。 """ platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM aid_raw = (account_id or "").strip() if aid_raw and not _is_placeholder_config(aid_raw): if not aid_raw.isdigit(): if not (login_id or "").strip(): login_id = aid_raw aid_raw = "" else: return _pick_by_id(platform_key, int(aid_raw)) lid = (login_id or "").strip() or None if lid and _is_placeholder_config(lid): lid = None try: return _ensure_web_with_lease(platform_key, login_id=lid) except AccountManagerError as exc: if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code: raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc raise def release_lease(lease_token: Optional[str]) -> None: token = (lease_token or "").strip() if not token: return try: proc = _run_argv(["lease", "release", token]) if proc.returncode != 0: logger.warning("lease_release_failed stdout=%s", (proc.stdout or "").strip()) except Exception: logger.warning("lease_release_exception", exc_info=True)