Add Telegram QR onboarding to dashboard
This commit is contained in:
parent
5300727a08
commit
2f0c8e90e6
7 changed files with 1156 additions and 75 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue