"""DingTalk Device Flow authorization.""" from __future__ import annotations import contextlib import os import sys import time from typing import Optional, Tuple import requests REGISTRATION_BASE_URL = os.environ.get("DINGTALK_REGISTRATION_BASE_URL", "https://oapi.dingtalk.com").rstrip("/") REGISTRATION_SOURCE = os.environ.get("DINGTALK_REGISTRATION_SOURCE", "openClaw") _POLL_STATUSES = {"WAITING", "SUCCESS", "FAIL", "EXPIRED"} _RETRY_WINDOW = 120 # seconds of transient errors / non-success statuses tolerated before giving up class RegistrationError(Exception): """Raised when a DingTalk registration API call fails.""" def _api_post(path: str, payload: dict) -> dict: """POST to the registration API and return the parsed JSON body.""" url = f"{REGISTRATION_BASE_URL}{path}" try: resp = requests.post(url, json=payload, timeout=15) resp.raise_for_status() data = resp.json() except requests.RequestException as exc: raise RegistrationError(f"Network error calling {url}: {exc}") from exc errcode = data.get("errcode", -1) if errcode != 0: raise RegistrationError(f"API error [{path}]: {data.get('errmsg', 'unknown error')} (errcode={errcode})") return data def begin_registration() -> dict: """Start a device-flow registration: init → nonce, begin → device_code + verification URL.""" nonce = str(_api_post("/app/registration/init", {"source": REGISTRATION_SOURCE}).get("nonce", "")).strip() if not nonce: raise RegistrationError("init response missing nonce") begin_data = _api_post("/app/registration/begin", {"nonce": nonce}) reg = {key: str(begin_data.get(key, "")).strip() for key in ("device_code", "verification_uri_complete")} for key, value in reg.items(): if not value: raise RegistrationError(f"begin response missing {key}") reg["expires_in"] = int(begin_data.get("expires_in", 7200)) reg["interval"] = max(int(begin_data.get("interval", 3)), 2) return reg def poll_registration(device_code: str) -> dict: """Poll the registration status once.""" data = _api_post("/app/registration/poll", {"device_code": device_code}) status_raw = str(data.get("status", "")).strip().upper() result = {"status": status_raw if status_raw in _POLL_STATUSES else "UNKNOWN"} result.update({key: str(data.get(key, "")).strip() or None for key in ("client_id", "client_secret", "fail_reason")}) return result def wait_for_registration_success( device_code: str, interval: int = 3, expires_in: int = 7200, on_waiting: Optional[callable] = None, ) -> Tuple[str, str]: """Block until the registration succeeds or times out. Transient errors and FAIL/EXPIRED/UNKNOWN statuses are retried for ``_RETRY_WINDOW`` seconds before being raised; a WAITING status resets that window. """ deadline = time.monotonic() + expires_in retry_start = 0.0 def _within_retry_window() -> bool: nonlocal retry_start if retry_start == 0: retry_start = time.monotonic() return time.monotonic() - retry_start < _RETRY_WINDOW while time.monotonic() < deadline: time.sleep(interval) try: result = poll_registration(device_code) except RegistrationError: if _within_retry_window(): continue raise status = result["status"] if status == "WAITING": retry_start = 0 if on_waiting: on_waiting() elif status == "SUCCESS": cid, csecret = result["client_id"], result["client_secret"] if not cid or not csecret: raise RegistrationError("authorization succeeded but credentials are missing") return cid, csecret elif not _within_retry_window(): raise RegistrationError(f"authorization failed: {result.get('fail_reason') or status}") raise RegistrationError("authorization timed out, please retry") def _ensure_qrcode_installed() -> bool: """Enable DingTalk dependencies; only render QR codes importable in this process.""" with contextlib.suppress(ImportError): import qrcode # noqa: F401 return True import pm with contextlib.suppress(pm.InstallError, OSError, ValueError): pm.sync_venv(["dingtalk"], explicit=True) # PM selects a new generation for the next launch, never this process. # The authorization link works without qrcode, so no restart is required here. return False def render_qr_to_terminal(url: str) -> bool: """Render *url* as a compact QR code (half-block glyphs, 2 rows per character) in the terminal.""" try: import qrcode except ImportError: return False qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=1, border=1) qr.add_data(url) qr.make(fit=True) matrix = qr.get_matrix() glyph = {(True, True): "\u2588", (True, False): "\u2580", (False, True): "\u2584", (False, False): " "} # █ ▀ ▄ space lines = [] for r in range(0, len(matrix), 2): bottom_row = matrix[r + 1] if r + 1 < len(matrix) else [False] * len(matrix[r]) lines.append(" " + "".join(glyph[(bool(top), bool(bottom))] for top, bottom in zip(matrix[r], bottom_row))) print("\n".join(lines)) return True def dingtalk_qr_auth() -> Optional[Tuple[str, str]]: """Run the interactive QR-code device-flow authorization (setup wizard entry point).""" from hermes_cli.setup import print_info, print_success, print_warning, print_error print() print_info(" Initializing DingTalk device authorization...") print_info(" Note: the scan page is branded 'OpenClaw' — DingTalk's") print_info(" ecosystem onboarding bridge. Safe to use.") try: reg = begin_registration() except RegistrationError as exc: print_error(f" Authorization init failed: {exc}") return None url = reg["verification_uri_complete"] if not _ensure_qrcode_installed(): print_warning(" QR rendering is unavailable in this process; using the authorization link.") print() print_info(" Please scan the QR code below with DingTalk to authorize:") print() if not render_qr_to_terminal(url): print_warning(" QR code render failed, please open the link below to authorize:") print() print_info(f" Or open this link manually: {url}") print() print_info(" Waiting for QR scan authorization... (timeout: 2 hours)") dot_count = 0 def _on_waiting(): nonlocal dot_count dot_count += 1 if dot_count % 10 == 0: sys.stdout.write(".") sys.stdout.flush() try: client_id, client_secret = wait_for_registration_success( device_code=reg["device_code"], interval=reg["interval"], expires_in=reg["expires_in"], on_waiting=_on_waiting, ) except RegistrationError as exc: print() print_error(f" Authorization failed: {exc}") return None print() print_success(" QR scan authorization successful!") print_success(f" Client ID: {client_id}") print_success(f" Client Secret: {client_secret[:8]}{'*' * (len(client_secret) - 8)}") return client_id, client_secret # ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ---- # Names external plugins imported from this module before the Sep 2026 decomposition. # Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does). # The whole block is removed by reverting the commit that added it. import logging # noqa: F401,E402 _PLUGIN_COMPAT_LAZY = { 'logger': ('hermes_cli.auth', 'logger'), } def __getattr__(name): # PEP 562 — lazy so no import cycles target = _PLUGIN_COMPAT_LAZY.get(name) if target is None: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") import importlib from hermes_cli.plugin_compat import warn_once warn_once(__name__, name, *target) return getattr(importlib.import_module(target[0]), target[1]) # ---- END PLUGIN-COMPAT ----