mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
feat(dashboard): clarify manual Telegram bot setup
This commit is contained in:
parent
3ffd8b3da0
commit
d1be769b45
4 changed files with 195 additions and 36 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue