Add Telegram QR onboarding to dashboard

This commit is contained in:
Shannon Sands
2026-06-03 13:24:03 +10:00
committed by Teknium
parent 5300727a08
commit 2f0c8e90e6
7 changed files with 1156 additions and 75 deletions

View File

@ -14,11 +14,14 @@ from contextlib import asynccontextmanager
import asyncio
import base64
import binascii
from dataclasses import dataclass
from datetime import datetime, timezone
import hmac
import importlib.util
import json
import logging
import os
import re
import secrets
import stat
import subprocess
@ -26,6 +29,7 @@ import sys
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
@ -557,6 +561,14 @@ class MessagingPlatformUpdate(BaseModel):
clear_env: List[str] = []
class TelegramOnboardingStart(BaseModel):
bot_name: Optional[str] = None
class TelegramOnboardingApply(BaseModel):
allowed_user_ids: List[str]
class AudioTranscriptionRequest(BaseModel):
data_url: str
mime_type: Optional[str] = None
@ -3050,6 +3062,329 @@ def _write_platform_enabled(platform_id: str, enabled: bool) -> None:
save_config(config)
_TELEGRAM_ONBOARDING_DEFAULT_URL = "https://setup.hermes-agent.nousresearch.com"
_TELEGRAM_USER_ID_RE = re.compile(r"^\d+$")
@dataclass
class _TelegramOnboardingPairing:
poll_token: str
expires_at: str
expires_at_ts: float
bot_token: str | None = None
bot_username: str | None = None
owner_user_id: str | None = None
_telegram_onboarding_pairings: dict[str, _TelegramOnboardingPairing] = {}
_telegram_onboarding_lock = threading.RLock()
def _telegram_onboarding_base_url() -> str:
return (
os.getenv("TELEGRAM_ONBOARDING_URL", _TELEGRAM_ONBOARDING_DEFAULT_URL)
.strip()
.rstrip("/")
)
def _parse_expiry_ts(value: str) -> float:
try:
normalized = value.replace("Z", "+00:00")
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.timestamp()
except Exception:
return time.time() + 600
def _prune_telegram_onboarding_pairings() -> None:
now = time.time()
expired = [
pairing_id
for pairing_id, record in _telegram_onboarding_pairings.items()
if record.expires_at_ts <= now
]
for pairing_id in expired:
_telegram_onboarding_pairings.pop(pairing_id, None)
def _normalize_telegram_user_id(value: Any) -> str | None:
normalized = str(value or "").strip()
if _TELEGRAM_USER_ID_RE.fullmatch(normalized):
return normalized
return None
def _telegram_onboarding_error_message(error: str, fallback: str) -> str:
return {
"not_found": "Telegram pairing was not found. Start a new setup.",
"expired": "Telegram setup expired. Start a new setup.",
"claimed": "Telegram setup was already claimed. Start a new setup.",
"unauthorized": "Telegram setup service rejected this request.",
"telegram_manager_bot_token_not_configured": "Telegram setup service is not configured.",
"telegram_token_fetch_failed": "Telegram could not finish bot setup. Try again.",
}.get(error, fallback)
def _telegram_onboarding_request_sync(
method: str,
path: str,
*,
body: dict[str, Any] | None = None,
bearer_token: str | None = None,
) -> dict[str, Any]:
data = None
headers = {"Accept": "application/json"}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
if bearer_token:
headers["Authorization"] = f"Bearer {bearer_token}"
request = urllib.request.Request(
f"{_telegram_onboarding_base_url()}{path}",
data=data,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
payload = response.read()
except urllib.error.HTTPError as exc:
payload = exc.read()
try:
parsed = json.loads(payload.decode("utf-8"))
except Exception:
parsed = {}
error = str(parsed.get("error") or parsed.get("status") or "")
detail = _telegram_onboarding_error_message(
error,
"Telegram setup service returned an error.",
)
status_code = 404 if exc.code == 404 else 502
if error in {"expired", "claimed"}:
status_code = 410
raise HTTPException(status_code=status_code, detail=detail) from exc
except Exception as exc:
raise HTTPException(
status_code=502,
detail="Telegram setup service is unavailable. Try again shortly.",
) from exc
try:
parsed = json.loads(payload.decode("utf-8"))
except Exception as exc:
raise HTTPException(
status_code=502,
detail="Telegram setup service returned an invalid response.",
) from exc
if not isinstance(parsed, dict):
raise HTTPException(
status_code=502,
detail="Telegram setup service returned an invalid response.",
)
return parsed
async def _telegram_onboarding_request(
method: str,
path: str,
*,
body: dict[str, Any] | None = None,
bearer_token: str | None = None,
) -> dict[str, Any]:
return await asyncio.to_thread(
_telegram_onboarding_request_sync,
method,
path,
body=body,
bearer_token=bearer_token,
)
@app.post("/api/messaging/telegram/onboarding/start")
async def start_telegram_onboarding(body: TelegramOnboardingStart):
bot_name = (body.bot_name or "Hermes Agent").strip() or "Hermes Agent"
payload = await _telegram_onboarding_request(
"POST",
"/v1/telegram/pairings",
body={"bot_name": bot_name},
)
pairing_id = str(payload.get("pairing_id") or "").strip()
poll_token = str(payload.get("poll_token") or "").strip()
expires_at = str(payload.get("expires_at") or "").strip()
deep_link = str(payload.get("deep_link") or "").strip()
qr_payload = str(payload.get("qr_payload") or deep_link).strip()
suggested_username = str(payload.get("suggested_username") or "").strip()
if not pairing_id or not poll_token or not expires_at or not deep_link:
raise HTTPException(
status_code=502,
detail="Telegram setup service returned an incomplete response.",
)
with _telegram_onboarding_lock:
_prune_telegram_onboarding_pairings()
_telegram_onboarding_pairings[pairing_id] = _TelegramOnboardingPairing(
poll_token=poll_token,
expires_at=expires_at,
expires_at_ts=_parse_expiry_ts(expires_at),
)
return {
"pairing_id": pairing_id,
"suggested_username": suggested_username,
"deep_link": deep_link,
"qr_payload": qr_payload,
"expires_at": expires_at,
}
@app.get("/api/messaging/telegram/onboarding/{pairing_id}")
async def get_telegram_onboarding_status(pairing_id: str):
with _telegram_onboarding_lock:
_prune_telegram_onboarding_pairings()
record = _telegram_onboarding_pairings.get(pairing_id)
if not record:
raise HTTPException(
status_code=404,
detail="Telegram setup session was not found. Start a new setup.",
)
if record.bot_token:
return {
"status": "ready",
"bot_username": record.bot_username,
"owner_user_id": record.owner_user_id,
"expires_at": record.expires_at,
}
poll_token = record.poll_token
payload = await _telegram_onboarding_request(
"GET",
f"/v1/telegram/pairings/{urllib.parse.quote(pairing_id, safe='')}",
bearer_token=poll_token,
)
status = str(payload.get("status") or "").strip()
if status == "waiting":
with _telegram_onboarding_lock:
current = _telegram_onboarding_pairings.get(pairing_id)
expires_at = current.expires_at if current else ""
return {"status": "waiting", "expires_at": expires_at}
if status == "ready":
bot_token = str(payload.get("token") or "").strip()
bot_username = str(payload.get("bot_username") or "").strip()
if not bot_token:
raise HTTPException(
status_code=502,
detail="Telegram setup service returned an incomplete response.",
)
owner_user_id = _normalize_telegram_user_id(payload.get("owner_user_id"))
with _telegram_onboarding_lock:
record = _telegram_onboarding_pairings.get(pairing_id)
if not record:
raise HTTPException(
status_code=404,
detail="Telegram setup session was not found. Start a new setup.",
)
record.bot_token = bot_token
record.bot_username = bot_username or None
record.owner_user_id = owner_user_id
return {
"status": "ready",
"bot_username": record.bot_username,
"owner_user_id": record.owner_user_id,
"expires_at": record.expires_at,
}
if status in {"expired", "claimed"}:
with _telegram_onboarding_lock:
_telegram_onboarding_pairings.pop(pairing_id, None)
raise HTTPException(
status_code=410,
detail=_telegram_onboarding_error_message(
status,
"Telegram setup is no longer available. Start a new setup.",
),
)
raise HTTPException(
status_code=502,
detail="Telegram setup service returned an unknown status.",
)
@app.post("/api/messaging/telegram/onboarding/{pairing_id}/apply")
async def apply_telegram_onboarding(
pairing_id: str, body: TelegramOnboardingApply
):
allowed_user_ids = []
seen = set()
for raw_id in body.allowed_user_ids:
normalized = _normalize_telegram_user_id(raw_id)
if not normalized:
raise HTTPException(
status_code=400,
detail="Allowed Telegram user IDs must be numeric.",
)
if normalized not in seen:
seen.add(normalized)
allowed_user_ids.append(normalized)
if not allowed_user_ids:
raise HTTPException(
status_code=400,
detail="Add at least one allowed Telegram user ID.",
)
with _telegram_onboarding_lock:
_prune_telegram_onboarding_pairings()
record = _telegram_onboarding_pairings.get(pairing_id)
if not record:
raise HTTPException(
status_code=404,
detail="Telegram setup session was not found. Start a new setup.",
)
bot_token = record.bot_token
bot_username = record.bot_username
if not bot_token:
raise HTTPException(
status_code=409,
detail="Telegram setup is not ready yet.",
)
try:
save_env_value("TELEGRAM_BOT_TOKEN", bot_token)
save_env_value("TELEGRAM_ALLOWED_USERS", ",".join(allowed_user_ids))
_write_platform_enabled("telegram", True)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
_log.exception("Telegram onboarding apply failed")
raise HTTPException(
status_code=500,
detail="Failed to save Telegram setup.",
) from exc
with _telegram_onboarding_lock:
_telegram_onboarding_pairings.pop(pairing_id, None)
return {
"ok": True,
"platform": "telegram",
"bot_username": bot_username,
"needs_restart": True,
}
@app.delete("/api/messaging/telegram/onboarding/{pairing_id}")
async def cancel_telegram_onboarding(pairing_id: str):
with _telegram_onboarding_lock:
_telegram_onboarding_pairings.pop(pairing_id, None)
return {"ok": True}
@app.get("/api/messaging/platforms")
async def get_messaging_platforms():
env_on_disk = load_env()
@ -7078,8 +7413,6 @@ async def get_models_analytics(days: int = 30):
# though uvicorn binds to 127.0.0.1.
# ---------------------------------------------------------------------------
import re
# PTY bridge is POSIX-only (depends on fcntl/termios/ptyprocess). On native
# Windows the import raises; catch and leave PtyBridge=None so the rest of
# the dashboard (sessions, jobs, metrics, config editor) still loads and the

View File

@ -21,7 +21,7 @@ let
# Single npm deps fetch from the workspace root lockfile.
# All workspace packages share this derivation.
npmDepsHash = "sha256-2CoB0uUc8Pf9iNR0I1EzVqgL89B5sADnC9sxGah8ndU=";
npmDepsHash = "sha256-T9UtpXgBCl/GywDZyrvG4a69RkV8oD6p1UOT7GPgAS0=";
npmDeps = pkgs.fetchNpmDeps {
inherit src;

221
package-lock.json generated
View File

@ -143,6 +143,9 @@
"vite": "^8.0.10",
"vitest": "^4.1.5",
"wait-on": "^9.0.5"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"apps/desktop/node_modules/@nous-research/ui": {
@ -8353,6 +8356,16 @@
"xmlbuilder": ">=11.0.1"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@ -8985,7 +8998,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -8995,7 +9007,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@ -9791,6 +9802,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001787",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
@ -10061,7 +10081,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@ -10074,7 +10093,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/colord": {
@ -10921,6 +10939,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@ -11083,6 +11110,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dir-compare": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
@ -11542,7 +11575,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/end-of-stream": {
@ -12704,7 +12736,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@ -14133,7 +14164,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -16823,6 +16853,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-manager-detector": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
@ -16905,7 +16944,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -17020,6 +17058,15 @@
"node": ">=8.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/points-on-curve": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
@ -17262,6 +17309,141 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
@ -17985,7 +18167,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -18000,6 +18181,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resedit": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
@ -18485,6 +18672,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@ -18915,7 +19108,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@ -19042,7 +19234,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@ -20930,6 +21121,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
@ -21972,6 +22169,7 @@
"leva": "^0.10.1",
"lucide-react": "^0.577.0",
"motion": "^12.38.0",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.1",
@ -21982,6 +22180,7 @@
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",

View File

@ -968,6 +968,157 @@ class TestWebServerEndpoints:
assert data["state"] == "not_configured"
assert "DISCORD_BOT_TOKEN" in data["message"]
def test_telegram_onboarding_start_strips_poll_token(self, monkeypatch):
import hermes_cli.web_server as ws
with ws._telegram_onboarding_lock:
ws._telegram_onboarding_pairings.clear()
calls = []
def fake_request(method, path, *, body=None, bearer_token=None):
calls.append((method, path, body, bearer_token))
return {
"pairing_id": "pair123",
"poll_token": "poll-secret",
"suggested_username": "hermes_pair123_bot",
"deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair123_bot",
"qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair123_bot",
"expires_at": "2027-05-18T00:00:00.000Z",
}
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
resp = self.client.post(
"/api/messaging/telegram/onboarding/start",
json={"bot_name": "Hosted Hermes"},
)
assert resp.status_code == 200
data = resp.json()
assert data["pairing_id"] == "pair123"
assert "poll_token" not in data
assert calls == [
(
"POST",
"/v1/telegram/pairings",
{"bot_name": "Hosted Hermes"},
None,
)
]
def test_telegram_onboarding_ready_and_apply_never_returns_bot_token(self, monkeypatch):
import hermes_cli.web_server as ws
from hermes_cli.config import load_config, load_env
with ws._telegram_onboarding_lock:
ws._telegram_onboarding_pairings.clear()
def fake_request(method, path, *, body=None, bearer_token=None):
if method == "POST":
return {
"pairing_id": "pair-ready",
"poll_token": "poll-secret",
"suggested_username": "hermes_pair_ready_bot",
"deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_ready_bot",
"qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_ready_bot",
"expires_at": "2027-05-18T00:00:00.000Z",
}
assert method == "GET"
assert path == "/v1/telegram/pairings/pair-ready"
assert bearer_token == "poll-secret"
return {
"status": "ready",
"bot_username": "hermes_pair_ready_bot",
"owner_user_id": 123456789,
"token": "123456:SECRET",
}
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
start = self.client.post("/api/messaging/telegram/onboarding/start", json={})
assert start.status_code == 200
ready = self.client.get("/api/messaging/telegram/onboarding/pair-ready")
assert ready.status_code == 200
ready_data = ready.json()
assert ready_data["status"] == "ready"
assert ready_data["owner_user_id"] == "123456789"
assert "token" not in ready_data
applied = self.client.post(
"/api/messaging/telegram/onboarding/pair-ready/apply",
json={"allowed_user_ids": ["123456789", "123456789"]},
)
assert applied.status_code == 200
applied_data = applied.json()
assert applied_data == {
"ok": True,
"platform": "telegram",
"bot_username": "hermes_pair_ready_bot",
"needs_restart": True,
}
env = load_env()
assert env["TELEGRAM_BOT_TOKEN"] == "123456:SECRET"
assert env["TELEGRAM_ALLOWED_USERS"] == "123456789"
assert load_config()["platforms"]["telegram"]["enabled"] is True
def test_telegram_onboarding_apply_requires_ready_pairing(self, monkeypatch):
import hermes_cli.web_server as ws
with ws._telegram_onboarding_lock:
ws._telegram_onboarding_pairings.clear()
def fake_request(method, path, *, body=None, bearer_token=None):
return {
"pairing_id": "pair-waiting",
"poll_token": "poll-secret",
"suggested_username": "hermes_pair_waiting_bot",
"deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_waiting_bot",
"qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_waiting_bot",
"expires_at": "2027-05-18T00:00:00.000Z",
}
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
start = self.client.post("/api/messaging/telegram/onboarding/start", json={})
assert start.status_code == 200
resp = self.client.post(
"/api/messaging/telegram/onboarding/pair-waiting/apply",
json={"allowed_user_ids": ["123456789"]},
)
assert resp.status_code == 409
assert "not ready" in resp.json()["detail"]
def test_telegram_onboarding_cancel_clears_local_session(self, monkeypatch):
import hermes_cli.web_server as ws
with ws._telegram_onboarding_lock:
ws._telegram_onboarding_pairings.clear()
def fake_request(method, path, *, body=None, bearer_token=None):
return {
"pairing_id": "pair-cancel",
"poll_token": "poll-secret",
"suggested_username": "hermes_pair_cancel_bot",
"deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_cancel_bot",
"qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_cancel_bot",
"expires_at": "2027-05-18T00:00:00.000Z",
}
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
start = self.client.post("/api/messaging/telegram/onboarding/start", json={})
assert start.status_code == 200
cancel = self.client.delete("/api/messaging/telegram/onboarding/pair-cancel")
assert cancel.status_code == 200
status = self.client.get("/api/messaging/telegram/onboarding/pair-cancel")
assert status.status_code == 404
def test_session_token_endpoint_removed(self):
"""GET /api/auth/session-token should no longer exist (token injected via HTML)."""
resp = self.client.get("/api/auth/session-token")
@ -3985,4 +4136,3 @@ class TestValidateProviderCredential:
def test_empty_value_rejected(self):
data = self._post("OPENAI_API_KEY", " ").json()
assert data["ok"] is False

View File

@ -25,6 +25,7 @@
"leva": "^0.10.1",
"lucide-react": "^0.577.0",
"motion": "^12.38.0",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.1",
@ -35,6 +36,7 @@
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",

View File

@ -589,6 +589,36 @@ export const api = {
`/api/messaging/platforms/${encodeURIComponent(id)}/test`,
{ method: "POST" },
),
startTelegramOnboarding: (body: { bot_name?: string }) =>
fetchJSON<TelegramOnboardingStartResponse>(
"/api/messaging/telegram/onboarding/start",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
),
getTelegramOnboardingStatus: (pairingId: string) =>
fetchJSON<TelegramOnboardingStatusResponse>(
`/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}`,
),
applyTelegramOnboarding: (
pairingId: string,
body: { allowed_user_ids: string[] },
) =>
fetchJSON<TelegramOnboardingApplyResponse>(
`/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}/apply`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
),
cancelTelegramOnboarding: (pairingId: string) =>
fetchJSON<{ ok: boolean }>(
`/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}`,
{ method: "DELETE" },
),
// Gateway / update actions
restartGateway: () =>
@ -1293,6 +1323,30 @@ export interface EnvVarInfo {
channel_managed?: boolean;
}
export interface TelegramOnboardingStartResponse {
pairing_id: string;
suggested_username: string;
deep_link: string;
qr_payload: string;
expires_at: string;
}
export type TelegramOnboardingStatusResponse =
| { status: "waiting"; expires_at: string }
| {
status: "ready";
bot_username: string;
owner_user_id?: string;
expires_at: string;
};
export interface TelegramOnboardingApplyResponse {
ok: boolean;
platform: "telegram";
bot_username?: string;
needs_restart: true;
}
export interface SessionMessage {
role: "user" | "assistant" | "system" | "tool";
content: string | null;

View File

@ -1,15 +1,19 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
import {
AlertTriangle,
Check,
CheckCircle2,
ExternalLink,
PlugZap,
QrCode,
Radio,
RotateCw,
Save,
Settings2,
WifiOff,
X,
} from "lucide-react";
import * as QRCode from "qrcode";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
@ -24,6 +28,7 @@ import type {
MessagingPlatform,
MessagingPlatformEnvVar,
MessagingPlatformUpdate,
TelegramOnboardingStartResponse,
} from "@/lib/api";
import { useModalBehavior } from "@/hooks/useModalBehavior";
import { usePageHeader } from "@/contexts/usePageHeader";
@ -48,6 +53,22 @@ function stateBadge(state: string) {
return STATE_BADGE[state] ?? { tone: "outline" as const, label: state };
}
const TELEGRAM_USER_ID_RE = /^\d+$/;
function formatExpiry(expiresAt: string): string {
const ms = Date.parse(expiresAt) - Date.now();
if (!Number.isFinite(ms) || ms <= 0) return "expired";
const seconds = Math.ceil(ms / 1000);
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return `${minutes}:${rest.toString().padStart(2, "0")}`;
}
function isTerminalTelegramOnboardingError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /\b410\b/.test(message) && /\b(expired|claimed|gone)\b/i.test(message);
}
export default function ChannelsPage() {
const [platforms, setPlatforms] = useState<MessagingPlatform[]>([]);
const [loading, setLoading] = useState(true);
@ -353,72 +374,83 @@ export default function ChannelsPage() {
: Radio;
return (
<Card key={platform.id} className="border-border">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3 min-w-0">
<StateIcon
className={cn(
"h-5 w-5 shrink-0 mt-0.5",
platform.state === "connected"
? "text-success"
: platform.state === "fatal"
? "text-destructive"
: "text-muted-foreground",
)}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mondwest normal-case text-sm font-medium">
{platform.name}
<CardContent className="flex flex-col gap-4 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3 min-w-0">
<StateIcon
className={cn(
"h-5 w-5 shrink-0 mt-0.5",
platform.state === "connected"
? "text-success"
: platform.state === "fatal"
? "text-destructive"
: "text-muted-foreground",
)}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mondwest normal-case text-sm font-medium">
{platform.name}
</span>
<Badge tone={badge.tone}>{badge.label}</Badge>
</div>
<span className="text-xs text-muted-foreground">
{platform.description}
</span>
<Badge tone={badge.tone}>{badge.label}</Badge>
{platform.error_message && (
<span className="text-xs text-destructive">
{platform.error_message}
</span>
)}
</div>
<span className="text-xs text-muted-foreground">
{platform.description}
</span>
{platform.error_message && (
<span className="text-xs text-destructive">
{platform.error_message}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 self-start sm:self-center">
<div className="flex items-center gap-1.5">
{busy ? (
<Spinner className="text-sm" />
) : (
<Switch
checked={platform.enabled}
onCheckedChange={() => void handleToggle(platform)}
aria-label={`Enable ${platform.name}`}
/>
)}
</div>
<Button
ghost
size="sm"
onClick={() => handleTest(platform)}
disabled={testingId === platform.id}
prefix={
testingId === platform.id ? (
<Spinner />
<div className="flex items-center gap-2 shrink-0 self-start sm:self-center">
<div className="flex items-center gap-1.5">
{busy ? (
<Spinner className="text-sm" />
) : (
<PlugZap className="h-4 w-4" />
)
}
>
Test
</Button>
<Button
size="sm"
className="uppercase"
onClick={() => openConfig(platform)}
prefix={<Settings2 className="h-4 w-4" />}
>
Configure
</Button>
<Switch
checked={platform.enabled}
onCheckedChange={() => void handleToggle(platform)}
aria-label={`Enable ${platform.name}`}
/>
)}
</div>
<Button
ghost
size="sm"
onClick={() => handleTest(platform)}
disabled={testingId === platform.id}
prefix={
testingId === platform.id ? (
<Spinner />
) : (
<PlugZap className="h-4 w-4" />
)
}
>
Test
</Button>
<Button
size="sm"
className="uppercase"
onClick={() => openConfig(platform)}
prefix={<Settings2 className="h-4 w-4" />}
>
Configure
</Button>
</div>
</div>
{platform.id === "telegram" && (
<TelegramOnboardingPanel
onChanged={load}
onRestartNeeded={() => setRestartNeeded(true)}
platform={platform}
setRestartNeeded={setRestartNeeded}
showToast={showToast}
/>
)}
</CardContent>
</Card>
);
@ -427,3 +459,314 @@ export default function ChannelsPage() {
</div>
);
}
function TelegramOnboardingPanel({
onChanged,
onRestartNeeded,
platform,
setRestartNeeded,
showToast,
}: {
onChanged: () => Promise<void>;
onRestartNeeded: () => void;
platform: MessagingPlatform;
setRestartNeeded: (needed: boolean) => void;
showToast: (message: string, type: "success" | "error") => void;
}) {
const [setup, setSetup] = useState<TelegramOnboardingStartResponse | null>(
null,
);
const [qrDataUrl, setQrDataUrl] = useState("");
const [phase, setPhase] = useState<
"idle" | "starting" | "waiting" | "ready" | "applying"
>("idle");
const [botUsername, setBotUsername] = useState<string | null>(null);
const [allowedIds, setAllowedIds] = useState<string[]>([]);
const [detectedOwnerId, setDetectedOwnerId] = useState<string | null>(null);
const [newAllowedId, setNewAllowedId] = useState("");
const [error, setError] = useState("");
const [tick, setTick] = useState(0);
useEffect(() => {
if (!setup || phase !== "waiting") return;
let cancelled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
const poll = async () => {
try {
const status = await api.getTelegramOnboardingStatus(setup.pairing_id);
if (cancelled) return;
if (status.status === "ready") {
setPhase("ready");
setBotUsername(status.bot_username ?? null);
setError("");
if (
status.owner_user_id &&
TELEGRAM_USER_ID_RE.test(status.owner_user_id)
) {
setDetectedOwnerId(status.owner_user_id);
setAllowedIds([status.owner_user_id]);
}
return;
}
setError("");
timeout = setTimeout(poll, 2000);
} catch (pollError) {
if (cancelled) return;
const expiresAt = Date.parse(setup.expires_at);
const expired =
Number.isFinite(expiresAt) && Date.now() >= expiresAt;
if (isTerminalTelegramOnboardingError(pollError) || expired) {
setSetup(null);
setQrDataUrl("");
setPhase("idle");
setError("Telegram pairing expired. Start a new QR setup to try again.");
return;
}
setError(`Still waiting for Telegram. Retrying after: ${pollError}`);
timeout = setTimeout(poll, 2000);
}
};
timeout = setTimeout(poll, 1200);
return () => {
cancelled = true;
if (timeout) clearTimeout(timeout);
};
}, [phase, setup]);
useEffect(() => {
if (!setup) return;
const timer = setInterval(() => setTick((value) => value + 1), 1000);
return () => clearInterval(timer);
}, [setup]);
const resetSetup = () => {
setSetup(null);
setQrDataUrl("");
setPhase("idle");
setBotUsername(null);
setAllowedIds([]);
setDetectedOwnerId(null);
setNewAllowedId("");
setError("");
};
const start = async () => {
setPhase("starting");
setError("");
setBotUsername(null);
setAllowedIds([]);
setDetectedOwnerId(null);
setNewAllowedId("");
try {
const res = await api.startTelegramOnboarding({ bot_name: "Hermes Agent" });
const dataUrl = await QRCode.toDataURL(res.qr_payload, {
errorCorrectionLevel: "M",
margin: 1,
width: 224,
});
setSetup(res);
setQrDataUrl(dataUrl);
setPhase("waiting");
} catch (startError) {
setPhase("idle");
setError(String(startError));
}
};
const cancel = async () => {
if (setup) {
try {
await api.cancelTelegramOnboarding(setup.pairing_id);
} catch {
/* local cleanup still wins */
}
}
resetSetup();
};
const addAllowedId = () => {
const trimmed = newAllowedId.trim();
if (!TELEGRAM_USER_ID_RE.test(trimmed)) {
setError("Allowed Telegram user IDs must be numeric.");
return;
}
setError("");
setAllowedIds((ids) => (ids.includes(trimmed) ? ids : [...ids, trimmed]));
setNewAllowedId("");
};
const apply = async () => {
if (!setup) return;
if (allowedIds.length === 0) {
setError("Add at least one allowed Telegram user ID.");
return;
}
setPhase("applying");
setError("");
try {
await api.applyTelegramOnboarding(setup.pairing_id, {
allowed_user_ids: allowedIds,
});
resetSetup();
showToast("Telegram saved", "success");
try {
await api.restartGateway();
showToast("Gateway restarting…", "success");
setRestartNeeded(false);
setTimeout(() => void onChanged(), 4000);
} catch (restartError) {
onRestartNeeded();
showToast(`Telegram saved; restart failed: ${restartError}`, "error");
}
await onChanged();
} catch (applyError) {
setPhase("ready");
setError(String(applyError));
}
};
const expiresIn = useMemo(
() => (setup ? formatExpiry(setup.expires_at) : ""),
// tick keeps the memo fresh without recalculating on every render branch.
// eslint-disable-next-line react-hooks/exhaustive-deps
[setup, tick],
);
return (
<div className="rounded-sm border border-border bg-background/35 p-4">
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
className="uppercase"
onClick={() => void start()}
disabled={phase === "starting" || phase === "waiting" || phase === "applying"}
prefix={phase === "starting" ? <Spinner /> : <QrCode className="h-4 w-4" />}
>
{phase === "starting" ? "Starting…" : "Set up with QR"}
</Button>
{platform.configured && (
<span className="text-xs text-muted-foreground">
Existing Telegram credentials are configured.
</span>
)}
</div>
{error && (
<div className="mt-3 border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
{setup && qrDataUrl && (
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr)_260px]">
<div className="grid gap-3">
{(phase === "ready" || phase === "applying") && (
<div className="grid gap-3">
<div className="flex flex-wrap items-center gap-2">
<Badge tone="success">Ready</Badge>
{botUsername && (
<span className="font-courier text-sm text-muted-foreground">
@{botUsername}
</span>
)}
</div>
<div className="grid gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs uppercase tracking-[0.12em] text-muted-foreground">
Allowed users
</span>
{detectedOwnerId && allowedIds.includes(detectedOwnerId) && (
<Badge tone="success">owner detected</Badge>
)}
</div>
<div className="flex flex-wrap gap-2">
{allowedIds.map((id) => (
<button
key={id}
type="button"
className="inline-flex items-center gap-1 border border-border px-2 py-1 font-courier text-xs text-foreground hover:border-destructive/50"
onClick={() =>
setAllowedIds((ids) =>
ids.filter((existing) => existing !== id),
)
}
>
{id}
<X className="h-3 w-3" />
</button>
))}
{allowedIds.length === 0 && (
<span className="text-sm text-muted-foreground">
Add at least one Telegram user ID.
</span>
)}
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={newAllowedId}
onChange={(event) => setNewAllowedId(event.target.value)}
placeholder="Telegram user ID"
className="font-courier"
/>
<Button size="sm" outlined onClick={addAllowedId} prefix={<Check />}>
Add
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
className="uppercase"
onClick={() => void apply()}
disabled={phase === "applying"}
prefix={phase === "applying" ? <Spinner /> : <Save className="h-4 w-4" />}
>
{phase === "applying" ? "Saving…" : "Save and restart"}
</Button>
<Button size="sm" ghost onClick={() => void cancel()}>
Cancel
</Button>
</div>
</div>
)}
</div>
<div className="flex flex-col items-center justify-center gap-3">
<img
src={qrDataUrl}
alt="Telegram setup QR code"
className="h-56 w-56 bg-white p-2"
/>
<div className="flex flex-wrap items-center justify-center gap-2 text-sm">
<Badge tone={expiresIn === "expired" ? "destructive" : "outline"}>
{expiresIn}
</Badge>
{phase === "waiting" && <Badge tone="warning">waiting</Badge>}
</div>
<div className="flex flex-wrap justify-center gap-2">
<a
href={setup.deep_link}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 items-center gap-1 border border-border px-3 text-xs uppercase text-foreground hover:border-foreground/40"
>
<ExternalLink className="h-4 w-4" />
Open Telegram
</a>
<Button size="sm" ghost onClick={() => void cancel()}>
Cancel
</Button>
</div>
</div>
</div>
)}
</div>
);
}