"""Client for the mautrix bridgev2 provisioning API (guided login flows). Auth is the user's own Matrix access token (allow_matrix_auth) plus a user_id query param; endpoints are /_matrix/provision/v3/* on each bridge's appservice port, reachable inside the compose network. """ from __future__ import annotations import json import os from pathlib import Path import aiohttp class ProvisioningError(Exception): pass def load_bridge_registry() -> dict: """bridges.json written by bootstrap: {network: {base_url, kind}}.""" path = Path(os.environ.get("BRIDGES_FILE", "/shared/bridges.json")) try: return json.loads(path.read_text()) except (OSError, json.JSONDecodeError): return {} class BridgeProvisioning: def __init__(self, base_url: str, user_id: str, access_token: str): self.base = base_url.rstrip("/") + "/_matrix/provision" self.params = {"user_id": user_id} self.headers = {"Authorization": f"Bearer {access_token}"} async def _req(self, method: str, path: str, body: dict | None = None, timeout: float = 30) -> dict: try: async with aiohttp.ClientSession() as session: async with session.request( method, self.base + path, params=self.params, headers=self.headers, json=body, timeout=aiohttp.ClientTimeout(total=timeout), ) as resp: data = await resp.json(content_type=None) if resp.status != 200: raise ProvisioningError( (data or {}).get("error") or f"HTTP {resp.status}" ) return data except aiohttp.ClientError as exc: raise ProvisioningError(f"bridge unreachable: {exc}") from exc async def whoami(self) -> dict: return await self._req("GET", "/v3/whoami") async def flows(self) -> list[dict]: return (await self._req("GET", "/v3/login/flows"))["flows"] async def start(self, flow_id: str) -> dict: """Returns {login_id, type, step_id, instructions, }.""" return await self._req("POST", f"/v3/login/start/{flow_id}") async def step(self, login_id: str, step_id: str, step_type: str, fields: dict[str, str] | None = None) -> dict: """Submit a step. For display_and_wait this long-polls until the user scans/confirms (or the code expires and a refreshed step returns).""" body = fields if step_type in ("user_input", "cookies") else None timeout = 310 if step_type == "display_and_wait" else 60 return await self._req( "POST", f"/v3/login/step/{login_id}/{step_id}/{step_type}", body=body, timeout=timeout, ) async def cancel(self, login_id: str) -> None: try: await self._req("POST", f"/v3/login/cancel/{login_id}") except ProvisioningError: pass # cancelling a finished/expired login is fine