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
@@ -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
|
||||
|
||||
+16
-2
@@ -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"
|
||||
|
||||
+18
-4
@@ -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
|
||||
|
||||
+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"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user