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:
parent
d75a24714d
commit
b39a0dd2ac
5 changed files with 167 additions and 168 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
112
apps/desktop/src/renderer/src/components/telegram-qr.tsx
Normal file
112
apps/desktop/src/renderer/src/components/telegram-qr.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue