From 2f0c8e90e6138dc986c2e533941e6e20e54536f5 Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Wed, 3 Jun 2026 13:24:03 +1000 Subject: [PATCH] Add Telegram QR onboarding to dashboard --- hermes_cli/web_server.py | 337 +++++++++++++++++++- nix/lib.nix | 2 +- package-lock.json | 221 ++++++++++++- tests/hermes_cli/test_web_server.py | 152 ++++++++- web/package.json | 2 + web/src/lib/api.ts | 54 ++++ web/src/pages/ChannelsPage.tsx | 463 ++++++++++++++++++++++++---- 7 files changed, 1156 insertions(+), 75 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 233245de3..628edf691 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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 diff --git a/nix/lib.nix b/nix/lib.nix index b3aa020ac..9ef9b1acd 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -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; diff --git a/package-lock.json b/package-lock.json index 5b47e07c6..b8fbc0c13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 592d62c44..6353ebc9e 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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 - diff --git a/web/package.json b/web/package.json index 7615a0976..72f6dc4f8 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2f59f095e..2ee4c8335 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -589,6 +589,36 @@ export const api = { `/api/messaging/platforms/${encodeURIComponent(id)}/test`, { method: "POST" }, ), + startTelegramOnboarding: (body: { bot_name?: string }) => + fetchJSON( + "/api/messaging/telegram/onboarding/start", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + getTelegramOnboardingStatus: (pairingId: string) => + fetchJSON( + `/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}`, + ), + applyTelegramOnboarding: ( + pairingId: string, + body: { allowed_user_ids: string[] }, + ) => + fetchJSON( + `/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; diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index 4320d5f86..98c9b7a77 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -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([]); const [loading, setLoading] = useState(true); @@ -353,72 +374,83 @@ export default function ChannelsPage() { : Radio; return ( - -
- -
-
- - {platform.name} + +
+
+ +
+
+ + {platform.name} + + {badge.label} +
+ + {platform.description} - {badge.label} + {platform.error_message && ( + + {platform.error_message} + + )}
- - {platform.description} - - {platform.error_message && ( - - {platform.error_message} - - )}
-
-
-
- {busy ? ( - - ) : ( - void handleToggle(platform)} - aria-label={`Enable ${platform.name}`} - /> - )} -
- - + void handleToggle(platform)} + aria-label={`Enable ${platform.name}`} + /> + )} +
+ + +
+ {platform.id === "telegram" && ( + setRestartNeeded(true)} + platform={platform} + setRestartNeeded={setRestartNeeded} + showToast={showToast} + /> + )} ); @@ -427,3 +459,314 @@ export default function ChannelsPage() {
); } + +function TelegramOnboardingPanel({ + onChanged, + onRestartNeeded, + platform, + setRestartNeeded, + showToast, +}: { + onChanged: () => Promise; + onRestartNeeded: () => void; + platform: MessagingPlatform; + setRestartNeeded: (needed: boolean) => void; + showToast: (message: string, type: "success" | "error") => void; +}) { + const [setup, setSetup] = useState( + null, + ); + const [qrDataUrl, setQrDataUrl] = useState(""); + const [phase, setPhase] = useState< + "idle" | "starting" | "waiting" | "ready" | "applying" + >("idle"); + const [botUsername, setBotUsername] = useState(null); + const [allowedIds, setAllowedIds] = useState([]); + const [detectedOwnerId, setDetectedOwnerId] = useState(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 | 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 ( +
+
+ + {platform.configured && ( + + Existing Telegram credentials are configured. + + )} +
+ + {error && ( +
+ {error} +
+ )} + + {setup && qrDataUrl && ( +
+
+ {(phase === "ready" || phase === "applying") && ( +
+
+ Ready + {botUsername && ( + + @{botUsername} + + )} +
+ +
+
+ + Allowed users + + {detectedOwnerId && allowedIds.includes(detectedOwnerId) && ( + owner detected + )} +
+
+ {allowedIds.map((id) => ( + + ))} + {allowedIds.length === 0 && ( + + Add at least one Telegram user ID. + + )} +
+
+ +
+ setNewAllowedId(event.target.value)} + placeholder="Telegram user ID" + className="font-courier" + /> + +
+ +
+ + +
+
+ )} +
+ +
+ Telegram setup QR code +
+ + {expiresIn} + + {phase === "waiting" && waiting} +
+
+ + + Open Telegram + + +
+
+
+ )} +
+ ); +}