#!/usr/bin/env python3 """Interactive Thanh Nguyen customer API example. Requires Python 3.10+ and requests. python -m pip install requests python workshop_api.py --base-url https://workshop.example.com Set XID_API_KEY or enter the key at the hidden prompt. Passwords and OTPs are prompted without echo and are never persisted by this example. """ from __future__ import annotations import argparse import getpass import os import re import sys import time from urllib.parse import urlsplit, urlunsplit import uuid import requests PREFIX = "/api/v1/workshop" POLL_SECONDS = 3 TIMEOUT_SECONDS = 900 IDEMPOTENCY = re.compile(r"[A-Za-z0-9._:-]{8,128}\Z") class APIError(RuntimeError): def __init__(self, status, code="request_failed", *, retry_after=0, account_id=None, job_id=None, batch_id=None): safe = code if isinstance(code, str) and re.fullmatch(r"[a-z_]{1,64}", code) else "request_failed" super().__init__(f"HTTP {status}: {safe}") self.status = status self.retry_after = retry_after self.account_id = optional_id(account_id) self.job_id = optional_id(job_id) self.batch_id = optional_id(batch_id) def optional_id(value): try: return str(uuid.UUID(value)) if isinstance(value, str) else None except ValueError: return None def record_id(value): result = optional_id(value) if result is None: raise RuntimeError("Máy chủ trả về mã tài khoản/tiến trình không hợp lệ.") return result def base_origin(value): parsed = urlsplit(value) try: _ = parsed.port except ValueError: raise argparse.ArgumentTypeError("Địa chỉ API không hợp lệ.") from None local = parsed.hostname in {"127.0.0.1", "localhost", "::1"} if ( not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.path not in {"", "/"} or parsed.query or parsed.fragment or not (parsed.scheme == "https" or (parsed.scheme == "http" and local)) ): raise argparse.ArgumentTypeError("Dùng origin HTTPS; HTTP chỉ dùng với localhost.") return urlunsplit((parsed.scheme, parsed.netloc, "", "", "")) class Client: def __init__(self, base_url, api_key, *, session=None): self.base_url = base_origin(base_url) self.session = session or requests.Session() self.session.trust_env = False self.session.headers.update({ "Authorization": "Bearer " + api_key, "Accept": "application/json", }) def preview_bulk(self, text): """Parse account lines without starting login or sending SMS.""" return self.call("POST", PREFIX + "/bulk/preview", {"text": text}) def submit_bulk(self, text, *, operation_id, **options): """Retain this operation ID and exact input until the outcome is known.""" return self.call("POST", PREFIX + "/bulk", {"text": text, **options}, operation_id=operation_id) def list_bulk(self): return self.call("GET", PREFIX + "/bulk") def get_bulk(self, batch_id): return self.call("GET", PREFIX + "/bulk/" + record_id(batch_id)) def cancel_bulk(self, batch_id): return self.call("POST", PREFIX + "/bulk/" + record_id(batch_id) + "/cancel") def export_accounts(self, account_ids=None, *, visibility="visible", folder_id=""): """Returns private TXT content; do not log it. None means all accounts.""" body = {"scope": "all" if account_ids is None else "selected", "visibility": visibility, "folder_id": folder_id} if account_ids is not None: body["account_ids"] = [record_id(value) for value in account_ids] return self.call("POST", PREFIX + "/accounts/export", body) def get_proxy(self): return self.call("GET", PREFIX + "/proxy") def save_proxy(self, mode, *, strategy="per_account", value=None): return self.call("PUT", PREFIX + "/proxy", { "mode": mode, "strategy": strategy, "value": value, }) def get_proxy_details(self): """Contains the owner's full saved secret; do not log or share it.""" return self.call("GET", PREFIX + "/proxy/details") def close(self): self.session.close() def call(self, method, path, body=None, *, operation_id=None): if not path.startswith(PREFIX + "/") or "?" in path or "#" in path: raise ValueError("Unsupported API path") headers = {} if method != "GET": headers["Content-Type"] = "application/json" body = body if body is not None else {} if operation_id is not None: if not IDEMPOTENCY.fullmatch(operation_id): raise ValueError("Idempotency-Key không hợp lệ.") headers["Idempotency-Key"] = operation_id try: response = self.session.request( method, self.base_url + path, json=body, headers=headers, timeout=(10, 45), allow_redirects=False, ) except requests.RequestException: # Deliberately omit the exception/URL/header dump, which may contain secrets. raise RuntimeError( "Mất kết nối API. Không tự gửi lại thao tác. Kiểm tra tiến trình; " "nếu thử lại tạo/login, dùng đúng Idempotency-Key và nội dung cũ." ) from None try: if 300 <= response.status_code < 400: raise RuntimeError("API chuyển hướng. Kiểm tra --base-url; không chuyển tiếp API Key.") try: value = response.json() except ValueError: raise RuntimeError("Máy chủ không trả JSON hợp lệ.") from None if not isinstance(value, dict): raise RuntimeError("Phản hồi API không hợp lệ.") if response.status_code >= 400: raw_retry = response.headers.get("Retry-After", "") retry = min(120, int(raw_retry)) if raw_retry.isdigit() else 0 raise APIError( response.status_code, value.get("error"), retry_after=retry, account_id=value.get("account_id"), job_id=value.get("job_id"), batch_id=value.get("batch_id"), ) return value finally: response.close() def sleep_bounded(seconds, deadline): remaining = deadline - time.monotonic() if remaining <= 0: raise TimeoutError("Đã hết thời gian theo dõi. Tiếp tục bằng --resume-job nếu tiến trình còn chạy.") time.sleep(min(seconds, remaining)) def get_job(client, job_id, deadline): while time.monotonic() < deadline: try: return client.call("GET", f"{PREFIX}/jobs/{job_id}")["job"] except APIError as error: if error.status != 429: raise sleep_bounded(max(POLL_SECONDS, error.retry_after), deadline) raise TimeoutError("Đã hết thời gian theo dõi; có thể dùng --resume-job để kiểm tra tiếp.") def factor_ready(client, job_id, job, deadline, *, options=False): while job.get("status") == "awaiting_otp": factor = job.get("two_factor") or {} if not factor.get("sending") and not factor.get("options_loading"): if factor.get("send_error") or (options and factor.get("options_error")): raise RuntimeError("Chưa gửi/chuẩn bị được mã. Kiểm tra tiến trình trên dashboard.") if not options or factor.get("options_loaded"): return job sleep_bounded(POLL_SECONDS, deadline) job = get_job(client, job_id, deadline) return job def ensure_challenge_valid(job, deadline): if time.monotonic() >= deadline: raise TimeoutError("Hết thời gian theo dõi tiến trình.") expires = job.get("otp_expires_at") if isinstance(expires, (int, float)) and expires <= time.time(): raise RuntimeError("Phiên nhập mã đã hết hạn. Đăng nhập lại tài khoản hiện có trên dashboard/API.") def handle_otp(client, job_id, job, deadline): ensure_challenge_valid(job, deadline) factor = job.get("two_factor") or {} automation = factor.get("automation") or {} if automation.get("status") in {"selecting", "waiting", "submitted"}: print("Đang nhận mã tự động; tiếp tục theo dõi.") return job mode = input("Nhận mã: Enter = nhập mã đã nhận, s = gửi SMS, t = gửi thiết bị tin cậy: ").strip().lower() ensure_challenge_valid(job, deadline) if mode == "s": if not factor.get("options_loaded"): job = client.call("POST", f"{PREFIX}/jobs/{job_id}/factor-options")["job"] job = factor_ready(client, job_id, job, deadline, options=True) if job.get("status") != "awaiting_otp": return job factor = job.get("two_factor") or {} phones = factor.get("phones") or [] if not phones: raise RuntimeError("Không có số điện thoại tin cậy để chọn. Dùng dashboard kiểm tra cách nhận mã.") for index, phone in enumerate(phones, 1): label = "".join(char for char in str(phone.get("label", "")) if char.isprintable()) print(f"{index}. {label}") choice = input("Chọn số thứ tự để nhận SMS: ").strip() if not choice.isdigit() or not 1 <= int(choice) <= len(phones): raise RuntimeError("Số thứ tự không hợp lệ.") phone_id = phones[int(choice) - 1].get("id") if type(phone_id) is not int: raise RuntimeError("Máy chủ trả về số điện thoại không hợp lệ.") body = {"mode": "sms", "phone_id": phone_id} elif mode == "t": body = {"mode": "trusted_device"} elif mode == "": if factor and not factor.get("code_sent"): raise RuntimeError("Chưa gửi mã. Chạy lại với --resume-job và chọn cách gửi mã.") body = None else: raise RuntimeError("Lựa chọn không hợp lệ; tiếp tục bằng --resume-job.") if body: retry_at = (job.get("two_factor") or {}).get("resend_available_at") if isinstance(retry_at, (int, float)) and retry_at > time.time(): print("Đang chờ đến lượt gửi mã tiếp theo…") sleep_bounded(min(retry_at - time.time() + 1, 60), deadline) ensure_challenge_valid(job, deadline) job = client.call("POST", f"{PREFIX}/jobs/{job_id}/send-code", body)["job"] job = factor_ready(client, job_id, job, deadline) if job.get("status") != "awaiting_otp": return job for _ in range(5): otp = getpass.getpass("Mã xác thực 6 số (không hiện khi nhập): ").strip() if re.fullmatch(r"[0-9]{6}", otp): ensure_challenge_valid(job, deadline) try: return client.call("POST", f"{PREFIX}/jobs/{job_id}/otp", {"code": otp})["job"] finally: otp = None print("Cần đúng 6 chữ số.") raise RuntimeError("Chưa nhập được mã hợp lệ. Tiếp tục bằng --resume-job.") def wait_for_login(client, job_id, *, timeout=TIMEOUT_SECONDS): deadline = time.monotonic() + timeout last_status = None while time.monotonic() < deadline: job = get_job(client, job_id, deadline) status = job.get("status") if status != last_status: labels = { "queued": "Đang chờ xử lý", "processing": "Đang xử lý", "awaiting_otp": "Cần nhập mã xác thực", "succeeded": "Hoàn tất", "failed": "Không thể hoàn tất", "cancelled": "Đã dừng", } print(labels.get(status, "Đang cập nhật")) last_status = status if status == "succeeded": return record_id(job.get("account_id")) if status in {"failed", "cancelled"}: raise RuntimeError("Tiến trình đã kết thúc. Kiểm tra tài khoản trên dashboard trước khi đăng nhập lại.") if status == "awaiting_otp": handle_otp(client, job_id, job, deadline) sleep_bounded(POLL_SECONDS, deadline) raise TimeoutError("Đã hết thời gian theo dõi; dùng --resume-job để kiểm tra tiếp.") def main(argv=None): parser = argparse.ArgumentParser(description="Đăng nhập Xưởng mã thiết bị qua API Key") parser.add_argument("--base-url", type=base_origin, default="https://workshop.example.com") parser.add_argument("--label", default="Python API", help="Tên gợi nhớ tài khoản") parser.add_argument("--proxy", action="store_true", help="Nhập proxy Apple tại prompt ẩn") parser.add_argument("--account-id", help="Đăng nhập lại tài khoản đã có, giữ account_id") parser.add_argument("--resume-job", help="Chỉ theo dõi tiến trình hiện có; không tạo/login lại") parser.add_argument("--idempotency-key", help="Dùng lại mã đã in khi thử lại đúng thao tác/nội dung cũ") args = parser.parse_args(argv) client = None job_id = None try: api_key = os.environ.get("XID_API_KEY") or getpass.getpass("Thanh Nguyen API Key (không hiện khi nhập): ").strip() if not api_key: raise RuntimeError("Chưa nhập API Key.") client = Client(args.base_url, api_key) api_key = None if args.resume_job: job_id = record_id(args.resume_job) else: operation_id = args.idempotency_key or str(uuid.uuid4()) if IDEMPOTENCY.fullmatch(operation_id) is None: raise RuntimeError("Idempotency-Key cần 8–128 ký tự chữ/số hoặc . _ : -") print("Idempotency-Key:", operation_id) account_id = record_id(args.account_id) if args.account_id else None body = {} if not account_id: body.update(apple_id=input("Apple ID: ").strip(), label=args.label) body["password"] = getpass.getpass("Mật khẩu Apple ID (không lưu): ") if args.proxy: body["proxy"] = {"mode": "direct", "strategy": "per_account", "value": getpass.getpass("Proxy cho Apple (không hiện khi nhập): ").strip()} path = f"{PREFIX}/accounts/{account_id}/login" if account_id else f"{PREFIX}/accounts" try: result = client.call("POST", path, body, operation_id=operation_id) finally: body.clear() job_id = record_id(result["job"]["id"]) print("Job ID:", job_id) print("Nếu cần theo dõi lại: --resume-job", job_id) account_id = wait_for_login(client, job_id) account = client.call("GET", f"{PREFIX}/accounts/{account_id}")["account"] print("Account ID:", account_id) print("device_api:", account.get("device_api") or "Key chưa có quyền xem liên kết") code = client.call("POST", f"{PREFIX}/accounts/{account_id}/code")["code"] if not isinstance(code, str) or re.fullmatch(r"[0-9]{6}", code) is None: raise RuntimeError("Phản hồi mã không hợp lệ.") print("Mã hiện tại:", code) return 0 except APIError as error: print(str(error), file=sys.stderr) if error.account_id: print("Account ID để kiểm tra:", error.account_id, file=sys.stderr) if error.job_id: print("Job ID để theo dõi:", error.job_id, file=sys.stderr) if error.retry_after: print(f"Thử lại sau ít nhất {error.retry_after} giây.", file=sys.stderr) return 1 except (RuntimeError, TimeoutError, ValueError, KeyError, TypeError) as error: # Built-in parse errors should not echo any JSON/credentials. message = str(error) if isinstance(error, (RuntimeError, TimeoutError)) else "Phản hồi hoặc tham số không hợp lệ." print(message, file=sys.stderr) return 1 except (KeyboardInterrupt, EOFError): print("Đã dừng chương trình; tiến trình trên server có thể vẫn chạy.", file=sys.stderr) return 130 finally: if client is not None: client.close() if __name__ == "__main__": raise SystemExit(main())