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
+218 -13
View File
@@ -18,6 +18,7 @@ from textual.screen import Screen
from textual.widgets import Footer, Input, Label, ListItem, ListView, RichLog, Static
from . import drafts as drafts_db
from .provisioning import BridgeProvisioning, ProvisioningError, load_bridge_registry
from .qr import looks_like_qr_payload, render_qr
from .session import build_client, load_credentials
@@ -34,15 +35,164 @@ LABELS = {
}
class BridgeScreen(Screen[str | None]):
"""List the known bridge bots; Enter opens or creates the bot DM."""
class LoginWizardScreen(Screen[bool]):
"""Guided bridge login driven by the bridgev2 provisioning API.
BINDINGS = [Binding("escape", "back", "Back")]
Step loop: pick a flow -> user_input/cookies steps become sequential
prompts -> display_and_wait steps render the QR/code and long-poll until
scanned -> complete.
"""
BINDINGS = [Binding("escape", "cancel", "Cancel login")]
def __init__(self, network: str, prov: BridgeProvisioning):
super().__init__()
self.network = network
self.prov = prov
self.login_id: str | None = None
self._fields: list[dict] = []
self._values: dict[str, str] = {}
self._step: dict | None = None
def compose(self) -> ComposeResult:
yield Static(f"Login to {self.network} - esc cancels", id="wiz-title")
yield Static("Loading login flows...", id="wiz-status")
yield ListView(id="wiz-flows")
yield RichLog(id="wiz-display", wrap=True, markup=False, highlight=False)
yield Input(id="wiz-input")
yield Footer()
def on_mount(self) -> None:
self.query_one("#wiz-input", Input).display = False
self.run_worker(self._load_flows(), exclusive=True)
def _status(self, text: str) -> None:
self.query_one("#wiz-status", Static).update(text)
async def _load_flows(self) -> None:
try:
flows = await self.prov.flows()
except ProvisioningError as exc:
self._status(f"Cannot reach bridge: {exc}")
return
lv = self.query_one("#wiz-flows", ListView)
for flow in flows:
item = ListItem(Label(f"{flow['name']} - {flow.get('description', '')}"))
item.flow_id = flow["id"]
await lv.append(item)
self._status("Pick a login method (Enter):")
lv.focus()
async def on_list_view_selected(self, event: ListView.Selected) -> None:
event.stop()
flow_id = getattr(event.item, "flow_id", None)
if not flow_id or self.login_id:
return
self.query_one("#wiz-flows", ListView).display = False
self._status("Starting login...")
self.run_worker(self._start(flow_id), exclusive=True)
async def _start(self, flow_id: str) -> None:
try:
resp = await self.prov.start(flow_id)
except ProvisioningError as exc:
self._status(f"Login start failed: {exc}")
return
self.login_id = resp["login_id"]
await self._handle_step(resp)
async def _handle_step(self, step: dict) -> None:
self._step = step
kind = step["type"]
instructions = step.get("instructions") or ""
display = self.query_one("#wiz-display", RichLog)
if kind == "complete":
self.notify(f"{self.network}: logged in.")
self.dismiss(True)
elif kind in ("user_input", "cookies"):
params = step.get(kind) or {}
self._fields = list(params.get("fields") or [])
self._values = {}
if url := params.get("url"):
display.write(f"Open {url} in a browser to obtain the values.")
self._status(instructions or "Fill in the requested values:")
self._next_field()
elif kind == "display_and_wait":
params = step.get("display_and_wait") or {}
display.clear()
if instructions:
display.write(instructions)
if params.get("type") == "qr":
display.write(render_qr(params.get("data", "")))
display.write(params.get("data", ""))
else:
display.write(f"Code: {params.get('data', '')}")
self._status("Waiting for you to scan/confirm (refreshes automatically)...")
self.run_worker(self._wait(step), exclusive=True)
else: # webauthn etc.
self._status(f"Unsupported step type {kind!r} - use the bot DM instead.")
async def _wait(self, step: dict) -> None:
try:
resp = await self.prov.step(self.login_id, step["step_id"], step["type"])
except ProvisioningError as exc:
self._status(f"Login failed: {exc}")
return
await self._handle_step(resp)
def _next_field(self) -> None:
box = self.query_one("#wiz-input", Input)
if not self._fields:
box.display = False
self.run_worker(self._submit_fields(), exclusive=True)
return
field = self._fields[0]
name = field.get("name") or field.get("id")
desc = field.get("description") or ""
box.placeholder = f"{name}{' - ' + desc if desc else ''}"
box.password = field.get("type") == "password"
box.value = field.get("default_value") or ""
box.display = True
box.focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
event.stop()
if event.input.id != "wiz-input" or not self._fields:
return
field = self._fields.pop(0)
self._values[field["id"]] = event.value.strip()
event.input.value = ""
self._next_field()
async def _submit_fields(self) -> None:
self._status("Submitting...")
try:
resp = await self.prov.step(
self.login_id, self._step["step_id"], self._step["type"], self._values
)
except ProvisioningError as exc:
self._status(f"Login failed: {exc}")
return
await self._handle_step(resp)
def action_cancel(self) -> None:
if self.login_id:
self.run_worker(self.prov.cancel(self.login_id))
self.dismiss(False)
class BridgeScreen(Screen[str | None]):
"""Bridge accounts: Enter starts a guided login, m opens the bot DM."""
BINDINGS = [
Binding("escape", "back", "Back"),
Binding("m", "bot_dm", "Bot DM"),
]
def compose(self) -> ComposeResult:
yield Static(
"Bridges - Enter opens (or creates) the bot DM, then send it a login "
"command there (e.g. '!wa help', 'login', 'help').",
"Bridges - Enter starts a guided login; m opens the bot DM for "
"manual commands.",
id="bridge-help",
)
yield ListView(id="bridge-list")
@@ -50,23 +200,75 @@ class BridgeScreen(Screen[str | None]):
def on_mount(self) -> None:
app: VortexApp = self.app
self.registry = load_bridge_registry()
lv = self.query_one("#bridge-list", ListView)
for net in NETWORKS:
bot = f"@{net}bot:{app.domain}"
dm = app.find_bot_dm(bot)
status = "DM open" if dm else "no DM (Enter creates one)"
item = ListItem(Label(f"[{LABELS[net]}] {net:<10} {bot:<32} {status}"))
item.bot, item.dm = bot, dm
item = ListItem(Label(self._row(net, "...")))
item.net, item.bot = net, bot
lv.append(item)
lv.focus()
self.run_worker(self._load_statuses(), exclusive=True)
def _row(self, net: str, status: str) -> str:
return f"[{LABELS[net]}] {net:<10} {status}"
def _prov(self, net: str) -> BridgeProvisioning | None:
info = self.registry.get(net)
if not info or info.get("kind") != "v2":
return None
app: VortexApp = self.app
return BridgeProvisioning(
info["base_url"], app.client.user_id, app.client.access_token
)
async def _load_statuses(self) -> None:
for item in list(self.query_one("#bridge-list", ListView).children):
net = item.net
if net not in self.registry:
status = "not running (check BRIDGES_ENABLED / TELEGRAM_API_* in .env)"
elif (prov := self._prov(net)) is None:
status = "manual login via bot DM (press m)"
else:
try:
logins = (await prov.whoami()).get("logins") or []
status = (
f"logged in: {', '.join(l.get('name') or l['id'] for l in logins)}"
if logins
else "not logged in (Enter to log in)"
)
except ProvisioningError:
status = "bridge unreachable"
item.query_one(Label).update(self._row(net, status))
async def on_list_view_selected(self, event: ListView.Selected) -> None:
event.stop()
item = event.item
if item.dm:
self.dismiss(item.dm)
net = event.item.net
if net not in self.registry:
self.notify(f"{net} is not in BRIDGES_ENABLED", severity="warning")
return
resp = await self.app.client.room_create(invite=[item.bot], is_direct=True)
prov = self._prov(net)
if prov is None:
await self._open_bot_dm(event.item.bot)
return
def done(_ok: bool | None) -> None:
self.run_worker(self._load_statuses(), exclusive=True)
self.app.push_screen(LoginWizardScreen(net, prov), done)
async def action_bot_dm(self) -> None:
item = self.query_one("#bridge-list", ListView).highlighted_child
if item is not None:
await self._open_bot_dm(item.bot)
async def _open_bot_dm(self, bot: str) -> None:
app: VortexApp = self.app
dm = app.find_bot_dm(bot)
if dm:
self.dismiss(dm)
return
resp = await app.client.room_create(invite=[bot], is_direct=True)
if isinstance(resp, RoomCreateResponse):
self.dismiss(resp.room_id)
else:
@@ -176,6 +378,9 @@ class VortexApp(App):
#rooms { width: 36; border-right: solid $accent; }
#timeline { height: 1fr; }
#bridge-help, #drafts-help { padding: 0 1; color: $text-muted; }
#wiz-title, #wiz-status { padding: 0 1; }
#wiz-flows { max-height: 10; }
#wiz-display { height: 1fr; }
"""
TITLE = "Vortex"
SUB_TITLE = "/reply <text> replies to the last message in the room"