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:
Prad Nukala
2026-07-08 12:36:26 -04:00
co-authored by Claude Fable 5
parent d57086cd00
commit 760d9db800
5 changed files with 355 additions and 33 deletions
+79
View File
@@ -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