mirror of
https://github.com/prdlk/vortex.git
synced 2026-08-02 17:31:41 +00:00
feat: guided bridge login wizard in TUI via bridgev2 provisioning API
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d57086cd00
commit
760d9db800
@@ -0,0 +1,79 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user