hermes-agent/hermes_cli/telegram_managed_bot.py
Teknium 8a5e9b214b
feat: automatic Telegram bot creation via Managed Bots (Bot API 9.6)
Add client-side support for Telegram's Managed Bots feature, enabling
zero-copy-paste bot creation during hermes setup:

- New module: hermes_cli/telegram_managed_bot.py
  - QR code terminal rendering via qrcode library
  - Deep link generation (t.me/newbot/{manager}/{username})
  - Pairing protocol client (nonce registration + token polling)
  - Full auto-setup orchestrator with animated progress

- Setup wizard (hermes_cli/setup.py)
  - Telegram setup now offers Automatic vs Manual choice
  - Automatic: scan QR → confirm in Telegram → token saved
  - Falls back to manual if auto-setup fails or is declined

- Dependencies: qrcode>=8.0 (pure Python, no PIL needed)

Requires a Nous-hosted manager bot + pairing API (Cloudflare Worker)
to complete the flow. See linked issue for backend infrastructure spec.
2026-04-15 17:17:08 -07:00

269 lines
8.8 KiB
Python

"""Telegram Managed Bot — automatic bot creation via Bot API 9.6.
Uses Telegram's Managed Bots feature to create bots for users without
manual BotFather interaction. The flow:
1. CLI generates a pairing nonce and a t.me/newbot deep link.
2. User opens the link in Telegram and confirms bot creation.
3. A Nous-hosted manager bot receives the ``managed_bot`` update,
calls ``getManagedBotToken``, and stores the token keyed by nonce.
4. CLI polls the pairing API and retrieves the token.
5. Token is saved to ``.env`` — zero manual copy-paste.
Requires:
- A Nous-hosted manager bot with Bot Management Mode enabled.
- A pairing API (Cloudflare Worker + KV or equivalent) at
``MANAGED_BOT_API_URL``.
"""
from __future__ import annotations
import secrets
import string
import sys
import time
import urllib.parse
from typing import Optional
import httpx
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# Default pairing API base URL (Nous-hosted Cloudflare Worker).
# Override via config.yaml ``telegram.managed_bot_api_url`` for self-hosted.
DEFAULT_API_URL = "https://setup.hermes-agent.nousresearch.com"
# The Nous-hosted manager bot username (without @).
DEFAULT_MANAGER_BOT = "HermesSetupBot"
# How long to poll before giving up (seconds).
DEFAULT_POLL_TIMEOUT = 180
# Poll interval (seconds).
POLL_INTERVAL = 2
# ---------------------------------------------------------------------------
# QR code rendering
# ---------------------------------------------------------------------------
def render_qr_terminal(url: str) -> str:
"""Render a URL as a QR code string suitable for terminal output.
Uses the ``qrcode`` library if available, otherwise returns an empty
string (caller should fall back to printing the URL directly).
"""
try:
import io
import qrcode # type: ignore[import-untyped]
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=1,
border=1,
)
qr.add_data(url)
qr.make(fit=True)
buf = io.StringIO()
qr.print_ascii(out=buf, invert=True)
return buf.getvalue()
except ImportError:
return ""
def print_qr_code(url: str) -> None:
"""Print a QR code to stdout, with URL fallback if qrcode is missing."""
qr_text = render_qr_terminal(url)
if qr_text:
print(qr_text)
else:
print(f" (Install 'qrcode' for a scannable QR code: pip install qrcode)")
print(f" Link: {url}")
# ---------------------------------------------------------------------------
# Deep link generation
# ---------------------------------------------------------------------------
def _random_suffix(length: int = 4) -> str:
"""Generate a short random alphanumeric suffix for bot usernames."""
alphabet = string.ascii_lowercase + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def generate_bot_username(profile_name: Optional[str] = None) -> str:
"""Generate a suggested bot username like ``hermes_work_a7f3_bot``.
Telegram requires bot usernames to end with ``bot`` and be 5-32 chars.
"""
base = "hermes"
suffix = _random_suffix()
if profile_name and profile_name != "default":
# Sanitize profile name for Telegram username rules (a-z, 0-9, _)
clean = "".join(c if c.isalnum() else "_" for c in profile_name.lower())
clean = clean[:12] # Keep it short
return f"{base}_{clean}_{suffix}_bot"
return f"{base}_{suffix}_bot"
def generate_deep_link(
manager_bot: str = DEFAULT_MANAGER_BOT,
suggested_username: Optional[str] = None,
suggested_name: Optional[str] = None,
) -> str:
"""Build the ``t.me/newbot`` deep link for managed bot creation.
Format: ``https://t.me/newbot/{manager_bot}/{suggested_username}[?name={name}]``
"""
username = suggested_username or generate_bot_username()
base_url = f"https://t.me/newbot/{manager_bot}/{username}"
if suggested_name:
params = urllib.parse.urlencode({"name": suggested_name})
return f"{base_url}?{params}"
return base_url
# ---------------------------------------------------------------------------
# Pairing protocol (client side)
# ---------------------------------------------------------------------------
def generate_pairing_nonce() -> str:
"""Generate a cryptographically random pairing nonce (32 hex chars)."""
return secrets.token_hex(16)
def register_pairing(api_url: str, nonce: str, timeout: float = 10.0) -> bool:
"""Register a pairing nonce with the pairing API.
``POST /pair`` body: ``{"nonce": "..."}``
Returns True on success, False on failure.
"""
try:
resp = httpx.post(
f"{api_url}/pair",
json={"nonce": nonce},
timeout=timeout,
)
return resp.status_code in (200, 201)
except httpx.HTTPError:
return False
def poll_for_token(
api_url: str,
nonce: str,
timeout: float = DEFAULT_POLL_TIMEOUT,
interval: float = POLL_INTERVAL,
) -> Optional[str]:
"""Poll the pairing API until the bot token is available or timeout.
``GET /pair/{nonce}`` → 200 with ``{"token": "..."}`` when ready,
404 while waiting.
Returns the bot token string on success, None on timeout/failure.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
resp = httpx.get(
f"{api_url}/pair/{nonce}",
timeout=10.0,
)
if resp.status_code == 200:
data = resp.json()
token = data.get("token")
if token:
return token
# 404 = not yet ready, keep polling
except httpx.HTTPError:
pass # Network hiccup, retry
time.sleep(interval)
return None
# ---------------------------------------------------------------------------
# Orchestrator — called from setup wizard
# ---------------------------------------------------------------------------
def auto_setup_telegram_bot(
api_url: str = DEFAULT_API_URL,
manager_bot: str = DEFAULT_MANAGER_BOT,
profile_name: Optional[str] = None,
poll_timeout: float = DEFAULT_POLL_TIMEOUT,
) -> Optional[str]:
"""Run the full automatic Telegram bot creation flow.
1. Generate nonce + suggested username.
2. Register the nonce with the pairing API.
3. Print the QR code / deep link for the user.
4. Poll until the token arrives (or timeout).
Returns the bot token on success, None on failure/timeout.
"""
nonce = generate_pairing_nonce()
username = generate_bot_username(profile_name)
deep_link = generate_deep_link(
manager_bot=manager_bot,
suggested_username=username,
suggested_name="Hermes Agent",
)
# Embed the nonce in the pairing API so the manager bot can match it.
# The manager bot receives the suggested_username from Telegram's
# managed_bot update and uses it to look up the nonce.
if not register_pairing(api_url, nonce):
print(" ✗ Could not reach the Hermes setup service.")
print(" Try the manual setup instead, or check your network.")
return None
print()
print(" Scan this QR code with your phone, or open the link below:")
print()
print_qr_code(deep_link)
print()
print(" When Telegram opens, tap 'Create Bot' to confirm.")
print(" (You can edit the bot name and username before confirming)")
print()
# Animated waiting
spinner_chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
start = time.monotonic()
deadline = start + poll_timeout
idx = 0
while time.monotonic() < deadline:
char = spinner_chars[idx % len(spinner_chars)]
elapsed = int(time.monotonic() - start)
remaining = int(poll_timeout - elapsed)
sys.stdout.write(f"\r {char} Waiting for bot creation... ({remaining}s remaining) ")
sys.stdout.flush()
idx += 1
try:
resp = httpx.get(f"{api_url}/pair/{nonce}", timeout=10.0)
if resp.status_code == 200:
data = resp.json()
token = data.get("token")
if token:
sys.stdout.write("\r ✓ Bot created successfully! \n")
sys.stdout.flush()
return token
except httpx.HTTPError:
pass
time.sleep(POLL_INTERVAL)
sys.stdout.write("\r ✗ Timed out waiting for bot creation. \n")
sys.stdout.flush()
print(" The bot may still be created — check Telegram.")
print(" You can paste the token manually below, or re-run setup.")
return None