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 { 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 { buildQrChannelEventName, isCanonicalOpenClawAccountId, usesPluginManagedQrAccounts, } from '@/lib/channel-alias'; 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 wechatIcon from '@/assets/channels/wechat.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; allowEditAccountId?: boolean; existingAccountIds?: string[]; initialConfigValues?: Record; agentId?: string; accountId?: string; onClose: () => void; onChannelSaved?: (channelType: ChannelType) => void | Promise; } const inputClasses = 'h-[44px] rounded-lg font-mono text-[13px] bg-white/70 dark:bg-muted border-slate-200/80 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-[#0369A1]/25 focus-visible:border-[#0369A1] shadow-sm transition-all text-foreground placeholder:text-foreground/40'; const labelClasses = 'text-[14px] text-foreground/80 font-bold'; const outlineButtonClasses = 'h-9 rounded-lg px-4 text-[13px] font-medium border-slate-200/80 dark:border-white/10 bg-white/60 hover:bg-white dark:bg-white/5 dark:hover:bg-white/10 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, allowEditAccountId = false, existingAccountIds = [], initialConfigValues, agentId, accountId, onClose, onChannelSaved, }: ChannelConfigModalProps) { const { t } = useTranslation('channels'); const { fetchChannels } = useChannelsStore(); const [selectedType, setSelectedType] = useState(initialSelectedType); const [configValues, setConfigValues] = useState>({}); const [channelName, setChannelName] = useState(''); const [accountIdInput, setAccountIdInput] = useState(accountId || ''); const [accountIdError, setAccountIdError] = useState(null); 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; const shouldUseCredentialValidation = selectedType !== 'feishu'; const usesManagedQrAccounts = usesPluginManagedQrAccounts(selectedType); const showAccountIdEditor = allowEditAccountId && !usesManagedQrAccounts; const resolvedAccountId = usesManagedQrAccounts ? (accountId ?? undefined) : showAccountIdEditor ? accountIdInput.trim() : (accountId ?? (agentId ? (agentId === 'main' ? 'default' : agentId) : undefined)); const shouldLoadExistingConfig = Boolean( selectedType && allowExistingConfig && configuredTypes.includes(selectedType) ); const accountIdForConfigLoad = shouldLoadExistingConfig ? resolvedAccountId : undefined; useEffect(() => { setSelectedType(initialSelectedType); }, [initialSelectedType]); useEffect(() => { setAccountIdInput(accountId || ''); setAccountIdError(null); }, [accountId]); useEffect(() => { if (!selectedType) { setConfigValues({}); setChannelName(''); setIsExistingConfig(false); setValidationResult(null); setQrCode(null); setConnecting(false); setAccountIdError(null); return; } if (!shouldLoadExistingConfig) { setConfigValues({}); setIsExistingConfig(false); setLoadingConfig(false); setChannelName(showChannelName ? CHANNEL_NAMES[selectedType] : ''); return; } if (initialConfigValues) { setConfigValues(initialConfigValues); setIsExistingConfig(Object.keys(initialConfigValues).length > 0); setLoadingConfig(false); setChannelName(showChannelName ? CHANNEL_NAMES[selectedType] : ''); return; } let cancelled = false; setLoadingConfig(true); setChannelName(showChannelName ? CHANNEL_NAMES[selectedType] : ''); (async () => { try { const accountParam = accountIdForConfigLoad ? `?accountId=${encodeURIComponent(accountIdForConfigLoad)}` : ''; const result = await hostApiFetch<{ success: boolean; values?: Record }>( `/api/channels/config/${encodeURIComponent(selectedType)}${accountParam}` ); 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; }; }, [accountIdForConfigLoad, initialConfigValues, selectedType, shouldLoadExistingConfig, showChannelName]); useEffect(() => { if (selectedType && !loadingConfig && showChannelName && firstInputRef.current) { firstInputRef.current.focus(); } }, [selectedType, loadingConfig, showChannelName]); const finishSave = useCallback(async (channelType: ChannelType) => { await fetchChannels(); await onChannelSaved?.(channelType); }, [fetchChannels, onChannelSaved]); const finishSaveRef = useRef(finishSave); const onCloseRef = useRef(onClose); const translateRef = useRef(t); useEffect(() => { finishSaveRef.current = finishSave; }, [finishSave]); useEffect(() => { onCloseRef.current = onClose; }, [onClose]); useEffect(() => { translateRef.current = t; }, [t]); function normalizeQrImageSource(data: { qr?: string; raw?: string }): string | null { const qr = typeof data.qr === 'string' ? data.qr.trim() : ''; if (qr) { if (qr.startsWith('data:image') || qr.startsWith('http://') || qr.startsWith('https://')) { return qr; } return `data:image/png;base64,${qr}`; } const raw = typeof data.raw === 'string' ? data.raw.trim() : ''; if (!raw) return null; if (raw.startsWith('data:image') || raw.startsWith('http://') || raw.startsWith('https://')) { return raw; } return null; } useEffect(() => { if (!selectedType || meta?.connectionType !== 'qr') return; const channelType = selectedType; const onQr = (...args: unknown[]) => { const data = args[0] as { qr?: string; raw?: string }; const nextQr = normalizeQrImageSource(data); if (!nextQr) return; setQrCode(nextQr); setConnecting(false); }; const onSuccess = async (...args: unknown[]) => { const data = args[0] as { accountId?: string } | undefined; void data?.accountId; toast.success(translateRef.current('toast.qrConnected', { name: CHANNEL_NAMES[channelType] })); try { if (channelType === 'whatsapp') { const saveResult = await hostApiFetch<{ success?: boolean; error?: string }>('/api/channels/config', { method: 'POST', body: JSON.stringify({ channelType: 'whatsapp', config: { enabled: true }, accountId: resolvedAccountId }), }); if (!saveResult?.success) { throw new Error(saveResult?.error || 'Failed to save WhatsApp config'); } } try { await finishSaveRef.current(channelType); } catch (postSaveError) { toast.warning(translateRef.current('toast.savedButRefreshFailed')); console.warn('Channel saved but post-save refresh failed:', postSaveError); } onCloseRef.current(); } catch (error) { toast.error(translateRef.current('toast.configFailed', { error: String(error) })); setConnecting(false); } }; const onError = (...args: unknown[]) => { const err = typeof args[0] === 'string' ? args[0] : String((args[0] as { message?: string } | undefined)?.message || args[0]); const errorText = channelType === 'whatsapp' && /websocket|network|proxy/i.test(err) ? translateRef.current('toast.whatsappNetworkHint', { error: err }) : translateRef.current('toast.qrFailed', { name: CHANNEL_NAMES[channelType], error: err }); toast.error(errorText); setQrCode(null); setConnecting(false); }; const removeQrListener = subscribeHostEvent(buildQrChannelEventName(channelType, 'qr'), onQr); const removeSuccessListener = subscribeHostEvent(buildQrChannelEventName(channelType, 'success'), onSuccess); const removeErrorListener = subscribeHostEvent(buildQrChannelEventName(channelType, 'error'), onError); return () => { removeQrListener(); removeSuccessListener(); removeErrorListener(); hostApiFetch(`/api/channels/${encodeURIComponent(channelType)}/cancel`, { method: 'POST', body: JSON.stringify(resolvedAccountId ? { accountId: resolvedAccountId } : {}), }).catch(() => { }); }; }, [meta?.connectionType, resolvedAccountId, selectedType]); const handleValidate = async () => { if (!selectedType || !shouldUseCredentialValidation) 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 (showAccountIdEditor) { const nextAccountId = accountIdInput.trim(); if (!nextAccountId) { const message = t('account.invalidId'); setAccountIdError(message); toast.error(message); setConnecting(false); return; } if (!isCanonicalOpenClawAccountId(nextAccountId)) { const message = t('account.invalidCanonicalId'); setAccountIdError(message); toast.error(message); setConnecting(false); return; } const duplicateExists = existingAccountIds.some((id) => id === nextAccountId && id !== (accountId || '').trim()); if (duplicateExists) { const message = t('account.accountIdExists', { accountId: nextAccountId }); setAccountIdError(message); toast.error(message); setConnecting(false); return; } setAccountIdError(null); } if (meta.connectionType === 'qr') { const startResult = await hostApiFetch<{ success?: boolean; error?: string }>(`/api/channels/${encodeURIComponent(selectedType)}/start`, { method: 'POST', body: JSON.stringify(resolvedAccountId ? { accountId: resolvedAccountId } : {}), }); if (!startResult?.success) { throw new Error(startResult?.error || 'Failed to start QR login'); } return; } if (meta.connectionType === 'token' && shouldUseCredentialValidation) { 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, accountId: resolvedAccountId }), }); if (!saveResult?.success) { throw new Error(saveResult?.error || 'Failed to save channel config'); } if (typeof saveResult.warning === 'string' && saveResult.warning) { toast.warning(saveResult.warning); } try { await finishSave(selectedType); } catch (postSaveError) { toast.warning(t('toast.savedButRefreshFailed')); console.warn('Channel saved but post-save refresh failed:', postSaveError); } 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 (
{ if (event.target === event.currentTarget) { onClose(); } }} > event.stopPropagation()} onClick={(event) => 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') || qrCode.startsWith('http://') || qrCode.startsWith('https://') ? ( 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} />
)} {showAccountIdEditor && (
{ setAccountIdInput(event.target.value); if (accountIdError) { setAccountIdError(null); } }} placeholder={t('account.customIdPlaceholder')} className={cn(inputClasses, accountIdError && 'border-destructive/50 focus-visible:ring-destructive/30')} /> {accountIdError ? (

{accountIdError}

) : (

{t('account.customIdHint')}

)}
)}
{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' && shouldUseCredentialValidation && ( )}
)}
); } 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 'wechat': return WeChat; 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 })}

)}
); }