import { useState, useEffect, useRef, useCallback } from 'react'; import { X, Loader2, QrCode, ExternalLink, BookOpen, Eye, EyeOff, Check, AlertCircle, CheckCircle, ShieldCheck, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { Badge } from '@/components/ui/badge'; import { useChannelsStore } from '@/stores/channels'; import { useGatewayStore } from '@/stores/gateway'; import { hostApiFetch } from '@/lib/host-api'; import { subscribeHostEvent } from '@/lib/host-events'; import { cn } from '@/lib/utils'; import { CHANNEL_ICONS, CHANNEL_NAMES, CHANNEL_META, getPrimaryChannels, type ChannelType, type ChannelMeta, type ChannelConfigField, } from '@/types/channel'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import telegramIcon from '@/assets/channels/telegram.svg'; import discordIcon from '@/assets/channels/discord.svg'; import whatsappIcon from '@/assets/channels/whatsapp.svg'; import dingtalkIcon from '@/assets/channels/dingtalk.svg'; import feishuIcon from '@/assets/channels/feishu.svg'; import wecomIcon from '@/assets/channels/wecom.svg'; import qqIcon from '@/assets/channels/qq.svg'; interface ChannelConfigModalProps { initialSelectedType?: ChannelType | null; configuredTypes?: string[]; showChannelName?: boolean; allowExistingConfig?: boolean; onClose: () => void; onChannelSaved?: (channelType: ChannelType) => void | Promise; } const inputClasses = 'h-[44px] rounded-xl font-mono text-[13px] bg-[#eeece3] dark:bg-[#151514] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40'; const labelClasses = 'text-[14px] text-foreground/80 font-bold'; const outlineButtonClasses = 'h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground'; const primaryButtonClasses = 'h-9 text-[13px] font-medium rounded-full px-4 shadow-none'; export function ChannelConfigModal({ initialSelectedType = null, configuredTypes = [], showChannelName = true, allowExistingConfig = true, onClose, onChannelSaved, }: ChannelConfigModalProps) { const { t } = useTranslation('channels'); const { channels, addChannel, fetchChannels } = useChannelsStore(); const [selectedType, setSelectedType] = useState(initialSelectedType); const [configValues, setConfigValues] = useState>({}); const [channelName, setChannelName] = useState(''); const [connecting, setConnecting] = useState(false); const [showSecrets, setShowSecrets] = useState>({}); const [qrCode, setQrCode] = useState(null); const [validating, setValidating] = useState(false); const [loadingConfig, setLoadingConfig] = useState(false); const [isExistingConfig, setIsExistingConfig] = useState(false); const firstInputRef = useRef(null); const [validationResult, setValidationResult] = useState<{ valid: boolean; errors: string[]; warnings: string[]; } | null>(null); const meta: ChannelMeta | null = selectedType ? CHANNEL_META[selectedType] : null; useEffect(() => { setSelectedType(initialSelectedType); }, [initialSelectedType]); useEffect(() => { if (!selectedType) { setConfigValues({}); setChannelName(''); setIsExistingConfig(false); setValidationResult(null); setQrCode(null); setConnecting(false); hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => {}); return; } const shouldLoadExistingConfig = allowExistingConfig && configuredTypes.includes(selectedType); if (!shouldLoadExistingConfig) { setConfigValues({}); setIsExistingConfig(false); setLoadingConfig(false); setChannelName(showChannelName ? CHANNEL_NAMES[selectedType] : ''); return; } let cancelled = false; setLoadingConfig(true); setChannelName(showChannelName ? CHANNEL_NAMES[selectedType] : ''); (async () => { try { const result = await hostApiFetch<{ success: boolean; values?: Record }>( `/api/channels/config/${encodeURIComponent(selectedType)}` ); if (cancelled) return; if (result.success && result.values && Object.keys(result.values).length > 0) { setConfigValues(result.values); setIsExistingConfig(true); } else { setConfigValues({}); setIsExistingConfig(false); } } catch { if (!cancelled) { setConfigValues({}); setIsExistingConfig(false); } } finally { if (!cancelled) setLoadingConfig(false); } })(); return () => { cancelled = true; }; }, [allowExistingConfig, configuredTypes, selectedType, showChannelName]); useEffect(() => { if (selectedType && !loadingConfig && showChannelName && firstInputRef.current) { firstInputRef.current.focus(); } }, [selectedType, loadingConfig, showChannelName]); const finishSave = useCallback(async (channelType: ChannelType) => { const displayName = showChannelName && channelName.trim() ? channelName.trim() : CHANNEL_NAMES[channelType]; const existingChannel = channels.find((channel) => channel.type === channelType); if (!existingChannel) { await addChannel({ type: channelType, name: displayName, token: meta?.configFields[0]?.key ? configValues[meta.configFields[0].key] : undefined, }); } else { await fetchChannels(); } await onChannelSaved?.(channelType); }, [addChannel, channelName, channels, configValues, fetchChannels, meta?.configFields, onChannelSaved, showChannelName]); useEffect(() => { if (selectedType !== 'whatsapp') return; const onQr = (...args: unknown[]) => { const data = args[0] as { qr: string; raw: string }; void data.raw; setQrCode(`data:image/png;base64,${data.qr}`); }; const onSuccess = async (...args: unknown[]) => { const data = args[0] as { accountId?: string } | undefined; void data?.accountId; toast.success(t('toast.whatsappConnected')); try { const saveResult = await hostApiFetch<{ success?: boolean; error?: string }>('/api/channels/config', { method: 'POST', body: JSON.stringify({ channelType: 'whatsapp', config: { enabled: true } }), }); if (!saveResult?.success) { throw new Error(saveResult?.error || 'Failed to save WhatsApp config'); } await finishSave('whatsapp'); useGatewayStore.getState().restart().catch(console.error); onClose(); } catch (error) { toast.error(t('toast.configFailed', { error: String(error) })); setConnecting(false); } }; const onError = (...args: unknown[]) => { const err = args[0] as string; toast.error(t('toast.whatsappFailed', { error: err })); setQrCode(null); setConnecting(false); }; const removeQrListener = subscribeHostEvent('channel:whatsapp-qr', onQr); const removeSuccessListener = subscribeHostEvent('channel:whatsapp-success', onSuccess); const removeErrorListener = subscribeHostEvent('channel:whatsapp-error', onError); return () => { removeQrListener(); removeSuccessListener(); removeErrorListener(); hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => {}); }; }, [selectedType, finishSave, onClose, t]); const handleValidate = async () => { if (!selectedType) return; setValidating(true); setValidationResult(null); try { const result = await hostApiFetch<{ success: boolean; valid?: boolean; errors?: string[]; warnings?: string[]; details?: Record; }>('/api/channels/credentials/validate', { method: 'POST', body: JSON.stringify({ channelType: selectedType, config: configValues }), }); const warnings = result.warnings || []; if (result.valid && result.details) { const details = result.details; if (details.botUsername) warnings.push(`Bot: @${details.botUsername}`); if (details.guildName) warnings.push(`Server: ${details.guildName}`); if (details.channelName) warnings.push(`Channel: #${details.channelName}`); } setValidationResult({ valid: result.valid || false, errors: result.errors || [], warnings, }); } catch (error) { setValidationResult({ valid: false, errors: [String(error)], warnings: [], }); } finally { setValidating(false); } }; const handleConnect = async () => { if (!selectedType || !meta) return; setConnecting(true); setValidationResult(null); try { if (meta.connectionType === 'qr') { await hostApiFetch('/api/channels/whatsapp/start', { method: 'POST', body: JSON.stringify({ accountId: 'default' }), }); return; } if (meta.connectionType === 'token') { const validationResponse = await hostApiFetch<{ success: boolean; valid?: boolean; errors?: string[]; warnings?: string[]; details?: Record; }>('/api/channels/credentials/validate', { method: 'POST', body: JSON.stringify({ channelType: selectedType, config: configValues }), }); if (!validationResponse.valid) { setValidationResult({ valid: false, errors: validationResponse.errors || ['Validation failed'], warnings: validationResponse.warnings || [], }); setConnecting(false); return; } const warnings = validationResponse.warnings || []; if (validationResponse.details) { const details = validationResponse.details; if (details.botUsername) warnings.push(`Bot: @${details.botUsername}`); if (details.guildName) warnings.push(`Server: ${details.guildName}`); if (details.channelName) warnings.push(`Channel: #${details.channelName}`); } setValidationResult({ valid: true, errors: [], warnings, }); } const config: Record = { ...configValues }; const saveResult = await hostApiFetch<{ success?: boolean; error?: string; warning?: string; }>('/api/channels/config', { method: 'POST', body: JSON.stringify({ channelType: selectedType, config }), }); if (!saveResult?.success) { throw new Error(saveResult?.error || 'Failed to save channel config'); } if (typeof saveResult.warning === 'string' && saveResult.warning) { toast.warning(saveResult.warning); } await finishSave(selectedType); toast.success(t('toast.channelSaved', { name: meta.name })); toast.success(t('toast.channelConnecting', { name: meta.name })); await new Promise((resolve) => setTimeout(resolve, 800)); onClose(); } catch (error) { toast.error(t('toast.configFailed', { error: String(error) })); setConnecting(false); } }; const openDocs = () => { if (!meta?.docsUrl) return; const url = t(meta.docsUrl); try { if (window.electron?.openExternal) { window.electron.openExternal(url); } else { window.open(url, '_blank'); } } catch { window.open(url, '_blank'); } }; const isFormValid = () => { if (!meta) return false; return meta.configFields .filter((field) => field.required) .every((field) => configValues[field.key]?.trim()); }; const updateConfigValue = (key: string, value: string) => { setConfigValues((prev) => ({ ...prev, [key]: value })); }; const toggleSecretVisibility = (key: string) => { setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] })); }; return (
event.stopPropagation()} >
{selectedType ? isExistingConfig ? t('dialog.updateTitle', { name: CHANNEL_NAMES[selectedType] }) : t('dialog.configureTitle', { name: CHANNEL_NAMES[selectedType] }) : t('dialog.addTitle')} {selectedType && isExistingConfig ? t('dialog.existingDesc') : meta ? t(meta.description.replace('channels:', '')) : t('dialog.selectDesc')}
{!selectedType ? (
{getPrimaryChannels().map((type) => { const channelMeta = CHANNEL_META[type]; const isConfigured = configuredTypes.includes(type); return ( ); })}
) : qrCode ? (
{qrCode.startsWith('data:image') ? ( Scan QR Code ) : (
)}

{t('dialog.scanQR', { name: meta?.name })}

) : loadingConfig ? (
{t('dialog.loadingConfig')}
) : (
{isExistingConfig && (
{t('dialog.existingHint')}
)}

{t('dialog.howToConnect')}

{meta ? t(meta.description.replace('channels:', '')) : ''}

    {meta?.instructions.map((instruction, index) => (
  1. {t(instruction)}
  2. ))}
{showChannelName && (
setChannelName(event.target.value)} className={inputClasses} />
)}
{meta?.configFields.map((field) => ( updateConfigValue(field.key, value)} showSecret={showSecrets[field.key] || false} onToggleSecret={() => toggleSecretVisibility(field.key)} /> ))}
{validationResult && (
{validationResult.valid ? ( ) : ( )}

{validationResult.valid ? t('dialog.credentialsVerified') : t('dialog.validationFailed')}

{validationResult.errors.length > 0 && (
    {validationResult.errors.map((err, index) => (
  • {err}
  • ))}
)} {validationResult.valid && validationResult.warnings.length > 0 && (
{validationResult.warnings.map((info, index) => (

{info}

))}
)} {!validationResult.valid && validationResult.warnings.length > 0 && (

{t('dialog.warnings')}

    {validationResult.warnings.map((warn, index) => (
  • {warn}
  • ))}
)}
)}
{meta?.connectionType === 'token' && ( )}
)}
); } interface ConfigFieldProps { field: ChannelConfigField; value: string; onChange: (value: string) => void; showSecret: boolean; onToggleSecret: () => void; } function ChannelLogo({ type }: { type: ChannelType }) { switch (type) { case 'telegram': return Telegram; case 'discord': return Discord; case 'whatsapp': return WhatsApp; case 'dingtalk': return DingTalk; case 'feishu': return Feishu; case 'wecom': return WeCom; case 'qqbot': return QQ; default: return {CHANNEL_ICONS[type] || '💬'}; } } function ConfigField({ field, value, onChange, showSecret, onToggleSecret }: ConfigFieldProps) { const { t } = useTranslation('channels'); const isPassword = field.type === 'password'; return (
onChange(event.target.value)} className={inputClasses} /> {isPassword && ( )}
{field.description && (

{t(field.description)}

)} {field.envVar && (

{t('dialog.envVar', { var: field.envVar })}

)}
); }