feat(channel): support weichat channel (#620)

This commit is contained in:
Haze
2026-03-22 17:08:02 +08:00
committed by GitHub
Unverified
parent f12f4a74df
commit 1e7b40a486
32 changed files with 1610 additions and 156 deletions

View File

@@ -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':