feat(dashboard): clarify manual Telegram bot setup

This commit is contained in:
Shannon Sands 2026-07-15 08:16:59 +10:00 committed by Teknium
parent 3ffd8b3da0
commit d1be769b45
4 changed files with 195 additions and 36 deletions

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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<string, string> = {
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}`}
</h2>
{editing.docs_url && (
<a
@ -376,12 +395,56 @@ export default function ChannelsPage() {
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
Setup guide <ExternalLink className="h-3 w-3" />
{editing.id === "telegram" ? "BotFather guide" : "Setup guide"}
<ExternalLink className="h-3 w-3" />
</a>
)}
</header>
<div className="grid gap-4 overflow-y-auto overscroll-contain p-4 sm:p-5">
{editing.id === "telegram" && (
<div className="grid gap-3 text-sm text-muted-foreground">
<p>
Connect a bot you already own, or create one in Telegram before
filling in this form.
</p>
<ol className="grid list-decimal gap-1.5 pl-5">
<li>
Open <span className="text-foreground">@BotFather</span>, send
<code className="mx-1 font-courier text-xs">/newbot</code>, and
follow its prompts.
</li>
<li>Copy the complete bot token BotFather gives you.</li>
<li>
Message <span className="text-foreground">@userinfobot</span> to
find your numeric Telegram user ID, then add it below for
immediate access.
</li>
</ol>
<div className="flex flex-wrap gap-x-4 gap-y-2 text-xs">
<a
href="https://t.me/BotFather"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
Open @BotFather <ExternalLink className="h-3 w-3" />
</a>
<a
href="https://t.me/userinfobot"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
Find my user ID <ExternalLink className="h-3 w-3" />
</a>
</div>
<p className="text-xs">
You can leave allowed users blank. Hermes will then send new DM
users a code that you approve from the Pairing page.
</p>
</div>
)}
<p className="text-xs text-muted-foreground">
{editing.description}
</p>
@ -534,18 +597,21 @@ export default function ChannelsPage() {
>
Test
</Button>
<Button
size="sm"
className="uppercase"
onClick={() => openConfig(platform)}
prefix={<Settings2 className="h-4 w-4" />}
>
Configure
</Button>
{platform.id !== "telegram" && (
<Button
size="sm"
className="uppercase"
onClick={() => openConfig(platform)}
prefix={<Settings2 className="h-4 w-4" />}
>
Configure
</Button>
)}
</div>
</div>
{platform.id === "telegram" && (
<TelegramOnboardingPanel
onManualSetup={() => 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<void>;
onRestartNeeded: () => void;
platform: MessagingPlatform;
@ -1192,23 +1260,75 @@ function TelegramOnboardingPanel({
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 className="grid gap-1">
<span className="font-mondwest text-sm text-foreground">
Choose how to connect your Telegram bot
</span>
<span className="text-xs text-muted-foreground">
Both options connect a bot you control and save its credentials only to
this Hermes installation.
</span>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-2 sm:divide-x sm:divide-border">
<div className="grid content-start gap-3 sm:pr-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium uppercase text-foreground">
Quick setup
</span>
<Badge tone="success">recommended</Badge>
</div>
<p className="text-xs text-muted-foreground">
Scan a QR code and confirm in Telegram. Hermes creates the bot and
detects your Telegram user ID automatically.
</p>
<Button
size="sm"
className="w-fit uppercase"
onClick={() => void start()}
disabled={phase !== "idle"}
prefix={phase === "starting" ? <Spinner /> : <QrCode className="h-4 w-4" />}
>
{phase === "starting" ? "Starting…" : "Create with QR"}
</Button>
</div>
<div className="grid content-start gap-3 border-t border-border pt-4 sm:border-t-0 sm:pl-4 sm:pt-0">
<span className="text-xs font-medium uppercase text-foreground">
Use your own bot
</span>
<p className="text-xs text-muted-foreground">
Create a bot with @BotFather, or connect one you already have, by
entering its token and choosing who can use it.
</p>
<Button
size="sm"
outlined
className="w-fit uppercase"
onClick={onManualSetup}
disabled={phase !== "idle"}
prefix={<Bot className="h-4 w-4" />}
>
Manual setup
</Button>
</div>
</div>
{platform.configured && (
<div className="mt-4 border-t border-border pt-3 text-xs text-muted-foreground">
Telegram credentials are already configured. A new QR setup or bot token
will replace the current bot when you save.
</div>
)}
{phase !== "idle" && (
<div className="mt-4 border-t border-border pt-4">
<span className="text-xs text-muted-foreground">
Finish or cancel the current QR setup before switching methods.
</span>
</div>
)}
{error && (
<div className="mt-3 border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}