/** * Channels Page * Manage messaging channel connections with configuration UI */ import { useState, useEffect, useCallback, useRef } from 'react'; import { RefreshCw, Trash2, QrCode, Loader2, X, 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 { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useChannelsStore } from '@/stores/channels'; import { useGatewayStore } from '@/stores/gateway'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { hostApiFetch } from '@/lib/host-api'; import { subscribeHostEvent } from '@/lib/host-events'; import { invokeIpc } from '@/lib/api-client'; import { cn } from '@/lib/utils'; import { CHANNEL_ICONS, CHANNEL_NAMES, CHANNEL_META, getPrimaryChannels, type ChannelType, type Channel, 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'; export function Channels() { const { t } = useTranslation('channels'); const { channels, loading, error, fetchChannels, deleteChannel } = useChannelsStore(); const gatewayStatus = useGatewayStore((state) => state.status); const [showAddDialog, setShowAddDialog] = useState(false); const [selectedChannelType, setSelectedChannelType] = useState(null); const [configuredTypes, setConfiguredTypes] = useState([]); const [channelToDelete, setChannelToDelete] = useState<{ id: string } | null>(null); // Fetch channels on mount useEffect(() => { fetchChannels(); }, [fetchChannels]); // Fetch configured channel types from config file const fetchConfiguredTypes = useCallback(async () => { try { const result = await hostApiFetch<{ success: boolean; channels?: string[]; }>('/api/channels/configured'); if (result.success && result.channels) { setConfiguredTypes(result.channels); } } catch { // ignore } }, []); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect void fetchConfiguredTypes(); }, [fetchConfiguredTypes]); useEffect(() => { const unsubscribe = subscribeHostEvent('gateway:channel-status', () => { fetchChannels(); fetchConfiguredTypes(); }); return () => { if (typeof unsubscribe === 'function') { unsubscribe(); } }; }, [fetchChannels, fetchConfiguredTypes]); // Get channel types to display const displayedChannelTypes = getPrimaryChannels(); if (loading) { return (
); } const safeChannels = Array.isArray(channels) ? channels : []; return (
{/* Header */}

{t('title') || 'Channels'}

{t('subtitle') || 'Connect to messaging platforms.'}

{/* Content Area */}
{/* Gateway Warning */} {gatewayStatus.state !== 'running' && (
{t('gatewayWarning')}
)} {/* Error Display */} {error && (
{error}
)} {/* Available Channels (Configured) */} {safeChannels.length > 0 && (

Available Channels

{safeChannels.map((channel) => ( setChannelToDelete({ id: channel.id })} /> ))}
)} {/* Supported Channels (Not yet configured) */}

Supported Channels

{displayedChannelTypes.map((type) => { const meta = CHANNEL_META[type]; const isConfigured = safeChannels.some(c => c.type === type) || configuredTypes.includes(type); // Hide already configured channels from "Supported Channels" section if (isConfigured) return null; return ( ); })}
{/* Add Channel Dialog */} {showAddDialog && ( { setShowAddDialog(false); setSelectedChannelType(null); }} onChannelAdded={() => { fetchChannels(); fetchConfiguredTypes(); setShowAddDialog(false); setSelectedChannelType(null); }} /> )} { if (channelToDelete) { await deleteChannel(channelToDelete.id); // Immediately update configuredTypes state so it disappears from available and appears in supported const channelType = channelToDelete.id.split('-')[0]; setConfiguredTypes((prev) => prev.filter((type) => type !== channelType)); setChannelToDelete(null); } }} onCancel={() => setChannelToDelete(null)} />
); } // ==================== Channel Logo Component ==================== 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] || '💬'}; } } // ==================== Channel Card Component ==================== interface ChannelCardProps { channel: Channel; onDelete: () => void; } function ChannelCard({ channel, onDelete }: ChannelCardProps) { const { t } = useTranslation('channels'); const meta = CHANNEL_META[channel.type]; return (

{channel.name}

{meta?.isPlugin && ( {t('pluginBadge', 'Plugin')} )}
{channel.error ? (

{channel.error}

) : (

{meta ? t(meta.description.replace('channels:', '')) : CHANNEL_NAMES[channel.type]}

)}
); } // ==================== Add Channel Dialog ==================== interface AddChannelDialogProps { selectedType: ChannelType | null; onSelectType: (type: ChannelType | null) => void; onClose: () => void; onChannelAdded: () => void; } function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded }: AddChannelDialogProps) { const { t } = useTranslation('channels'); const { addChannel } = useChannelsStore(); 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; // Load existing config when a channel type is selected useEffect(() => { if (!selectedType) { setConfigValues({}); setChannelName(''); setIsExistingConfig(false); setChannelName(''); setIsExistingConfig(false); // Ensure we clean up any pending QR session if switching away hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { }); return; } let cancelled = false; setLoadingConfig(true); (async () => { try { const result = await invokeIpc( 'channel:getFormValues', selectedType ) as { success: boolean; values?: Record }; 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; }; }, [selectedType]); // Focus first input when form is ready (avoids Windows focus loss after native dialogs) useEffect(() => { if (selectedType && !loadingConfig && firstInputRef.current) { firstInputRef.current.focus(); } }, [selectedType, loadingConfig]); // Listen for WhatsApp QR events useEffect(() => { if (selectedType !== 'whatsapp') return; const onQr = (...args: unknown[]) => { const data = args[0] as { qr: string; raw: string }; setQrCode(`data:image/png;base64,${data.qr}`); }; const onSuccess = async (...args: unknown[]) => { const data = args[0] as { accountId?: string } | undefined; toast.success(t('toast.whatsappConnected')); const accountId = data?.accountId || channelName.trim() || 'default'; 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) { console.error('Failed to save WhatsApp config:', saveResult?.error); } else { console.info('Saved WhatsApp config for account:', accountId); } } catch (error) { console.error('Failed to save WhatsApp config:', error); } // Register the channel locally so it shows up immediately addChannel({ type: 'whatsapp', name: channelName || 'WhatsApp', }).then(() => { // Restart gateway to pick up the new session useGatewayStore.getState().restart().catch(console.error); onChannelAdded(); }); }; const onError = (...args: unknown[]) => { const err = args[0] as string; console.error('WhatsApp Login Error:', err); 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 () => { if (typeof removeQrListener === 'function') removeQrListener(); if (typeof removeSuccessListener === 'function') removeSuccessListener(); if (typeof removeErrorListener === 'function') removeErrorListener(); // Cancel when unmounting or switching types hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { }); }; }, [selectedType, addChannel, channelName, onChannelAdded, 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 { // For QR-based channels, request QR code if (meta.connectionType === 'qr') { const accountId = channelName.trim() || 'default'; await hostApiFetch('/api/channels/whatsapp/start', { method: 'POST', body: JSON.stringify({ accountId }), }); // The QR code will be set via event listener return; } // Step 1: Validate credentials against the actual service API 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; } // Show success details (bot name, guild name, etc.) as warnings/info 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}`); } } // Show validation success with details setValidationResult({ valid: true, errors: [], warnings, }); } // Step 2: Save channel configuration via IPC const config: Record = { ...configValues }; const saveResult = await hostApiFetch<{ success?: boolean; error?: string; warning?: string; pluginInstalled?: boolean; }>('/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); } // Step 3: Add a local channel entry for the UI await addChannel({ type: selectedType, name: channelName || CHANNEL_NAMES[selectedType], token: configValues[meta.configFields[0]?.key] || undefined, }); toast.success(t('toast.channelSaved', { name: meta.name })); // Gateway restart is now handled server-side via debouncedRestart() // inside the channel:saveConfig IPC handler, so we don't need to // trigger it explicitly here. This avoids cascading restarts when // multiple config changes happen in quick succession (e.g. during // the setup wizard). toast.success(t('toast.channelConnecting', { name: meta.name })); // Brief delay so user can see the success state before dialog closes await new Promise((resolve) => setTimeout(resolve, 800)); onChannelAdded(); } catch (error) { toast.error(t('toast.configFailed', { error })); setConnecting(false); } }; const openDocs = () => { if (meta?.docsUrl) { const url = t(meta.docsUrl.replace('channels:', '')); try { if (window.electron?.openExternal) { window.electron.openExternal(url); } else { // Fallback: open in new window window.open(url, '_blank'); } } catch (error) { console.error('Failed to open docs:', error); // Fallback: open in new window window.open(url, '_blank'); } } }; const isFormValid = () => { if (!meta) return false; // Check all required fields are filled 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 (
{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 ? ( // Channel type selection
{getPrimaryChannels().map((type) => { const channelMeta = CHANNEL_META[type]; return ( ); })}
) : qrCode ? ( // QR Code display
{qrCode.startsWith('data:image') ? ( Scan QR Code ) : (
)}

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

) : loadingConfig ? ( // Loading saved config
{t('dialog.loadingConfig')}
) : ( // Connection form
{/* Existing config hint */} {isExistingConfig && (
{t('dialog.existingHint')}
)} {/* Instructions */}

{t('dialog.howToConnect')}

    {meta?.instructions.map((instruction, i) => (
  1. {t(instruction.replace('channels:', ''))}
  2. ))}
{/* Channel name */}
setChannelName(e.target.value)} className="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" />
{/* Configuration fields */} {meta?.configFields.map((field) => ( updateConfigValue(field.key, value)} showSecret={showSecrets[field.key] || false} onToggleSecret={() => toggleSecretVisibility(field.key)} /> ))} {/* Validation Results */} {validationResult && (
{validationResult.valid ? ( ) : ( )}

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

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

{info}

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

{t('dialog.warnings')}

    {validationResult.warnings.map((warn, i) => (
  • {warn}
  • ))}
)}
)}
{/* Validation Button - Only for token-based channels for now */} {meta?.connectionType === 'token' && ( )}
)}
); } // ==================== Config Field Component ==================== interface ConfigFieldProps { field: ChannelConfigField; value: string; onChange: (value: string) => void; showSecret: boolean; onToggleSecret: () => void; } function ConfigField({ field, value, onChange, showSecret, onToggleSecret }: ConfigFieldProps) { const { t } = useTranslation('channels'); const isPassword = field.type === 'password'; return (
onChange(e.target.value)} className="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" /> {isPassword && ( )}
{field.description && (

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

)} {field.envVar && (

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

)}
); } export default Channels;