From 760d9db800f6fd37d89dd9d68f5760990f7d5247 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Wed, 8 Jul 2026 12:36:26 -0400 Subject: [PATCH] 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 --- README.md | 38 +++-- bootstrap/bootstrap.sh | 18 ++- scripts/verify.sh | 22 ++- tui/app/vortex_tui/main.py | 231 +++++++++++++++++++++++++++-- tui/app/vortex_tui/provisioning.py | 79 ++++++++++ 5 files changed, 355 insertions(+), 33 deletions(-) create mode 100644 tui/app/vortex_tui/provisioning.py diff --git a/README.md b/README.md index 73276da..c37a0d0 100644 --- a/README.md +++ b/README.md @@ -43,22 +43,32 @@ a no-op. No YAML editing, no token copying. ## Logging into bridges -Open the TUI, press `b` for the bridge screen, pick a network — it opens a DM -with that bridge's bot. Send `help` to list commands and `login` to start the -login flow. QR codes sent by bots render in-terminal. +Press `b` in the TUI for the bridge screen. It shows each bridge's login state +(via the bridge provisioning API). **Enter starts a guided login wizard**: -| network | bot | login flow | -|---|---|---| -| WhatsApp | `@whatsappbot` | `login qr` (scan with phone) or `login pairing-code` | -| Google Messages | `@gmessagesbot` | `login` → QR scan from the Messages app | -| Telegram | `@telegrambot` | `login` → phone number + code. **Requires `TELEGRAM_API_ID`/`TELEGRAM_API_HASH` in `.env`** (get them at [my.telegram.org](https://my.telegram.org)). Without them the bridge is skipped with a log line. | -| X/Twitter | `@twitterbot` | `login cookies` — paste browser cookies | -| LinkedIn | `@linkedinbot` | `login cookies` | -| Discord | `@discordbot` | `login qr` (scan with Discord app) or `login token` | -| Instagram | `@metabot` | `login cookies` (bridge runs in `instagram` mode) | +1. Pick a login method (the bridge advertises its flows: QR, pairing code, + phone number, browser cookies, ...). +2. The wizard walks each step — QR codes render right in the terminal, + text fields become prompts, and the wizard waits while you scan/confirm. +3. On success the bridge starts syncing your chats into the room list. -Cookie flows: the bot explains exactly which cookies to paste. Double puppeting -is pre-configured for all bridges — your own messages sent from other devices +The wizard talks to each bridge's `/_matrix/provision/v3` API, authenticated +with your own Matrix access token — no extra secrets to configure. + +| network | typical flows | +|---|---| +| WhatsApp | QR scan or phone pairing code | +| Google Messages | Google account / QR from the Messages app | +| Telegram | phone number + code. **Requires `TELEGRAM_API_ID`/`TELEGRAM_API_HASH` in `.env`** ([my.telegram.org](https://my.telegram.org)); without them the bridge is skipped. | +| X/Twitter | browser cookies (wizard prompts for each value) | +| LinkedIn | browser cookies | +| Discord | manual only — legacy bridge without the v3 provisioning API. Press `m` to open the bot DM and use `login qr` or `login token`. | +| Instagram | browser cookies (bridge runs in `instagram` mode) | + +`m` on any bridge opens the bot DM for manual commands (`help`, `logout`, +relay settings, ...). Passkey/WebAuthn flows aren't supported in a terminal — +use the corresponding cookie or QR flow instead. Double puppeting is +pre-configured for all bridges — your own messages sent from other devices appear as you. ## The TUI diff --git a/bootstrap/bootstrap.sh b/bootstrap/bootstrap.sh index 47ee0d9..e8646d4 100644 --- a/bootstrap/bootstrap.sh +++ b/bootstrap/bootstrap.sh @@ -28,6 +28,8 @@ secret_of() { } REG_SECRET=$(secret_of registration_shared_secret "${REGISTRATION_SHARED_SECRET:-auto}") DP_SECRET=$(secret_of doublepuppet_shared_secret "${DOUBLEPUPPET_SHARED_SECRET:-auto}") +PROV_SECRET=$(secret_of provisioning_shared_secret auto) +export PROV_SECRET MACAROON=$(secret_of macaroon_secret_key auto) FORM=$(secret_of form_secret auto) @@ -97,6 +99,8 @@ echo " - /data/appservices/doublepuppet.yaml" >> "$DATA/synapse/homeserver.yaml # ── bridges ───────────────────────────────────────────────────────── pg() { PGPASSWORD=$POSTGRES_PASSWORD psql -h postgres -U vortex -d postgres -qtAc "$1"; } +BRIDGES_JSON="{" # network -> provisioning endpoint info, consumed by the TUI + for b in $ALL_BRIDGES; do enabled "$b" || continue [ "$b" = telegram ] && [ "$telegram_ok" = false ] && continue @@ -138,8 +142,7 @@ for b in $ALL_BRIDGES; do .database.uri = strenv(DB_URI) | .bridge.permissions = {"*": "relay", strenv(DOMAIN): "user", strenv(ADMIN_MXID): "admin"} | .double_puppet.servers = {} | - .double_puppet.secrets = {strenv(DOMAIN): ("as_token:" + strenv(DP_SECRET))} | - .provisioning.shared_secret = "disable" + .double_puppet.secrets = {strenv(DOMAIN): ("as_token:" + strenv(DP_SECRET))} ' "$cfg" fi if [ "$b" = telegram ]; then @@ -151,6 +154,12 @@ for b in $ALL_BRIDGES; do log "rendered $b config" fi + # applied every run (idempotent) so existing configs pick up changes: + # provisioning API on with matrix-token auth, for the TUI's guided login + if [ "$b" != discord ]; then + yq -i '.provisioning.shared_secret = strenv(PROV_SECRET) | .provisioning.allow_matrix_auth = true' "$cfg" + fi + # registration: regenerated deterministically from persisted tokens. # Written to the bridge dir too, else the mautrix docker-run.sh entrypoint # regenerates it with fresh tokens and clobbers the ones in config.yaml. @@ -173,9 +182,14 @@ namespaces: EOF cp "$DATA/bridges/$b/registration.yaml" "$DATA/synapse/appservices/$b.yaml" echo " - /data/appservices/$b.yaml" >> "$DATA/synapse/homeserver.yaml.new" + kind=v2; [ "$b" = discord ] && kind=legacy + BRIDGES_JSON="$BRIDGES_JSON\"$b\": {\"base_url\": \"http://$b:$port\", \"kind\": \"$kind\"}," log "bridge $b ready (port $port)" done +echo "${BRIDGES_JSON%,}}" | yq -p json -o json > "$DATA/shared/bridges.json" +chmod a+r "$DATA/shared/bridges.json" + mv "$DATA/synapse/homeserver.yaml.new" "$DATA/synapse/homeserver.yaml" chown -R 991:991 "$DATA/synapse" # synapse image runs as uid 991 log "done" diff --git a/scripts/verify.sh b/scripts/verify.sh index e466c71..1ba5d63 100644 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -8,7 +8,7 @@ HS=http://localhost:8008 pass() { echo "PASS: $*"; } fail() { echo "FAIL: $*"; exit 1; } -echo "== 1/4 containers" +echo "== 1/5 containers" docker compose ps --format '{{.Service}} {{.State}} {{.Health}}' | tee /tmp/vortex-ps.txt for svc in postgres synapse mcp; do grep -q "^$svc running" /tmp/vortex-ps.txt || fail "$svc not running" @@ -23,7 +23,7 @@ for oneshot in bootstrap provision; do done pass "all services up, one-shots exited 0" -echo "== 2/4 synapse health + login" +echo "== 2/5 synapse health + login" curl -sf $HS/health >/dev/null || fail "synapse /health" TOKEN=$(curl -sf -X POST $HS/_matrix/client/v3/login -d "{ \"type\":\"m.login.password\", @@ -33,7 +33,7 @@ TOKEN=$(curl -sf -X POST $HS/_matrix/client/v3/login -d "{ [ -n "$TOKEN" ] || fail "login" pass "synapse healthy, admin login works" -echo "== 3/4 bridge bot round-trip" +echo "== 3/5 bridge bot round-trip" FIRST_BRIDGE=$(echo "${BRIDGES_ENABLED}" | cut -d, -f1) BOT="@${FIRST_BRIDGE}bot:${MATRIX_SERVER_NAME}" python3 - "$HS" "$TOKEN" "$BOT" <<'EOF' || fail "bridge bot did not respond" @@ -65,7 +65,21 @@ sys.exit(1) EOF pass "appservice round-trip via $BOT" -echo "== 4/4 MCP tools + draft gating" +echo "== 4/5 bridge provisioning API (guided TUI login)" +docker compose exec -T mcp python - "$TOKEN" <<'EOF' || fail "provisioning API" +import json, sys, urllib.request, urllib.parse +tok = sys.argv[1] +bridges = json.load(open("/shared/bridges.json")) +net, info = next((n, i) for n, i in bridges.items() if i["kind"] == "v2") +url = f"{info['base_url']}/_matrix/provision/v3/login/flows?user_id=%40admin%3Alocalhost" +req = urllib.request.Request(url, headers={"Authorization": "Bearer " + tok}) +flows = json.load(urllib.request.urlopen(req))["flows"] +assert flows, "no login flows" +print(f"{net} login flows: {[f['id'] for f in flows]}") +EOF +pass "provisioning API reachable with matrix-token auth" + +echo "== 5/5 MCP tools + draft gating" docker compose exec -T mcp python - <<'EOF' || fail "mcp checks" import asyncio, json, os from fastmcp import Client diff --git a/tui/app/vortex_tui/main.py b/tui/app/vortex_tui/main.py index bb5cb26..ccebd08 100644 --- a/tui/app/vortex_tui/main.py +++ b/tui/app/vortex_tui/main.py @@ -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 replies to the last message in the room" diff --git a/tui/app/vortex_tui/provisioning.py b/tui/app/vortex_tui/provisioning.py new file mode 100644 index 0000000..e11f8b8 --- /dev/null +++ b/tui/app/vortex_tui/provisioning.py @@ -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, }.""" + 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