mirror of
https://github.com/prdlk/vortex.git
synced 2026-08-02 17:31:41 +00:00
Bootstrap now enables each bridge's provisioning API (persisted shared secret, matrix-token auth) and publishes /shared/bridges.json. The TUI bridge screen shows per-bridge login state and launches a step-driven wizard: flow picker, sequential input prompts (phone/cookies/password), in-terminal QR rendering with long-poll wait, cancel support. Discord (legacy bridge, no v3 API) keeps the bot-DM path via 'm'. verify.sh gains a provisioning API smoke check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
80 lines
3.0 KiB
Python
80 lines
3.0 KiB
Python
"""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, <params>}."""
|
|
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
|