Add Telegram QR onboarding to dashboard

This commit is contained in:
Shannon Sands 2026-06-03 13:24:03 +10:00 committed by Teknium
parent 5300727a08
commit 2f0c8e90e6
7 changed files with 1156 additions and 75 deletions

View file

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

View file

@ -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>
);
}