diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 45cee2071232..8260b4422923 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -4140,14 +4140,14 @@ OPTIONAL_ENV_VARS = { # ── Messaging platforms ── "TELEGRAM_BOT_TOKEN": { - "description": "Telegram bot token from @BotFather", + "description": "Complete Telegram bot token created by @BotFather (numeric bot ID followed by a colon and secret)", "prompt": "Telegram bot token", "url": "https://t.me/BotFather", "password": True, "category": "messaging", }, "TELEGRAM_ALLOWED_USERS": { - "description": "Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)", + "description": "Optional comma-separated numeric Telegram user IDs allowed immediately; leave blank to approve new users through DM pairing", "prompt": "Allowed Telegram user IDs (comma-separated)", "url": "https://t.me/userinfobot", "password": False, diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 3dbbfa67d6e5..ac40ff8eba4e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3360,14 +3360,33 @@ def _gateway_display_command(profile: Optional[str], verb: str) -> str: return " ".join(["hermes", *_gateway_subcommand(profile, verb)]) -# Slack member IDs (users U..., Enterprise Grid W...). Kept in sync with the -# frontend SLACK_MEMBER_ID_RE in web/src/pages/ChannelsPage.tsx. +# Kept in sync with the corresponding frontend validation in ChannelsPage.tsx. +_TELEGRAM_BOT_TOKEN_RE = re.compile(r"\d+:[A-Za-z0-9_-]{30,}") +_TELEGRAM_USER_ID_RE = re.compile(r"\d+") _SLACK_MEMBER_ID_RE = re.compile(r"[UW][A-Z0-9]{2,}") def _validate_messaging_env_value(platform_id: str, key: str, value: str) -> None: """Reject platform credentials that are clearly in the wrong field.""" - if platform_id != "slack" or not value: + if not value: + return + + if platform_id == "telegram": + if key == "TELEGRAM_BOT_TOKEN" and not _TELEGRAM_BOT_TOKEN_RE.fullmatch(value): + raise HTTPException( + status_code=400, + detail="Telegram bot token must be the complete token from @BotFather, such as 123456789:ABC…", + ) + if key == "TELEGRAM_ALLOWED_USERS": + user_ids = [part.strip() for part in value.split(",") if part.strip()] + if any(not _TELEGRAM_USER_ID_RE.fullmatch(user_id) for user_id in user_ids): + raise HTTPException( + status_code=400, + detail="Telegram allowed users must be comma-separated numeric user IDs.", + ) + return + + if platform_id != "slack": return if key == "SLACK_BOT_TOKEN" and not value.startswith("xoxb-"): @@ -7684,9 +7703,6 @@ async def cancel_whatsapp_onboarding(pairing_id: str): _TELEGRAM_ONBOARDING_DEFAULT_URL = "https://setup.hermes-agent.nousresearch.com" _TELEGRAM_ONBOARDING_USER_AGENT = f"HermesDashboard/{__version__}" -_TELEGRAM_USER_ID_RE = re.compile(r"^\d+$") - - @dataclass class _TelegramOnboardingPairing: poll_token: str diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 32e55af34c66..5cbdeffe4612 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2659,7 +2659,12 @@ class TestWebServerEndpoints: telegram = next(platform for platform in platforms if platform["id"] == "telegram") assert telegram["name"] == "Telegram" assert telegram["enabled"] is False - assert any(field["key"] == "TELEGRAM_BOT_TOKEN" and field["required"] for field in telegram["env_vars"]) + fields = {field["key"]: field for field in telegram["env_vars"]} + assert fields["TELEGRAM_BOT_TOKEN"]["required"] is True + assert fields["TELEGRAM_BOT_TOKEN"]["url"] == "https://t.me/BotFather" + assert "Complete Telegram bot token" in fields["TELEGRAM_BOT_TOKEN"]["description"] + assert fields["TELEGRAM_ALLOWED_USERS"]["url"] == "https://t.me/userinfobot" + assert "DM pairing" in fields["TELEGRAM_ALLOWED_USERS"]["description"] def test_slack_messaging_platform_exposes_user_allowlist(self): resp = self.client.get("/api/messaging/platforms") @@ -2771,18 +2776,36 @@ class TestWebServerEndpoints: "/api/messaging/platforms/telegram", json={ "enabled": False, - "env": {"TELEGRAM_BOT_TOKEN": "1234567890abcdef"}, + "env": {"TELEGRAM_BOT_TOKEN": "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ_1234"}, }, ) assert resp.status_code == 200 - assert load_env()["TELEGRAM_BOT_TOKEN"] == "1234567890abcdef" + assert load_env()["TELEGRAM_BOT_TOKEN"] == "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ_1234" assert load_config()["platforms"]["telegram"]["enabled"] is False status = self.client.get("/api/messaging/platforms").json()["platforms"] telegram = next(platform for platform in status if platform["id"] == "telegram") assert telegram["enabled"] is False + def test_update_messaging_platform_rejects_invalid_telegram_bot_token(self): + resp = self.client.put( + "/api/messaging/platforms/telegram", + json={"env": {"TELEGRAM_BOT_TOKEN": "not-a-botfather-token"}}, + ) + + assert resp.status_code == 400 + assert "@BotFather" in resp.json()["detail"] + + def test_update_messaging_platform_rejects_invalid_telegram_allowed_users(self): + resp = self.client.put( + "/api/messaging/platforms/telegram", + json={"env": {"TELEGRAM_ALLOWED_USERS": "123456,@username"}}, + ) + + assert resp.status_code == 400 + assert "numeric user IDs" in resp.json()["detail"] + def test_update_messaging_platform_saves_slack_allowed_users(self): from hermes_cli.config import load_env diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index 625fd9bccf4b..08d781e30632 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; import { AlertTriangle, + Bot, Check, CheckCircle2, ExternalLink, @@ -57,6 +58,7 @@ function stateBadge(state: string) { } const TELEGRAM_USER_ID_RE = /^\d+$/; +const TELEGRAM_BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{30,}$/; const SLACK_MEMBER_ID_RE = /^[UW][A-Z0-9]{2,}$/; const SLACK_TOKEN_PREFIXES: Record = { SLACK_BOT_TOKEN: "xoxb-", @@ -67,6 +69,21 @@ function validateMessagingEnvField(field: MessagingPlatformEnvVar, value: string const trimmed = value.trim(); if (!trimmed) return null; + if (field.key === "TELEGRAM_BOT_TOKEN" && !TELEGRAM_BOT_TOKEN_RE.test(trimmed)) { + return "Paste the complete token from @BotFather (for example, 123456789:ABC…)."; + } + + if (field.key === "TELEGRAM_ALLOWED_USERS") { + const invalid = trimmed + .split(",") + .map((part) => part.trim()) + .filter(Boolean) + .find((part) => !TELEGRAM_USER_ID_RE.test(part)); + if (invalid) { + return `${invalid} is not a numeric Telegram user ID.`; + } + } + const expectedPrefix = SLACK_TOKEN_PREFIXES[field.key]; if (expectedPrefix && !trimmed.startsWith(expectedPrefix)) { return `${field.prompt || field.key} must start with ${expectedPrefix}`; @@ -367,7 +384,9 @@ export default function ChannelsPage() { id="channel-config-title" className="font-mondwest text-display text-base tracking-wider" > - Configure {editing.name} + {editing.id === "telegram" + ? "Use your own Telegram bot" + : `Configure ${editing.name}`} {editing.docs_url && ( - Setup guide + {editing.id === "telegram" ? "BotFather guide" : "Setup guide"} + )}
+ {editing.id === "telegram" && ( +
+

+ Connect a bot you already own, or create one in Telegram before + filling in this form. +

+
    +
  1. + Open @BotFather, send + /newbot, and + follow its prompts. +
  2. +
  3. Copy the complete bot token BotFather gives you.
  4. +
  5. + Message @userinfobot to + find your numeric Telegram user ID, then add it below for + immediate access. +
  6. +
+
+ + Open @BotFather + + + Find my user ID + +
+

+ You can leave allowed users blank. Hermes will then send new DM + users a code that you approve from the Pairing page. +

+
+ )}

{editing.description}

@@ -534,18 +597,21 @@ export default function ChannelsPage() { > Test - + {platform.id !== "telegram" && ( + + )}
{platform.id === "telegram" && ( openConfig(platform)} onChanged={load} onRestartNeeded={() => setRestartNeeded(true)} platform={platform} @@ -979,12 +1045,14 @@ function WhatsAppOnboardingPanel({ } function TelegramOnboardingPanel({ + onManualSetup, onChanged, onRestartNeeded, platform, setRestartNeeded, showToast, }: { + onManualSetup: () => void; onChanged: () => Promise; onRestartNeeded: () => void; platform: MessagingPlatform; @@ -1192,23 +1260,75 @@ function TelegramOnboardingPanel({ return (
-
- - {platform.configured && ( - - Existing Telegram credentials are configured. - - )} +
+ + Choose how to connect your Telegram bot + + + Both options connect a bot you control and save its credentials only to + this Hermes installation. +
+
+
+
+ + Quick setup + + recommended +
+

+ Scan a QR code and confirm in Telegram. Hermes creates the bot and + detects your Telegram user ID automatically. +

+ +
+ +
+ + Use your own bot + +

+ Create a bot with @BotFather, or connect one you already have, by + entering its token and choosing who can use it. +

+ +
+
+ + {platform.configured && ( +
+ Telegram credentials are already configured. A new QR setup or bot token + will replace the current bot when you save. +
+ )} + + {phase !== "idle" && ( +
+ + Finish or cancel the current QR setup before switching methods. + +
+ )} + {error && (
{error}