feat(desktop): add Telegram QR code to Clients page and Onboarding

- Create TelegramConnectQR component (fetches short code from Gateway)
- Export reusable hooks/components from qr-code.tsx
- Update ChannelsTab with Telegram QR card
- Rewrite ConnectStep for QR-based flow (replaces BotFather token input)
- Re-add ConnectStep to onboarding: Privacy → Provider → Channels → Start

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiayuan Zhang 2026-02-14 00:30:10 +08:00
parent d75a24714d
commit b39a0dd2ac
5 changed files with 167 additions and 168 deletions

View file

@ -35,7 +35,7 @@ function generateToken(): string {
* - Auto-refreshes when expired
* - Registers token with Hub
*/
function useQRToken(agentId: string, expirySeconds: number) {
export function useQRToken(agentId: string, expirySeconds: number) {
const [token, setToken] = useState(generateToken)
const [expiresAt, setExpiresAt] = useState(() => Date.now() + expirySeconds * 1000)
@ -59,7 +59,7 @@ function useQRToken(agentId: string, expirySeconds: number) {
* Hook for countdown timer
* Returns remaining seconds, auto-updates every second
*/
function useCountdown(expiresAt: number, onExpire: () => void) {
export function useCountdown(expiresAt: number, onExpire: () => void) {
const [remaining, setRemaining] = useState(() =>
Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000))
)
@ -121,7 +121,7 @@ function CornerAccent({ position }: { position: 'tl' | 'tr' | 'bl' | 'br' }) {
}
/** QR code frame with corner accents */
function QRCodeFrame({ children }: { children: React.ReactNode }) {
export function QRCodeFrame({ children }: { children: React.ReactNode }) {
return (
<div className="relative inline-block">
<CornerAccent position="tl" />
@ -141,7 +141,7 @@ function formatTime(seconds: number): string {
}
/** Expiry timer display */
function ExpiryTimer({ remaining }: { remaining: number }) {
export function ExpiryTimer({ remaining }: { remaining: number }) {
// Derive display state from remaining seconds (no extra state needed)
const isWarning = remaining > 0 && remaining < 10

View file

@ -0,0 +1,112 @@
import { useState, useEffect } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import { Loader2 } from 'lucide-react'
import { useQRToken, useCountdown, QRCodeFrame, ExpiryTimer } from './qr-code'
export interface TelegramConnectQRProps {
gateway: string
hubId: string
agentId: string
expirySeconds?: number
size?: number
}
/**
* Telegram QR code for deep link connection flow.
*
* Generates a token, sends it to Gateway to create a short code,
* then renders a QR encoding https://t.me/{botUsername}?start={code}.
* Auto-refreshes when the token expires.
*/
export function TelegramConnectQR({
gateway,
hubId,
agentId,
expirySeconds = 30,
size = 200,
}: TelegramConnectQRProps) {
const { token, expiresAt, refresh } = useQRToken(agentId, expirySeconds)
const remaining = useCountdown(expiresAt, refresh)
const [deepLink, setDeepLink] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
let cancelled = false
async function fetchCode() {
setLoading(true)
setError(null)
try {
const res = await fetch(`${gateway}/telegram/connect-code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gateway, hubId, agentId, token, expires: expiresAt }),
})
if (cancelled) return
if (!res.ok) {
if (res.status === 503) {
setError('Telegram bot not configured on Gateway')
} else {
setError(`Gateway error: ${res.statusText}`)
}
setDeepLink(null)
return
}
const data = (await res.json()) as { code: string; botUsername: string }
if (cancelled) return
setDeepLink(`https://t.me/${data.botUsername}?start=${data.code}`)
} catch (err) {
if (cancelled) return
setError(err instanceof Error ? err.message : 'Failed to connect to Gateway')
setDeepLink(null)
} finally {
if (!cancelled) setLoading(false)
}
}
fetchCode()
return () => { cancelled = true }
}, [token, expiresAt, gateway, hubId, agentId])
if (loading) {
return (
<div className="flex flex-col items-center gap-4 py-4">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center gap-2 py-4">
<p className="text-sm text-destructive">{error}</p>
</div>
)
}
if (!deepLink) return null
return (
<div className="flex flex-col items-center gap-4">
<QRCodeFrame>
<QRCodeSVG
value={deepLink}
size={size}
level="M"
marginSize={0}
bgColor="#ffffff"
fgColor="#0a0a0a"
/>
</QRCodeFrame>
<ExpiryTimer remaining={remaining} />
</div>
)
}

View file

@ -16,17 +16,40 @@ import {
import { QrCode, Radio, Smartphone, WifiOff, Loader2 } from 'lucide-react'
import { useHubStore, selectPrimaryAgent } from '../stores/hub'
import { ConnectionQRCode } from '../components/qr-code'
import { TelegramConnectQR } from '../components/telegram-qr'
import { DeviceList } from '../components/device-list'
function ChannelsTab() {
const { hubInfo, agents } = useHubStore()
const primaryAgent = selectPrimaryAgent(agents)
return (
<div className="space-y-4">
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Connect messaging platforms to chat with your agent.
</p>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Telegram</CardTitle>
<CardDescription>Scan with your phone camera to connect on Telegram.</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex justify-center">
<TelegramConnectQR
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id ?? 'unknown'}
expirySeconds={30}
size={200}
/>
</CardContent>
</Card>
<p className="text-sm text-muted-foreground">
Message <span className="font-medium text-foreground">@multica_bot</span> on Telegram to get started.
Discord and Slack coming soon.
</p>
</div>

View file

@ -1,73 +1,18 @@
import { useState } from 'react'
import { Button } from '@multica/ui/components/ui/button'
import { Input } from '@multica/ui/components/ui/input'
import { Badge } from '@multica/ui/components/ui/badge'
import { Separator } from '@multica/ui/components/ui/separator'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@multica/ui/components/ui/hover-card'
import {
ChevronLeft,
Loader2,
HelpCircle,
Share2,
Check,
Info,
} from 'lucide-react'
import { useChannelsStore } from '../../../stores/channels'
import { ChevronLeft, Info } from 'lucide-react'
import { useHubStore, selectPrimaryAgent } from '../../../stores/hub'
import { TelegramConnectQR } from '../../../components/telegram-qr'
import { StepDots } from './step-dots'
function statusVariant(
status: string
): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case 'running':
return 'default'
case 'starting':
return 'secondary'
case 'error':
return 'destructive'
default:
return 'outline'
}
}
interface ConnectStepProps {
onNext: () => void
onBack: () => void
}
export default function ConnectStep({ onNext, onBack }: ConnectStepProps) {
const { states, config, saveToken } = useChannelsStore()
const [token, setToken] = useState('')
const [saving, setSaving] = useState(false)
const [localError, setLocalError] = useState<string | null>(null)
const state = states.find(
(s) => s.channelId === 'telegram' && s.accountId === 'default'
)
const savedConfig = config['telegram']?.['default'] as
| { botToken?: string }
| undefined
const hasToken = Boolean(savedConfig?.botToken)
const isRunning = state?.status === 'running'
const isStarting = state?.status === 'starting'
const handleConnect = async () => {
if (!token.trim()) return
setSaving(true)
setLocalError(null)
const result = await saveToken('telegram', 'default', token.trim())
if (!result.ok) {
setLocalError(result.error ?? 'Failed to connect')
} else {
setToken('')
}
setSaving(false)
}
const { hubInfo, agents } = useHubStore()
const primaryAgent = selectPrimaryAgent(agents)
return (
<div className="h-full flex items-center justify-center px-6 py-8 animate-in fade-in duration-300">
@ -84,119 +29,34 @@ export default function ConnectStep({ onNext, onBack }: ConnectStepProps) {
{/* Header */}
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">
Connect a channel
Connect Telegram
</h1>
<p className="text-sm text-muted-foreground">
Create bots that talk to your local agent from anywhere.
Scan the QR code with your phone camera to connect on Telegram.
</p>
</div>
{/* Info box */}
<div className="rounded-lg bg-muted/50 px-4 py-3 space-y-2">
<p className="text-sm text-muted-foreground">
Your bot connects directly to this machine
chat from your phone, tablet, or any device.
Chat with your agent from your phone via Telegram.
Your messages are routed through the Gateway to this machine.
</p>
<p className="text-xs text-muted-foreground/70 flex items-center gap-1.5">
<Info className="size-3.5 shrink-0" />
Telegram now. Discord, Slack, Mobile app coming soon.
Discord, Slack, and more coming soon.
</p>
</div>
{/* Telegram card */}
<div className="rounded-xl border border-border bg-card">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-8 rounded-lg bg-muted shrink-0">
<Share2 className="size-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">Telegram</p>
<p className="text-xs text-muted-foreground">
Bot API long polling
</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Status badge */}
{state && (
<Badge variant={statusVariant(state.status)}>
{state.status}
</Badge>
)}
{/* Help hover card */}
<HoverCard>
<HoverCardTrigger className="p-1 text-muted-foreground hover:text-foreground transition-colors">
<HelpCircle className="size-4" />
</HoverCardTrigger>
<HoverCardContent align="end" side="top" className="w-56">
<p className="font-medium text-sm mb-2">
Get a bot token
</p>
<ol className="space-y-1.5">
<li className="text-xs text-muted-foreground flex gap-2">
<span className="text-foreground/50 shrink-0">1.</span>
<span>Open @BotFather in Telegram</span>
</li>
<li className="text-xs text-muted-foreground flex gap-2">
<span className="text-foreground/50 shrink-0">2.</span>
<span>Send /newbot and name your bot</span>
</li>
<li className="text-xs text-muted-foreground flex gap-2">
<span className="text-foreground/50 shrink-0">3.</span>
<span>Copy the token and paste below</span>
</li>
</ol>
</HoverCardContent>
</HoverCard>
</div>
</div>
<div className="p-4">
{hasToken ? (
<div className="flex items-center gap-2">
<Check className="size-4 text-green-600 dark:text-green-500 shrink-0" />
<p className="text-sm text-muted-foreground">
{isRunning
? 'Bot is running. Send it a message to test.'
: isStarting
? 'Starting bot...'
: 'Bot configured.'}
</p>
</div>
) : (
<div className="flex gap-2">
<Input
type="password"
placeholder="Bot token from @BotFather"
value={token}
onChange={(e) => setToken(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleConnect()}
className="flex-1"
/>
<Button
size="sm"
variant='ghost'
onClick={handleConnect}
disabled={saving || !token.trim()}
>
{saving && (
<Loader2 className="size-4 animate-spin mr-1.5" />
)}
Connect
</Button>
</div>
)}
{localError && (
<p className="text-sm text-destructive mt-2">{localError}</p>
)}
{state?.status === 'error' && state.error && (
<p className="text-sm text-destructive mt-2">{state.error}</p>
)}
</div>
{/* QR code */}
<div className="flex justify-center py-2">
<TelegramConnectQR
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id ?? 'unknown'}
expirySeconds={30}
size={180}
/>
</div>
<Separator />
@ -208,7 +68,7 @@ export default function ConnectStep({ onNext, onBack }: ConnectStepProps) {
<Button size="sm" variant="outline" onClick={onNext}>
Skip
</Button>
<Button size="sm" onClick={onNext} disabled={!hasToken}>
<Button size="sm" onClick={onNext}>
Continue
</Button>
</div>

View file

@ -5,9 +5,10 @@ import { ModeToggle } from "../../components/mode-toggle";
import WelcomeStep from "./components/welcome-step";
import PermissionsStep from "./components/permissions-step";
import SetupStep from "./components/setup-step";
import ConnectStep from "./components/connect-step";
import TryItStep from "./components/try-it-step";
const steps = ["Privacy", "Provider", "Start"];
const steps = ["Privacy", "Provider", "Channels", "Start"];
export default function OnboardingPage() {
const navigate = useNavigate();
@ -77,6 +78,9 @@ export default function OnboardingPage() {
{currentStep === 1 && <PermissionsStep onNext={nextStep} />}
{currentStep === 2 && <SetupStep onNext={nextStep} onBack={prevStep} />}
{currentStep === 3 && (
<ConnectStep onNext={nextStep} onBack={prevStep} />
)}
{currentStep === 4 && (
<TryItStep onComplete={handleComplete} onBack={prevStep} />
)}
</main>