feat(channel): support weichat channel (#620)
This commit is contained in:
@@ -32,11 +32,13 @@ import {
|
||||
type ChannelMeta,
|
||||
type ChannelConfigField,
|
||||
} from '@/types/channel';
|
||||
import { buildQrChannelEventName, 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';
|
||||
@@ -95,9 +97,13 @@ export function ChannelConfigModal({
|
||||
|
||||
const meta: ChannelMeta | null = selectedType ? CHANNEL_META[selectedType] : null;
|
||||
const shouldUseCredentialValidation = selectedType !== 'feishu';
|
||||
const resolvedAccountId = allowEditAccountId
|
||||
? accountIdInput.trim()
|
||||
: (accountId ?? (agentId ? (agentId === 'main' ? 'default' : agentId) : undefined));
|
||||
const usesManagedQrAccounts = usesPluginManagedQrAccounts(selectedType);
|
||||
const showAccountIdEditor = allowEditAccountId && !usesManagedQrAccounts;
|
||||
const resolvedAccountId = usesManagedQrAccounts
|
||||
? (accountId ?? undefined)
|
||||
: showAccountIdEditor
|
||||
? accountIdInput.trim()
|
||||
: (accountId ?? (agentId ? (agentId === 'main' ? 'default' : agentId) : undefined));
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedType(initialSelectedType);
|
||||
@@ -115,7 +121,6 @@ export function ChannelConfigModal({
|
||||
setValidationResult(null);
|
||||
setQrCode(null);
|
||||
setConnecting(false);
|
||||
hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -195,63 +200,102 @@ export function ChannelConfigModal({
|
||||
await onChannelSaved?.(channelType);
|
||||
}, [addChannel, channelName, channels, configValues, fetchChannels, meta?.configFields, onChannelSaved, showChannelName]);
|
||||
|
||||
const finishSaveRef = useRef(finishSave);
|
||||
const onCloseRef = useRef(onClose);
|
||||
const translateRef = useRef(t);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedType !== 'whatsapp') return;
|
||||
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 };
|
||||
void data.raw;
|
||||
setQrCode(`data:image/png;base64,${data.qr}`);
|
||||
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(t('toast.whatsappConnected'));
|
||||
toast.success(translateRef.current('toast.qrConnected', { name: CHANNEL_NAMES[channelType] }));
|
||||
try {
|
||||
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');
|
||||
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 finishSave('whatsapp');
|
||||
await finishSaveRef.current(channelType);
|
||||
} catch (postSaveError) {
|
||||
toast.warning(t('toast.savedButRefreshFailed'));
|
||||
toast.warning(translateRef.current('toast.savedButRefreshFailed'));
|
||||
console.warn('Channel saved but post-save refresh failed:', postSaveError);
|
||||
}
|
||||
// Gateway restart is already triggered by scheduleGatewayChannelRestart
|
||||
// in the POST /api/channels/config route handler (debounced). Calling
|
||||
// restart() here directly races with that debounced restart and the
|
||||
// config write, which can cause openclaw.json overwrites.
|
||||
onClose();
|
||||
onCloseRef.current();
|
||||
} catch (error) {
|
||||
toast.error(t('toast.configFailed', { error: String(error) }));
|
||||
toast.error(translateRef.current('toast.configFailed', { error: String(error) }));
|
||||
setConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (...args: unknown[]) => {
|
||||
const err = args[0] as string;
|
||||
toast.error(t('toast.whatsappFailed', { error: err }));
|
||||
const err = typeof args[0] === 'string'
|
||||
? args[0]
|
||||
: String((args[0] as { message?: string } | undefined)?.message || args[0]);
|
||||
toast.error(translateRef.current('toast.qrFailed', { name: CHANNEL_NAMES[channelType], 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);
|
||||
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/whatsapp/cancel', { method: 'POST' }).catch(() => { });
|
||||
hostApiFetch(`/api/channels/${encodeURIComponent(channelType)}/cancel`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(resolvedAccountId ? { accountId: resolvedAccountId } : {}),
|
||||
}).catch(() => { });
|
||||
};
|
||||
}, [finishSave, onClose, resolvedAccountId, selectedType, t]);
|
||||
}, [meta?.connectionType, resolvedAccountId, selectedType]);
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (!selectedType || !shouldUseCredentialValidation) return;
|
||||
@@ -302,7 +346,7 @@ export function ChannelConfigModal({
|
||||
setValidationResult(null);
|
||||
|
||||
try {
|
||||
if (allowEditAccountId) {
|
||||
if (showAccountIdEditor) {
|
||||
const nextAccountId = accountIdInput.trim();
|
||||
if (!nextAccountId) {
|
||||
toast.error(t('account.invalidId'));
|
||||
@@ -318,9 +362,9 @@ export function ChannelConfigModal({
|
||||
}
|
||||
|
||||
if (meta.connectionType === 'qr') {
|
||||
await hostApiFetch('/api/channels/whatsapp/start', {
|
||||
await hostApiFetch(`/api/channels/${encodeURIComponent(selectedType)}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ accountId: resolvedAccountId || 'default' }),
|
||||
body: JSON.stringify(resolvedAccountId ? { accountId: resolvedAccountId } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -513,7 +557,7 @@ export function ChannelConfigModal({
|
||||
) : qrCode ? (
|
||||
<div className="text-center space-y-6">
|
||||
<div className="bg-[#eeece3] dark:bg-muted p-4 rounded-3xl inline-block shadow-sm border border-black/10 dark:border-white/10">
|
||||
{qrCode.startsWith('data:image') ? (
|
||||
{qrCode.startsWith('data:image') || qrCode.startsWith('http://') || qrCode.startsWith('https://') ? (
|
||||
<img src={qrCode} alt="Scan QR Code" className="w-64 h-64 object-contain rounded-2xl" />
|
||||
) : (
|
||||
<div className="w-64 h-64 bg-white dark:bg-background rounded-2xl flex items-center justify-center">
|
||||
@@ -590,7 +634,7 @@ export function ChannelConfigModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allowEditAccountId && (
|
||||
{showAccountIdEditor && (
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor="account-id" className={labelClasses}>{t('account.customIdLabel')}</Label>
|
||||
<Input
|
||||
@@ -693,7 +737,7 @@ export function ChannelConfigModal({
|
||||
onClick={() => {
|
||||
void handleConnect();
|
||||
}}
|
||||
disabled={connecting || !isFormValid() || (allowEditAccountId && !accountIdInput.trim())}
|
||||
disabled={connecting || !isFormValid() || (showAccountIdEditor && !accountIdInput.trim())}
|
||||
className={primaryButtonClasses}
|
||||
>
|
||||
{connecting ? (
|
||||
@@ -736,6 +780,8 @@ function ChannelLogo({ type }: { type: ChannelType }) {
|
||||
return <img src={discordIcon} alt="Discord" className="w-[22px] h-[22px] dark:invert" />;
|
||||
case 'whatsapp':
|
||||
return <img src={whatsappIcon} alt="WhatsApp" className="w-[22px] h-[22px] dark:invert" />;
|
||||
case 'wechat':
|
||||
return <img src={wechatIcon} alt="WeChat" className="w-[22px] h-[22px] dark:invert" />;
|
||||
case 'dingtalk':
|
||||
return <img src={dingtalkIcon} alt="DingTalk" className="w-[22px] h-[22px] dark:invert" />;
|
||||
case 'feishu':
|
||||
|
||||
Reference in New Issue
Block a user