Merge pull request #175 from multica-ai/forrestchang/telegram-arch

feat(gateway): Telegram QR-based connection flow with centralized bot
This commit is contained in:
Jiayuan Zhang 2026-02-14 01:45:46 +08:00 committed by GitHub
commit 9189883710
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1354 additions and 521 deletions

9
.env.example Normal file
View file

@ -0,0 +1,9 @@
# Telegram Bot
# Get a token from @BotFather on Telegram
TELEGRAM_BOT_TOKEN=
# Optional: webhook secret token for production
# TELEGRAM_WEBHOOK_SECRET_TOKEN=
# Optional: webhook URL (if not set, uses long-polling mode for local dev)
# TELEGRAM_WEBHOOK_URL=https://your-domain.ngrok-free.dev/telegram/webhook

View file

@ -1,7 +1,8 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useState, useCallback, useMemo } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import { Button } from '@multica/ui/components/ui/button'
import { Copy, Check } from 'lucide-react'
import { useQRToken, useCountdown } from './qr-hooks'
// ============ Types ============
@ -22,65 +23,7 @@ export interface ConnectionQRCodeProps {
size?: number
}
// ============ Hooks ============
/** Generate a secure random token */
function generateToken(): string {
return crypto.randomUUID()
}
/**
* Hook to manage QR token lifecycle
* - Generates token on mount
* - Auto-refreshes when expired
* - Registers token with Hub
*/
function useQRToken(agentId: string, expirySeconds: number) {
const [token, setToken] = useState(generateToken)
const [expiresAt, setExpiresAt] = useState(() => Date.now() + expirySeconds * 1000)
const refresh = useCallback(() => {
const newToken = generateToken()
const newExpiry = Date.now() + expirySeconds * 1000
setToken(newToken)
setExpiresAt(newExpiry)
window.electronAPI?.hub.registerToken(newToken, agentId, newExpiry)
}, [agentId, expirySeconds])
// Register initial token
useEffect(() => {
window.electronAPI?.hub.registerToken(token, agentId, expiresAt)
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return { token, expiresAt, refresh }
}
/**
* Hook for countdown timer
* Returns remaining seconds, auto-updates every second
*/
function useCountdown(expiresAt: number, onExpire: () => void) {
const [remaining, setRemaining] = useState(() =>
Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000))
)
const onExpireRef = useRef(onExpire)
onExpireRef.current = onExpire
useEffect(() => {
// Reset when expiresAt changes
setRemaining(Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)))
const id = setInterval(() => {
const next = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000))
setRemaining(next)
if (next === 0) onExpireRef.current()
}, 1000)
return () => clearInterval(id)
}, [expiresAt])
return remaining
}
// Hooks are in ./qr-hooks.ts (separate file for react-refresh compatibility)
/**
* Hook for clipboard copy with feedback
@ -121,7 +64,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 +84,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,59 @@
import { useState, useEffect, useCallback, useRef } from 'react'
/** Generate a secure random token */
function generateToken(): string {
return crypto.randomUUID()
}
/**
* Hook to manage QR token lifecycle
* - Generates token on mount
* - Auto-refreshes when expired
* - Registers token with Hub
*/
export function useQRToken(agentId: string, expirySeconds: number) {
const [token, setToken] = useState(generateToken)
const [expiresAt, setExpiresAt] = useState(() => Date.now() + expirySeconds * 1000)
const refresh = useCallback(() => {
const newToken = generateToken()
const newExpiry = Date.now() + expirySeconds * 1000
setToken(newToken)
setExpiresAt(newExpiry)
window.electronAPI?.hub.registerToken(newToken, agentId, newExpiry)
}, [agentId, expirySeconds])
// Register initial token
useEffect(() => {
window.electronAPI?.hub.registerToken(token, agentId, expiresAt)
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return { token, expiresAt, refresh }
}
/**
* Hook for countdown timer
* Returns remaining seconds, auto-updates every second
*/
export function useCountdown(expiresAt: number, onExpire: () => void) {
const [remaining, setRemaining] = useState(() =>
Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000))
)
const onExpireRef = useRef(onExpire)
onExpireRef.current = onExpire
useEffect(() => {
// Reset when expiresAt changes
setRemaining(Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)))
const id = setInterval(() => {
const next = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000))
setRemaining(next)
if (next === 0) onExpireRef.current()
}, 1000)
return () => clearInterval(id)
}, [expiresAt])
return remaining
}

View file

@ -0,0 +1,113 @@
import { useState, useEffect } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import { Loader2 } from 'lucide-react'
import { useQRToken, useCountdown } from './qr-hooks'
import { 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

@ -7,8 +7,6 @@ import {
CardTitle,
} from '@multica/ui/components/ui/card'
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 {
Tabs,
TabsContent,
@ -16,169 +14,44 @@ import {
TabsTrigger,
} from '@multica/ui/components/ui/tabs'
import { QrCode, Radio, Smartphone, WifiOff, Loader2 } from 'lucide-react'
import { useChannelsStore } from '../stores/channels'
import { useHubStore, selectPrimaryAgent } from '../stores/hub'
import { ConnectionQRCode } from '../components/qr-code'
import { TelegramConnectQR } from '../components/telegram-qr'
import { DeviceList } from '../components/device-list'
/** Status badge color mapping */
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'
}
}
function TelegramCard() {
const { states, config, saveToken, removeToken, startChannel, stopChannel } = useChannelsStore()
const [token, setToken] = useState('')
const [saving, setSaving] = useState(false)
const [localError, setLocalError] = useState<string | null>(null)
// Current state and config for telegram:default
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 handleSave = 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 save')
} else {
setToken('') // Clear input on success
}
setSaving(false)
}
const handleRemove = async () => {
setSaving(true)
setLocalError(null)
const result = await removeToken('telegram', 'default')
if (!result.ok) {
setLocalError(result.error ?? 'Failed to remove')
}
setSaving(false)
}
const handleToggle = async () => {
setSaving(true)
setLocalError(null)
if (isRunning || isStarting) {
await stopChannel('telegram', 'default')
} else {
await startChannel('telegram', 'default')
}
setSaving(false)
}
// Mask the token for display: show first 5 and last 5 chars
const maskedToken = savedConfig?.botToken
? `${savedConfig.botToken.slice(0, 5)}${'*'.repeat(10)}${savedConfig.botToken.slice(-5)}`
: null
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Telegram</CardTitle>
<CardDescription>
Connect a Telegram bot via Bot API long polling.
</CardDescription>
</div>
{state && (
<Badge variant={statusVariant(state.status)}>
{state.status}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{hasToken ? (
// Token is configured — show masked token and actions
<div className="space-y-3">
<div className="flex items-center gap-2">
<code className="text-sm text-muted-foreground bg-muted px-2 py-1 rounded flex-1 truncate">
{maskedToken}
</code>
</div>
{state?.error && (
<p className="text-sm text-destructive">{state.error}</p>
)}
<div className="flex gap-2">
<Button
variant={isRunning ? 'outline' : 'default'}
size="sm"
onClick={handleToggle}
disabled={saving}
>
{isRunning ? 'Stop' : isStarting ? 'Starting...' : 'Start'}
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleRemove}
disabled={saving || isRunning}
title={isRunning ? 'Stop the bot before removing' : undefined}
>
Remove
</Button>
</div>
</div>
) : (
// No token — show input form
<div className="space-y-3">
<Input
type="password"
placeholder="Bot Token (from @BotFather)"
value={token}
onChange={(e) => setToken(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
/>
<Button
size="sm"
onClick={handleSave}
disabled={saving || !token.trim()}
>
{saving ? 'Saving...' : 'Save & Connect'}
</Button>
</div>
)}
{localError && (
<p className="text-sm text-destructive">{localError}</p>
)}
</CardContent>
</Card>
)
}
function ChannelsTab() {
const { loading, error } = useChannelsStore()
if (loading) {
return <p className="text-sm text-muted-foreground">Loading...</p>
}
if (error) {
return <p className="text-sm text-destructive">{error}</p>
}
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>
<TelegramCard />
<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">
Discord and Slack coming soon.
</p>
</div>
)
}

View file

@ -1,37 +1,31 @@
import { useState } from 'react'
import { useEffect, useState, useCallback } 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 { ChevronLeft, Info, Check, Smartphone } from 'lucide-react'
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'
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@multica/ui/components/ui/alert-dialog'
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 DeviceMeta {
userAgent?: string
platform?: string
language?: string
clientName?: string
}
interface PendingConfirm {
deviceId: string
meta?: DeviceMeta
}
interface ConnectStepProps {
@ -40,34 +34,37 @@ interface ConnectStepProps {
}
export default function ConnectStep({ onNext, onBack }: ConnectStepProps) {
const { states, config, saveToken } = useChannelsStore()
const { hubInfo, agents } = useHubStore()
const primaryAgent = selectPrimaryAgent(agents)
const [connected, setConnected] = useState(false)
const [connectedDevice, setConnectedDevice] = useState<string | null>(null)
const [pending, setPending] = useState<PendingConfirm | null>(null)
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('')
// Listen for device confirm requests during onboarding
useEffect(() => {
window.electronAPI?.hub.onDeviceConfirmRequest((deviceId: string, meta?: DeviceMeta) => {
setPending({ deviceId, meta })
})
return () => {
window.electronAPI?.hub.offDeviceConfirmRequest()
}
setSaving(false)
}
}, [])
const handleAllow = useCallback(() => {
if (!pending) return
window.electronAPI?.hub.deviceConfirmResponse(pending.deviceId, true)
setConnectedDevice(pending.meta?.clientName ?? pending.deviceId)
setPending(null)
setConnected(true)
}, [pending])
const handleReject = useCallback(() => {
if (!pending) return
window.electronAPI?.hub.deviceConfirmResponse(pending.deviceId, false)
setPending(null)
}, [pending])
const deviceLabel = pending?.meta?.clientName ?? pending?.deviceId
return (
<div className="h-full flex items-center justify-center px-6 py-8 animate-in fade-in duration-300">
@ -84,119 +81,49 @@ 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" />
{/* QR code or connected state */}
<div className="flex justify-center py-2">
{connected ? (
<div className="flex flex-col items-center gap-3 py-6">
<div className="size-12 rounded-full bg-green-500/10 flex items-center justify-center">
<Check className="size-6 text-green-500" />
</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>
<p className="text-sm font-medium">Telegram connected</p>
{connectedDevice && (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 rounded-lg px-3 py-2">
<Smartphone className="size-3.5 shrink-0" />
<span>{connectedDevice}</span>
</div>
)}
{/* 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>
) : (
<TelegramConnectQR
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id ?? 'unknown'}
expirySeconds={30}
size={180}
/>
)}
</div>
<Separator />
@ -205,15 +132,38 @@ export default function ConnectStep({ onNext, onBack }: ConnectStepProps) {
<div className="flex items-center justify-between">
<StepDots />
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={onNext}>
Skip
</Button>
<Button size="sm" onClick={onNext} disabled={!hasToken}>
{!connected && (
<Button size="sm" variant="outline" onClick={onNext}>
Skip
</Button>
)}
<Button size="sm" onClick={onNext}>
Continue
</Button>
</div>
</div>
</div>
{/* Device confirm dialog */}
<AlertDialog open={pending !== null}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>New Device Connection</AlertDialogTitle>
<AlertDialogDescription>
<span className="font-medium">{deviceLabel}</span> wants to connect.
<span className="block mt-1">Allow this device?</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleReject}>
Reject
</AlertDialogCancel>
<AlertDialogAction onClick={handleAllow}>
Allow
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View file

@ -12,6 +12,7 @@ async function bootstrap(): Promise<void> {
console.log("[Gateway] NestFactory created");
app.useLogger(app.get(Logger));
app.enableCors();
const port = process.env["PORT"] ?? 3000;
console.log(`[Gateway] Listening on port ${port}...`);

View file

@ -0,0 +1,77 @@
/**
* In-memory short code store for Telegram deep link connection flow.
*
* Maps short alphanumeric codes to full ConnectionInfo objects.
* Codes are one-time use and expire with the underlying connection token.
*/
import { randomBytes } from "node:crypto";
import type { ConnectionInfo } from "@multica/store/connection";
const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const CODE_LENGTH = 12;
const CLEANUP_INTERVAL_MS = 10_000;
interface CodeEntry {
connectionInfo: ConnectionInfo;
}
export class ShortCodeStore {
private codes = new Map<string, CodeEntry>();
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
}
/** Store connection info and return a short code. */
store(connectionInfo: ConnectionInfo): string {
const code = this.generateCode();
this.codes.set(code, { connectionInfo });
return code;
}
/** Retrieve and delete a code (one-time use). Returns null if expired or not found. */
consume(code: string): ConnectionInfo | null {
const entry = this.codes.get(code);
if (!entry) return null;
this.codes.delete(code);
// Check expiry
if (Date.now() > entry.connectionInfo.expires) {
return null;
}
return entry.connectionInfo;
}
/** Stop cleanup interval and clear all codes. */
destroy(): void {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
this.codes.clear();
}
private generateCode(): string {
const bytes = randomBytes(CODE_LENGTH);
let code = "";
for (let i = 0; i < CODE_LENGTH; i++) {
code += CHARS[bytes[i]! % CHARS.length];
}
// Ensure uniqueness (extremely unlikely collision, but safe)
if (this.codes.has(code)) return this.generateCode();
return code;
}
private cleanup(): void {
const now = Date.now();
for (const [code, entry] of this.codes) {
if (now > entry.connectionInfo.expires) {
this.codes.delete(code);
}
}
}
}

View file

@ -0,0 +1,81 @@
/**
* Markdown Telegram HTML converter.
*
* Telegram supports a subset of HTML:
* <b>, <i>, <u>, <s>, <code>, <pre>, <a href="...">, <blockquote>
*
* Strategy:
* 1. Extract code blocks and inline code (protect from further processing)
* 2. Escape HTML entities in remaining text
* 3. Convert Markdown syntax to HTML tags
* 4. Restore code blocks
*/
/** Escape HTML special characters */
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/**
* Convert Markdown text to Telegram-compatible HTML.
* Handles: bold, italic, strikethrough, inline code, code blocks, links, blockquotes.
*/
export function markdownToTelegramHtml(markdown: string): string {
// Placeholder system: replace code blocks/inline code with placeholders,
// process markdown on the rest, then restore.
const placeholders: string[] = [];
const placeholder = (content: string): string => {
const idx = placeholders.length;
placeholders.push(content);
return `\x00PH${idx}\x00`;
};
let text = markdown;
// 1. Fenced code blocks: ```lang\n...\n```
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang: string, code: string) => {
const escaped = escapeHtml(code.replace(/\n$/, ""));
const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : "";
return placeholder(`<pre><code${langAttr}>${escaped}</code></pre>`);
});
// 2. Inline code: `...`
text = text.replace(/`([^`\n]+)`/g, (_match, code: string) => {
return placeholder(`<code>${escapeHtml(code)}</code>`);
});
// 3. Escape HTML in remaining text
text = escapeHtml(text);
// 4. Links: [text](url) — escape quotes in URL to prevent attribute breakout
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label: string, url: string) =>
`<a href="${url.replace(/"/g, "&quot;")}">${label}</a>`,
);
// 5. Bold: **text** or __text__
text = text.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
text = text.replace(/__(.+?)__/g, "<b>$1</b>");
// 6. Italic: *text* or _text_ (but not inside words with underscores)
text = text.replace(/(?<!\w)\*(?!\s)(.+?)(?<!\s)\*(?!\w)/g, "<i>$1</i>");
text = text.replace(/(?<!\w)_(?!\s)(.+?)(?<!\s)_(?!\w)/g, "<i>$1</i>");
// 7. Strikethrough: ~~text~~
text = text.replace(/~~(.+?)~~/g, "<s>$1</s>");
// 8. Blockquotes: > text (at line start)
text = text.replace(/^&gt; (.+)$/gm, "<blockquote>$1</blockquote>");
// Merge adjacent blockquotes
text = text.replace(/<\/blockquote>\n<blockquote>/g, "\n");
// 9. Headings: strip # markers, make bold
text = text.replace(/^#{1,6}\s+(.+)$/gm, "<b>$1</b>");
// Restore placeholders
text = text.replace(/\x00PH(\d+)\x00/g, (_match, idx: string) => placeholders[Number(idx)]!);
return text;
}

View file

@ -1,9 +1,15 @@
/**
* Telegram user store - MySQL persistence layer.
* Telegram user store.
*
* Uses MySQL when MYSQL_DSN is set (production).
* Falls back to JSON file persistence when database is unavailable (local development).
* File stored at ~/.super-multica/gateway/telegram-users.json.
*/
import { Inject, Injectable, Logger } from "@nestjs/common";
import { generateEncryptedId } from "@multica/utils";
import { generateEncryptedId, DATA_DIR } from "@multica/utils";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import type { RowDataPacket } from "mysql2/promise";
import { DatabaseService } from "../database/database.service.js";
import type { TelegramUser, TelegramUserCreate } from "./types.js";
@ -20,15 +26,24 @@ interface TelegramUserRow extends RowDataPacket {
telegram_last_name: string | null;
}
const LOCAL_STORE_DIR = join(DATA_DIR, "gateway");
const LOCAL_STORE_PATH = join(LOCAL_STORE_DIR, "telegram-users.json");
@Injectable()
export class TelegramUserStore {
private readonly logger = new Logger(TelegramUserStore.name);
/** Local file-backed store, keyed by telegramUserId */
private localStore = new Map<string, TelegramUser>();
private localStoreLoaded = false;
constructor(@Inject(DatabaseService) private readonly db: DatabaseService) {}
/** Find user by Telegram user ID */
async findByTelegramUserId(telegramUserId: string): Promise<TelegramUser | null> {
if (!this.db.isAvailable()) return null;
if (!this.db.isAvailable()) {
await this.ensureLocalStoreLoaded();
return this.localStore.get(telegramUserId) ?? null;
}
const rows = await this.db.query<TelegramUserRow[]>(
"SELECT * FROM telegram_users WHERE telegram_user_id = ?",
@ -41,7 +56,13 @@ export class TelegramUserStore {
/** Find user by device ID */
async findByDeviceId(deviceId: string): Promise<TelegramUser | null> {
if (!this.db.isAvailable()) return null;
if (!this.db.isAvailable()) {
await this.ensureLocalStoreLoaded();
for (const user of this.localStore.values()) {
if (user.deviceId === deviceId) return user;
}
return null;
}
const rows = await this.db.query<TelegramUserRow[]>(
"SELECT * FROM telegram_users WHERE device_id = ?",
@ -55,7 +76,7 @@ export class TelegramUserStore {
/** Create or update a Telegram user */
async upsert(data: TelegramUserCreate): Promise<TelegramUser> {
if (!this.db.isAvailable()) {
throw new Error("Database not available");
return this.upsertLocal(data);
}
// Check if user exists
@ -110,6 +131,67 @@ export class TelegramUserStore {
return created!;
}
// ── Local file-backed store (local development only) ──
//
// When MYSQL_DSN is not set the methods below provide a simple JSON-file
// persistence layer so Telegram user bindings survive gateway restarts.
// This is NOT intended for production use — production always uses MySQL.
/** Load store from JSON file on first access */
private async ensureLocalStoreLoaded(): Promise<void> {
if (this.localStoreLoaded) return;
this.localStoreLoaded = true;
try {
const data = await readFile(LOCAL_STORE_PATH, "utf-8");
const records = JSON.parse(data) as Record<string, TelegramUser>;
for (const [key, user] of Object.entries(records)) {
// Restore Date objects from JSON strings
user.createdAt = new Date(user.createdAt);
user.updatedAt = new Date(user.updatedAt);
this.localStore.set(key, user);
}
this.logger.log(`Loaded ${this.localStore.size} Telegram user(s) from ${LOCAL_STORE_PATH}`);
} catch {
// File doesn't exist or is invalid — start fresh
}
}
/** Persist store to JSON file */
private async saveLocalStore(): Promise<void> {
const obj: Record<string, TelegramUser> = {};
for (const [key, user] of this.localStore) {
obj[key] = user;
}
await mkdir(LOCAL_STORE_DIR, { recursive: true });
await writeFile(LOCAL_STORE_PATH, JSON.stringify(obj, null, 2), "utf-8");
}
/** Upsert to local file store */
private async upsertLocal(data: TelegramUserCreate): Promise<TelegramUser> {
await this.ensureLocalStoreLoaded();
const existing = this.localStore.get(data.telegramUserId);
const now = new Date();
const user: TelegramUser = {
telegramUserId: data.telegramUserId,
hubId: data.hubId,
agentId: data.agentId,
deviceId: data.deviceId ?? existing?.deviceId ?? `tg-${generateEncryptedId()}`,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
telegramUsername: data.telegramUsername,
telegramFirstName: data.telegramFirstName,
telegramLastName: data.telegramLastName,
};
this.localStore.set(data.telegramUserId, user);
await this.saveLocalStore();
this.logger.debug(`Local upsert: telegramUserId=${data.telegramUserId}`);
return user;
}
/** Convert database row to TelegramUser object */
private rowToUser(row: TelegramUserRow): TelegramUser {
return {

View file

@ -1,11 +1,13 @@
/**
* Telegram webhook controller.
* Telegram controller.
*
* Receives webhook requests from Telegram Bot API.
* - POST /telegram/webhook Receives webhook requests from Telegram Bot API
* - POST /telegram/connect-code Creates a short code for QR deep link flow
*/
import { Controller, Inject, Logger, Post, Req, Res, Headers } from "@nestjs/common";
import { Body, Controller, HttpException, HttpStatus, Inject, Logger, Post, Req, Res, Headers } from "@nestjs/common";
import { TelegramService } from "./telegram.service.js";
import type { ConnectionInfo } from "@multica/store/connection";
// Minimal Express types for webhook handling
interface ExpressRequest {
@ -25,6 +27,34 @@ export class TelegramController {
constructor(@Inject(TelegramService) private readonly telegramService: TelegramService) {}
@Post("connect-code")
async createConnectCode(
@Body() body: { gateway: string; hubId: string; agentId: string; token: string; expires: number },
): Promise<{ code: string; botUsername: string }> {
if (!this.telegramService.isConfigured()) {
throw new HttpException("Telegram bot not configured", HttpStatus.SERVICE_UNAVAILABLE);
}
const botUsername = this.telegramService.getBotUsername();
if (!botUsername) {
throw new HttpException("Bot username not available", HttpStatus.INTERNAL_SERVER_ERROR);
}
const connectionInfo: ConnectionInfo = {
type: "multica-connect",
gateway: body.gateway,
hubId: body.hubId,
agentId: body.agentId,
token: body.token,
expires: body.expires,
};
const code = this.telegramService.createConnectCode(connectionInfo);
this.logger.debug(`Created connect code: ${code}`);
return { code, botUsername };
}
@Post("webhook")
async handleWebhook(
@Req() req: ExpressRequest,

File diff suppressed because it is too large Load diff

View file

@ -18,6 +18,7 @@
"dev:desktop:onboarding": "pnpm --filter @multica/desktop dev:onboarding",
"dev:gateway": "pnpm --filter @multica/gateway dev",
"dev:web": "pnpm --filter @multica/web dev",
"dev:local": "bash scripts/dev-local.sh",
"dev:all": "concurrently \"pnpm dev:gateway\" \"pnpm dev:web\"",
"build": "turbo build",
"build:desktop": "pnpm --filter @multica/desktop build",

View file

@ -13,13 +13,10 @@ export type {
ChannelsConfig,
} from "./types.js";
// Built-in channel plugins
import { registerChannel } from "./registry.js";
import { telegramChannel } from "./plugins/telegram.js";
/** Register all built-in channel plugins. Call once at startup. */
export function initChannels(): void {
registerChannel(telegramChannel);
// Telegram: use official bot via Gateway webhook instead of user-created bots.
// The long-polling plugin is kept in plugins/telegram.ts but not registered.
// Future: registerChannel(discordChannel);
// Future: registerChannel(feishuChannel);
}

54
scripts/dev-local.sh Executable file
View file

@ -0,0 +1,54 @@
#!/usr/bin/env bash
#
# Local development: Gateway (with Telegram bot) + Desktop + Web (for login)
#
# Usage:
# pnpm dev:local
#
# Reads TELEGRAM_BOT_TOKEN from .env at the repo root.
# Gateway runs on port 4000 in long-polling mode (no TELEGRAM_WEBHOOK_URL needed).
# Web app runs on port 3000 (default) for OAuth login flow.
# Desktop connects to the local Gateway and uses local Web for login.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/.."
ENV_FILE="$ROOT_DIR/.env"
# Load .env
if [ ! -f "$ENV_FILE" ]; then
echo "Error: .env file not found at $ENV_FILE"
echo "Copy .env.example to .env and fill in TELEGRAM_BOT_TOKEN"
exit 1
fi
set -a
source "$ENV_FILE"
set +a
if [ -z "${TELEGRAM_BOT_TOKEN:-}" ]; then
echo "Error: TELEGRAM_BOT_TOKEN not set in .env"
exit 1
fi
echo "Starting local dev environment..."
echo " Gateway: http://localhost:4000 (Telegram long-polling mode)"
echo " Web: http://localhost:3000 (OAuth login)"
echo " Desktop: connecting to local Gateway + Web"
echo ""
# Build shared packages first
pnpm turbo build --filter=@multica/types --filter=@multica/utils --filter=@multica/core
# Start everything
# Gateway uses PORT=4000 to avoid conflict with Web app on port 3000
exec pnpm concurrently \
-n types,utils,core,gateway,web,desktop \
-c blue,green,yellow,magenta,red,cyan \
"pnpm --filter @multica/types dev" \
"pnpm --filter @multica/utils dev" \
"pnpm --filter @multica/core dev" \
"PORT=4000 pnpm --filter @multica/gateway dev" \
"pnpm --filter @multica/web dev" \
"GATEWAY_URL=http://localhost:4000 MAIN_VITE_WEB_URL=http://localhost:3000 pnpm --filter @multica/desktop dev"