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:
commit
9189883710
15 changed files with 1354 additions and 521 deletions
9
.env.example
Normal file
9
.env.example
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
59
apps/desktop/src/renderer/src/components/qr-hooks.ts
Normal file
59
apps/desktop/src/renderer/src/components/qr-hooks.ts
Normal 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
|
||||
}
|
||||
113
apps/desktop/src/renderer/src/components/telegram-qr.tsx
Normal file
113
apps/desktop/src/renderer/src/components/telegram-qr.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}...`);
|
||||
|
|
|
|||
77
apps/gateway/telegram/short-code-store.ts
Normal file
77
apps/gateway/telegram/short-code-store.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
apps/gateway/telegram/telegram-format.ts
Normal file
81
apps/gateway/telegram/telegram-format.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, """)}">${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(/^> (.+)$/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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
54
scripts/dev-local.sh
Executable 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue