# -*- coding: utf-8 -*- """Account query and pick CLI commands.""" import json import uuid from typing import Optional from db.accounts_repo import ( acquire_lease, find_account_ids, find_accounts, find_credentials, get_account_by_id, get_active_lease_by_token, update_account_last_used, ) from db.connection import get_conn, init_db from db.credentials_repo import ( CredentialNotEncryptedError, list_credentials_by_account, read_credential_plaintext, ) from service._svc_common import ( PlatformCallerMeta, _err_json, _resolve_or_auto_register, _resolve_or_fail, _say, _say_err, ) from service.crypto import CredentialDecryptError from service.master_key import MasterKeyMissingError from util.logging_config import get_skill_logger from util.platforms import seed_platforms from util.runtime_paths import _runtime_paths_debug_text def cmd_account_get(account_id, include_credentials: bool = False) -> None: """Get a single account by id. Returns JSON.""" log = get_skill_logger() log.info("cli_start subcommand=account_get id=%r", account_id) init_db() acc = get_account_by_id(account_id) if not acc: log.warning("account_get_not_found id=%r", account_id) _say(f"ERROR:ACCOUNT_NOT_FOUND 账号 id={account_id} 不存在。") _say_err(_runtime_paths_debug_text()) return if include_credentials: creds = find_credentials( platform_key=acc["platform_key"], limit=20, ) linked_creds = [c for c in creds if c["account_id"] == acc["id"]] if not linked_creds: linked_creds = [c for c in creds if c["account_id"] is None] acc["credentials"] = [ { "id": c["id"], "credential_label": c["credential_label"], "credential_type": c["credential_type"], "secret_storage": c["secret_storage"], "secret_ref": c["secret_ref"], "secret_mask": c["secret_mask"], "status": c["status"], } for c in linked_creds ] _say(json.dumps(acc, ensure_ascii=False)) def cmd_account_get_credential( account_id: int, *, reveal: bool = False, lease_token: Optional[str] = None, ) -> None: """account get-credential — metadata or plaintext when lease proves access.""" log = get_skill_logger() log.info("cli_start subcommand=account_get_credential id=%s reveal=%s", account_id, reveal) init_db() acc = get_account_by_id(account_id) if not acc: log.warning("account_get_credential_not_found id=%s", account_id) _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。")) return conn = get_conn() try: creds_all = list_credentials_by_account(conn, account_id) creds = [c for c in creds_all if (c.get("status") or "").strip().lower() == "active"] if not creds: _say(_err_json( "ERR_CREDENTIAL_MISSING", f"账号 id={account_id} 没有活跃凭据。", )) return cred = creds[0] base_out = { "success": True, "account_id": account_id, "credential_id": cred["id"], "credential_type": cred["credential_type"], "secret_storage": cred["secret_storage"], "secret_mask": cred["secret_mask"], } if not reveal: _say(json.dumps(base_out, ensure_ascii=False)) return tok = (lease_token or "").strip() if not tok: log.warning("account_get_credential_reveal_missing_token id=%s", account_id) _say(_err_json( "ERR_LEASE_INVALID", "reveal 必须带 --lease-token。", )) return lease = get_active_lease_by_token(conn, tok) lease_prefix = tok[:8] if lease is None: log.warning( "account_get_credential_reveal_bad_lease id=%s lease_prefix=%s", account_id, lease_prefix, ) _say(_err_json( "ERR_LEASE_INVALID", "lease token 无效或已过期", )) return if int(lease["account_id"]) != int(account_id): log.warning( "account_get_credential_reveal_lease_mismatch id=%s lease_account=%s lease_prefix=%s", account_id, lease["account_id"], lease_prefix, ) _say(_err_json( "ERR_LEASE_INVALID", "lease token 不属于该 account", )) return try: pt = read_credential_plaintext(conn, int(cred["id"])) except MasterKeyMissingError as e: log.warning("account_get_credential_master_missing id=%s", account_id) _say(_err_json("ERR_MASTER_KEY_MISSING", str(e))) return except CredentialDecryptError as e: log.warning( "account_get_credential_decrypt_failed id=%s cred_id=%s lease_prefix=%s", account_id, cred["id"], lease_prefix, ) _say(_err_json("ERR_CREDENTIAL_DECRYPT_FAILED", str(e))) return except CredentialNotEncryptedError: out = {**base_out, "plaintext": None, "note": "credential storage=none, no plaintext to reveal"} log.info( "account_get_credential_reveal_note_plain id=%s cred_id=%s lease_prefix=%s", account_id, cred["id"], lease_prefix, ) _say(json.dumps(out, ensure_ascii=False)) return log.info( "account_get_credential_reveal_success account_id=%s cred_id=%s lease_prefix=%s", account_id, cred["id"], lease_prefix, ) _say(json.dumps({**base_out, "plaintext": pt}, ensure_ascii=False)) finally: conn.close() def cmd_get(account_id) -> None: """Legacy: get — same as account get .""" cmd_account_get(account_id, include_credentials=False) def cmd_account_list( platform_input: Optional[str] = None, environment: Optional[str] = None, tenant_id: Optional[str] = None, role: Optional[str] = None, login_id: Optional[str] = None, label: Optional[str] = None, limit: int = 200, ) -> None: """account list — output JSON array of accounts matching filters.""" log = get_skill_logger() log.info( "cli_start subcommand=account_list platform=%s env=%s tenant=%s role=%s login_id=%s label=%s", platform_input, environment, tenant_id, role, login_id, label, ) init_db() key = None if platform_input and platform_input not in ("all", "全部", ""): key = _resolve_or_fail(platform_input) if not key: return accounts = find_accounts( platform_key=key, environment=environment, tenant_id=tenant_id, role=role, login_id=login_id, label=label, limit=limit, ) _say(json.dumps(accounts, ensure_ascii=False)) def cmd_account_resolve( *, account_id: Optional[str] = None, platform_input: Optional[str] = None, login_id: Optional[str] = None, label: Optional[str] = None, query: Optional[str] = None, ) -> None: """Resolve one account by id, login_id, label, or free-form query. Success: same JSON shape as ``account get``. Errors: ACCOUNT_NOT_FOUND / NO_ACCOUNT / AMBIGUOUS_ACCOUNT / CLI_BAD_ARGS. """ log = get_skill_logger() init_db() aid_raw = (account_id or "").strip() q = (query or "").strip() lid = (login_id or "").strip() lab = (label or "").strip() # Free-form: digits → id; otherwise treat as login_id (then label fallback). if q and not aid_raw and not lid and not lab: if q.isdigit(): aid_raw = q else: lid = q if aid_raw: try: aid = int(aid_raw) except (TypeError, ValueError): _say(_err_json("ERROR:CLI_BAD_ARGS", f"账号 id 必须是整数:{aid_raw}")) return acc = get_account_by_id(aid) if not acc: _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={aid} 不存在。")) return log.info("account_resolve_by_id id=%s", aid) _say(json.dumps(acc, ensure_ascii=False)) return if not lid and not lab: _say(_err_json( "ERROR:CLI_BAD_ARGS", "account resolve 需要 --id、<数字 query>、--login-id 或 --label(也可用 --query 用户名/手机号)。", )) return key = None if platform_input and platform_input not in ("all", "全部", ""): key = _resolve_or_fail(platform_input) if not key: return elif not platform_input: # login_id/label without platform: search all platforms key = None matches = find_accounts( platform_key=key, login_id=lid or None, label=lab or None, status="active", limit=20, ) # If query was used as login_id and miss, retry as label. if not matches and q and not login_id and not label and not q.isdigit(): matches = find_accounts( platform_key=key, label=q, status="active", limit=20, ) if not matches: ref = lid or lab or q _say(_err_json( "ERROR:NO_ACCOUNT", f"未找到匹配的账号(ref={ref!r}" + (f", platform={key}" if key else "") + ")。", )) return if len(matches) > 1: ids = [m.get("id") for m in matches[:10]] _say(_err_json( "ERROR:AMBIGUOUS_ACCOUNT", f"匹配到多条账号,请改用 --id 或补充 --platform。候选 id={ids}", )) return acc = matches[0] log.info( "account_resolve_ok id=%s login_id=%s label=%s", acc.get("id"), acc.get("login_id"), acc.get("account_label"), ) _say(json.dumps(acc, ensure_ascii=False)) def cmd_list_json(platform_input: str, limit: int = 200) -> None: """Legacy: list-json [--limit N]""" log = get_skill_logger() log.info("cli_start subcommand=list_json filter=%r limit=%s", platform_input, limit) init_db() raw = (platform_input or "all").strip() if not raw or raw.lower() == "all" or raw == "全部": key = None else: key = _resolve_or_fail(raw) if not key: return lim = max(1, min(int(limit), 500)) accounts = find_accounts(platform_key=key, limit=lim) _say(json.dumps(accounts, ensure_ascii=False)) def cmd_account_pick_web( platform_input: str, environment: Optional[str] = None, tenant_id: Optional[str] = None, role: Optional[str] = None, login_id: Optional[str] = None, label: Optional[str] = None, do_lease: bool = False, ttl_sec: int = 900, holder: Optional[str] = None, purpose: Optional[str] = None, platform_meta: Optional[PlatformCallerMeta] = None, ) -> None: """Pick a web/RPA account for use. Optionally acquire a lease.""" log = get_skill_logger() log.info( "account_pick_web_attempt platform=%s env=%s tenant=%s role=%s login_id=%s label=%s lease=%s", platform_input, environment, tenant_id, role, login_id, label, do_lease, ) key = _resolve_or_auto_register(platform_input, platform_meta) if not key: return init_db() conn = get_conn() try: seed_platforms(conn) finally: conn.close() lid = (login_id or "").strip() or None lab = (label or "").strip() or None # Human-ref filters should be unique; ambiguous active matches → explicit error. if lid or lab: active_matches = find_accounts( platform_key=key, environment=environment, tenant_id=tenant_id, role=role, status="active", login_id=lid, label=lab, limit=20, ) if len(active_matches) > 1: ids = [m.get("id") for m in active_matches[:10]] _say(_err_json( "ERROR:AMBIGUOUS_ACCOUNT", f"login_id/label 匹配到多条账号,请改用 account get 。候选 id={ids}", )) return candidate_ids = find_account_ids( platform_key=key, environment=environment, tenant_id=tenant_id, role=role, login_id=lid, label=lab, ) if not candidate_ids: hint = "" if lid or lab: hint = f"(login_id={lid!r} label={lab!r})" _say(_err_json( "ERROR:NO_ACCOUNT", f"没有符合条件的可用账号{hint}(status=active 且未被其他 lease 占用)。", )) return picked_id = candidate_ids[0] acc = get_account_by_id(picked_id) if not acc: _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", "选中的账号突然不存在了。")) return update_account_last_used(picked_id) result = { "id": acc["id"], "platform_key": acc["platform_key"], "account_label": acc["account_label"], "login_id": acc["login_id"], "profile_dir": acc["profile_dir"], "url": acc["url"], "tenant_id": acc["tenant_id"], "environment": acc["environment"], "role": acc["role"], "provider_code": acc["provider_code"], "session_status": acc["session_status"], "auth_strategy": acc["auth_strategy"], "session_persistent": acc["session_persistent"], "device_fingerprint": acc.get("device_fingerprint"), } lease_info = None if do_lease: lease_token = uuid.uuid4().hex[:32] resolved_holder = holder or "unknown" try: lease_info = acquire_lease( account_id=picked_id, lease_token=lease_token, holder=resolved_holder, purpose=purpose, ttl_sec=ttl_sec, ) result["lease_token"] = lease_info["lease_token"] result["lease_expires_at"] = lease_info["expires_at"] except ValueError as e: log.warning("account_pick_web_lease_conflict id=%s", picked_id) _say(_err_json("ERROR:LEASE_CONFLICT", str(e))) return log.info("account_pick_web_success id=%s token_prefix=%s", picked_id, lease_info["lease_token"][:8] if lease_info else "none") _say(json.dumps(result, ensure_ascii=False)) def cmd_pick_web(platform_input: str) -> None: """Legacy: pick-web — same as account pick-web without lease.""" cmd_account_pick_web(platform_input, do_lease=False)