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
+218
-13
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user