From b39a0dd2acacba969d432ce2ba707cf5a31eb33a Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Sat, 14 Feb 2026 00:30:10 +0800 Subject: [PATCH] feat(desktop): add Telegram QR code to Clients page and Onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/renderer/src/components/qr-code.tsx | 8 +- .../renderer/src/components/telegram-qr.tsx | 112 +++++++++++ .../src/renderer/src/pages/clients.tsx | 29 ++- .../onboarding/components/connect-step.tsx | 180 ++---------------- .../renderer/src/pages/onboarding/index.tsx | 6 +- 5 files changed, 167 insertions(+), 168 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/telegram-qr.tsx diff --git a/apps/desktop/src/renderer/src/components/qr-code.tsx b/apps/desktop/src/renderer/src/components/qr-code.tsx index 0dd5eef6..6ea9a019 100644 --- a/apps/desktop/src/renderer/src/components/qr-code.tsx +++ b/apps/desktop/src/renderer/src/components/qr-code.tsx @@ -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 (
@@ -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 diff --git a/apps/desktop/src/renderer/src/components/telegram-qr.tsx b/apps/desktop/src/renderer/src/components/telegram-qr.tsx new file mode 100644 index 00000000..be957a3e --- /dev/null +++ b/apps/desktop/src/renderer/src/components/telegram-qr.tsx @@ -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(null) + const [error, setError] = useState(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 ( +
+ +
+ ) + } + + if (error) { + return ( +
+

{error}

+
+ ) + } + + if (!deepLink) return null + + return ( +
+ + + + + +
+ ) +} diff --git a/apps/desktop/src/renderer/src/pages/clients.tsx b/apps/desktop/src/renderer/src/pages/clients.tsx index 7cb50d6e..924f6e9b 100644 --- a/apps/desktop/src/renderer/src/pages/clients.tsx +++ b/apps/desktop/src/renderer/src/pages/clients.tsx @@ -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 ( -
+

Connect messaging platforms to chat with your agent.

+ + + +
+
+ Telegram + Scan with your phone camera to connect on Telegram. +
+
+
+ + + +
+

- Message @multica_bot on Telegram to get started. Discord and Slack coming soon.

diff --git a/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx b/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx index 1ab0198a..c0b10a79 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx @@ -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(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 (
@@ -84,119 +29,34 @@ export default function ConnectStep({ onNext, onBack }: ConnectStepProps) { {/* Header */}

- Connect a channel + Connect Telegram

- Create bots that talk to your local agent from anywhere. + Scan the QR code with your phone camera to connect on Telegram.

{/* Info box */}

- 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.

- Telegram now. Discord, Slack, Mobile app coming soon. + Discord, Slack, and more coming soon.

- {/* Telegram card */} -
-
-
-
- -
-
-

Telegram

-

- Bot API long polling -

-
-
- -
- {/* Status badge */} - {state && ( - - {state.status} - - )} - - {/* Help hover card */} - - - - - -

- Get a bot token -

-
    -
  1. - 1. - Open @BotFather in Telegram -
  2. -
  3. - 2. - Send /newbot and name your bot -
  4. -
  5. - 3. - Copy the token and paste below -
  6. -
-
-
-
-
- -
- {hasToken ? ( -
- -

- {isRunning - ? 'Bot is running. Send it a message to test.' - : isStarting - ? 'Starting bot...' - : 'Bot configured.'} -

-
- ) : ( -
- setToken(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleConnect()} - className="flex-1" - /> - -
- )} - - {localError && ( -

{localError}

- )} - {state?.status === 'error' && state.error && ( -

{state.error}

- )} -
+ {/* QR code */} +
+
@@ -208,7 +68,7 @@ export default function ConnectStep({ onNext, onBack }: ConnectStepProps) { -
diff --git a/apps/desktop/src/renderer/src/pages/onboarding/index.tsx b/apps/desktop/src/renderer/src/pages/onboarding/index.tsx index 3a374423..fb43dc69 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/index.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/index.tsx @@ -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 && } {currentStep === 2 && } {currentStep === 3 && ( + + )} + {currentStep === 4 && ( )}