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
+24 -14
View File
@@ -43,22 +43,32 @@ a no-op. No YAML editing, no token copying.
## Logging into bridges ## Logging into bridges
Open the TUI, press `b` for the bridge screen, pick a network — it opens a DM Press `b` in the TUI for the bridge screen. It shows each bridge's login state
with that bridge's bot. Send `help` to list commands and `login` to start the (via the bridge provisioning API). **Enter starts a guided login wizard**:
login flow. QR codes sent by bots render in-terminal.
| network | bot | login flow | 1. Pick a login method (the bridge advertises its flows: QR, pairing code,
|---|---|---| phone number, browser cookies, ...).
| WhatsApp | `@whatsappbot` | `login qr` (scan with phone) or `login pairing-code` | 2. The wizard walks each step — QR codes render right in the terminal,
| Google Messages | `@gmessagesbot` | `login` → QR scan from the Messages app | text fields become prompts, and the wizard waits while you scan/confirm.
| 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. | 3. On success the bridge starts syncing your chats into the room list.
| 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) |
Cookie flows: the bot explains exactly which cookies to paste. Double puppeting The wizard talks to each bridge's `/_matrix/provision/v3` API, authenticated
is pre-configured for all bridges — your own messages sent from other devices 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. appear as you.
## The TUI ## The TUI
+16 -2
View File
@@ -28,6 +28,8 @@ secret_of() {
} }
REG_SECRET=$(secret_of registration_shared_secret "${REGISTRATION_SHARED_SECRET:-auto}") REG_SECRET=$(secret_of registration_shared_secret "${REGISTRATION_SHARED_SECRET:-auto}")
DP_SECRET=$(secret_of doublepuppet_shared_secret "${DOUBLEPUPPET_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) MACAROON=$(secret_of macaroon_secret_key auto)
FORM=$(secret_of form_secret auto) FORM=$(secret_of form_secret auto)
@@ -97,6 +99,8 @@ echo " - /data/appservices/doublepuppet.yaml" >> "$DATA/synapse/homeserver.yaml
# ── bridges ───────────────────────────────────────────────────────── # ── bridges ─────────────────────────────────────────────────────────
pg() { PGPASSWORD=$POSTGRES_PASSWORD psql -h postgres -U vortex -d postgres -qtAc "$1"; } 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 for b in $ALL_BRIDGES; do
enabled "$b" || continue enabled "$b" || continue
[ "$b" = telegram ] && [ "$telegram_ok" = false ] && continue [ "$b" = telegram ] && [ "$telegram_ok" = false ] && continue
@@ -138,8 +142,7 @@ for b in $ALL_BRIDGES; do
.database.uri = strenv(DB_URI) | .database.uri = strenv(DB_URI) |
.bridge.permissions = {"*": "relay", strenv(DOMAIN): "user", strenv(ADMIN_MXID): "admin"} | .bridge.permissions = {"*": "relay", strenv(DOMAIN): "user", strenv(ADMIN_MXID): "admin"} |
.double_puppet.servers = {} | .double_puppet.servers = {} |
.double_puppet.secrets = {strenv(DOMAIN): ("as_token:" + strenv(DP_SECRET))} | .double_puppet.secrets = {strenv(DOMAIN): ("as_token:" + strenv(DP_SECRET))}
.provisioning.shared_secret = "disable"
' "$cfg" ' "$cfg"
fi fi
if [ "$b" = telegram ]; then if [ "$b" = telegram ]; then
@@ -151,6 +154,12 @@ for b in $ALL_BRIDGES; do
log "rendered $b config" log "rendered $b config"
fi 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. # registration: regenerated deterministically from persisted tokens.
# Written to the bridge dir too, else the mautrix docker-run.sh entrypoint # 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. # regenerates it with fresh tokens and clobbers the ones in config.yaml.
@@ -173,9 +182,14 @@ namespaces:
EOF EOF
cp "$DATA/bridges/$b/registration.yaml" "$DATA/synapse/appservices/$b.yaml" cp "$DATA/bridges/$b/registration.yaml" "$DATA/synapse/appservices/$b.yaml"
echo " - /data/appservices/$b.yaml" >> "$DATA/synapse/homeserver.yaml.new" 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)" log "bridge $b ready (port $port)"
done 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" mv "$DATA/synapse/homeserver.yaml.new" "$DATA/synapse/homeserver.yaml"
chown -R 991:991 "$DATA/synapse" # synapse image runs as uid 991 chown -R 991:991 "$DATA/synapse" # synapse image runs as uid 991
log "done" log "done"
+18 -4
View File
@@ -8,7 +8,7 @@ HS=http://localhost:8008
pass() { echo "PASS: $*"; } pass() { echo "PASS: $*"; }
fail() { echo "FAIL: $*"; exit 1; } 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 docker compose ps --format '{{.Service}} {{.State}} {{.Health}}' | tee /tmp/vortex-ps.txt
for svc in postgres synapse mcp; do for svc in postgres synapse mcp; do
grep -q "^$svc running" /tmp/vortex-ps.txt || fail "$svc not running" grep -q "^$svc running" /tmp/vortex-ps.txt || fail "$svc not running"
@@ -23,7 +23,7 @@ for oneshot in bootstrap provision; do
done done
pass "all services up, one-shots exited 0" 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" curl -sf $HS/health >/dev/null || fail "synapse /health"
TOKEN=$(curl -sf -X POST $HS/_matrix/client/v3/login -d "{ TOKEN=$(curl -sf -X POST $HS/_matrix/client/v3/login -d "{
\"type\":\"m.login.password\", \"type\":\"m.login.password\",
@@ -33,7 +33,7 @@ TOKEN=$(curl -sf -X POST $HS/_matrix/client/v3/login -d "{
[ -n "$TOKEN" ] || fail "login" [ -n "$TOKEN" ] || fail "login"
pass "synapse healthy, admin login works" 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) FIRST_BRIDGE=$(echo "${BRIDGES_ENABLED}" | cut -d, -f1)
BOT="@${FIRST_BRIDGE}bot:${MATRIX_SERVER_NAME}" BOT="@${FIRST_BRIDGE}bot:${MATRIX_SERVER_NAME}"
python3 - "$HS" "$TOKEN" "$BOT" <<'EOF' || fail "bridge bot did not respond" python3 - "$HS" "$TOKEN" "$BOT" <<'EOF' || fail "bridge bot did not respond"
@@ -65,7 +65,21 @@ sys.exit(1)
EOF EOF
pass "appservice round-trip via $BOT" 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" docker compose exec -T mcp python - <<'EOF' || fail "mcp checks"
import asyncio, json, os import asyncio, json, os
from fastmcp import Client from fastmcp import Client
+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 textual.widgets import Footer, Input, Label, ListItem, ListView, RichLog, Static
from . import drafts as drafts_db from . import drafts as drafts_db
from .provisioning import BridgeProvisioning, ProvisioningError, load_bridge_registry
from .qr import looks_like_qr_payload, render_qr from .qr import looks_like_qr_payload, render_qr
from .session import build_client, load_credentials from .session import build_client, load_credentials
@@ -34,15 +35,164 @@ LABELS = {
} }
class BridgeScreen(Screen[str | None]): class LoginWizardScreen(Screen[bool]):
"""List the known bridge bots; Enter opens or creates the bot DM.""" """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: def compose(self) -> ComposeResult:
yield Static( yield Static(
"Bridges - Enter opens (or creates) the bot DM, then send it a login " "Bridges - Enter starts a guided login; m opens the bot DM for "
"command there (e.g. '!wa help', 'login', 'help').", "manual commands.",
id="bridge-help", id="bridge-help",
) )
yield ListView(id="bridge-list") yield ListView(id="bridge-list")
@@ -50,23 +200,75 @@ class BridgeScreen(Screen[str | None]):
def on_mount(self) -> None: def on_mount(self) -> None:
app: VortexApp = self.app app: VortexApp = self.app
self.registry = load_bridge_registry()
lv = self.query_one("#bridge-list", ListView) lv = self.query_one("#bridge-list", ListView)
for net in NETWORKS: for net in NETWORKS:
bot = f"@{net}bot:{app.domain}" bot = f"@{net}bot:{app.domain}"
dm = app.find_bot_dm(bot) item = ListItem(Label(self._row(net, "...")))
status = "DM open" if dm else "no DM (Enter creates one)" item.net, item.bot = net, bot
item = ListItem(Label(f"[{LABELS[net]}] {net:<10} {bot:<32} {status}"))
item.bot, item.dm = bot, dm
lv.append(item) lv.append(item)
lv.focus() 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: async def on_list_view_selected(self, event: ListView.Selected) -> None:
event.stop() event.stop()
item = event.item net = event.item.net
if item.dm: if net not in self.registry:
self.dismiss(item.dm) self.notify(f"{net} is not in BRIDGES_ENABLED", severity="warning")
return 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): if isinstance(resp, RoomCreateResponse):
self.dismiss(resp.room_id) self.dismiss(resp.room_id)
else: else:
@@ -176,6 +378,9 @@ class VortexApp(App):
#rooms { width: 36; border-right: solid $accent; } #rooms { width: 36; border-right: solid $accent; }
#timeline { height: 1fr; } #timeline { height: 1fr; }
#bridge-help, #drafts-help { padding: 0 1; color: $text-muted; } #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" TITLE = "Vortex"
SUB_TITLE = "/reply <text> replies to the last message in the room" SUB_TITLE = "/reply <text> replies to the last message in the room"
+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