Add Telegram QR onboarding to dashboard
This commit is contained in:
@ -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
|
||||
|
||||
@ -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
221
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user