feat: add whatsapp web qr chat channel (#16238)

Adds a WhatsApp chat channel backed by a QR-based web login flow so users can connect without manual token setup.
This commit is contained in:
buua436
2026-06-23 17:45:31 +08:00
committed by GitHub
parent e15130534f
commit aba5d172bd
16 changed files with 3160 additions and 10 deletions

View File

@@ -1,16 +1,24 @@
import { DynamicForm, FormFieldConfig } from '@/components/dynamic-form';
import { Modal } from '@/components/ui/modal/modal';
import { IModalProps } from '@/interfaces/common';
import { useEffect, useMemo, useState } from 'react';
import { fetchChatChannelRuntime } from '@/services/chat-channel-service';
import { QrCode } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FieldValues } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import {
ChatChannelFormDefaultValues,
ChatChannelKey,
getChatChannelFields,
getChatChannelRuntimeStatusClass,
getChatChannelRuntimeStatusText,
mergeChatChannelFormValues,
} from './constant';
import { IChatChannel, IChatChannelInfo } from './interface';
const getRuntimeSnapshot = (payload: any) =>
payload?.data?.data ?? payload?.data ?? payload?.runtime ?? payload ?? {};
const AddChatChannelModal = ({
visible,
hideModal,
@@ -24,6 +32,10 @@ const AddChatChannelModal = ({
}) => {
const { t } = useTranslation();
const [fields, setFields] = useState<FormFieldConfig[]>([]);
const [runtimeStatus, setRuntimeStatus] = useState('');
const [runtimeError, setRuntimeError] = useState('');
const [runtimeQr, setRuntimeQr] = useState('');
const runtimePollingInFlightRef = useRef(false);
useEffect(() => {
if (channel) {
@@ -31,6 +43,42 @@ const AddChatChannelModal = ({
}
}, [channel]);
const refreshRuntime = useCallback(async () => {
if (channel?.id !== ChatChannelKey.WHATSAPP || !record?.id) {
return;
}
if (runtimePollingInFlightRef.current) {
return;
}
runtimePollingInFlightRef.current = true;
try {
const { data } = await fetchChatChannelRuntime(record.id);
const snapshot = getRuntimeSnapshot(data);
setRuntimeStatus(snapshot?.status || '');
setRuntimeError(snapshot?.last_error || '');
setRuntimeQr(snapshot?.qr_data_url || '');
} catch (error: any) {
setRuntimeError(error?.message || 'Failed to load QR.');
setRuntimeQr('');
} finally {
runtimePollingInFlightRef.current = false;
}
}, [channel?.id, record?.id]);
useEffect(() => {
if (channel?.id !== ChatChannelKey.WHATSAPP || !record?.id) {
return;
}
setRuntimeStatus('');
setRuntimeError('');
setRuntimeQr('');
void refreshRuntime();
const timer = window.setInterval(() => {
void refreshRuntime();
}, 1000);
return () => window.clearInterval(timer);
}, [channel?.id, record?.id, refreshRuntime]);
const defaultValues = useMemo(() => {
const base = channel ? ChatChannelFormDefaultValues[channel.id] : undefined;
return mergeChatChannelFormValues(base, record) as FieldValues;
@@ -63,6 +111,46 @@ const AddChatChannelModal = ({
defaultValues={defaultValues}
labelClassName="font-normal"
>
{channel?.id === ChatChannelKey.WHATSAPP && (
<div className="mb-6 rounded-lg border border-border-button bg-bg-card p-4">
{record?.id ? (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div
className={`flex items-center gap-2 rounded-full border px-2 py-1 text-sm ${getChatChannelRuntimeStatusClass(runtimeStatus)}`}
>
<QrCode className="size-4" />
<span>
{getChatChannelRuntimeStatusText(runtimeStatus)}
</span>
</div>
</div>
{runtimeError ? (
<div className="text-sm text-state-error">{runtimeError}</div>
) : null}
{runtimeQr && runtimeStatus !== 'connected' ? (
<img
src={runtimeQr}
alt="WhatsApp QR"
className="mx-auto w-56 max-w-full rounded-lg border border-border-button bg-white"
/>
) : runtimeStatus === 'connected' ? (
<div className="text-sm text-state-success">
Channel is connected.
</div>
) : !runtimeStatus ? (
<div className="text-sm text-text-secondary">
QR will appear after the channel starts.
</div>
) : null}
</div>
) : (
<div className="text-sm text-text-secondary">
Save this WhatsApp channel first, then scan the QR code here.
</div>
)}
</div>
)}
<div className="absolute bottom-0 right-0 left-0 flex items-center justify-end w-full gap-2 py-6 px-6">
<DynamicForm.CancelButton
handleCancel={() => {

View File

@@ -1,15 +1,25 @@
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Modal } from '@/components/ui/modal/modal';
import { useSetModalState } from '@/hooks/common-hooks';
import { Link2, Settings, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { fetchChatChannelRuntime } from '@/services/chat-channel-service';
import { Link2, QrCode, RefreshCw, Settings, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatChannelInfo } from '../constant';
import {
ChatChannelKey,
getChatChannelRuntimeStatusClass,
getChatChannelRuntimeStatusText,
useChatChannelInfo,
} from '../constant';
import { useDeleteChatChannel, useFetchChatChannelDetail } from '../hooks';
import { IChatChannel, IChatChannelBase, IChatChannelInfo } from '../interface';
import ConnectDialogModal from './connect-dialog-modal';
import { delChannelModal } from './delete-channel-modal';
const getRuntimeSnapshot = (payload: any) =>
payload?.data?.data ?? payload?.data ?? payload?.runtime ?? payload ?? {};
export type IAddedChannelCardProps = IChatChannelInfo & {
list: IChatChannelBase[];
onEdit: (channel: IChatChannelInfo, record: IChatChannel) => void;
@@ -29,6 +39,13 @@ export const AddedChannelCard = (props: IAddedChannelCardProps) => {
const [connectTarget, setConnectTarget] = useState<
IChatChannelBase | undefined
>(undefined);
const [qrVisible, setQrVisible] = useState(false);
const [qrChannelId, setQrChannelId] = useState<string>('');
const [qrLoading, setQrLoading] = useState(false);
const [qrData, setQrData] = useState<string>('');
const [qrStatus, setQrStatus] = useState<string>('');
const [qrError, setQrError] = useState<string>('');
const qrPollingInFlightRef = useRef(false);
const handleEdit = async (id: string) => {
const record = await fetchDetail(id);
@@ -42,6 +59,42 @@ export const AddedChannelCard = (props: IAddedChannelCardProps) => {
showConnectModal();
};
const loadQr = useCallback(async () => {
if (!qrChannelId) {
return;
}
if (qrPollingInFlightRef.current) {
return;
}
qrPollingInFlightRef.current = true;
setQrLoading(true);
try {
const { data } = await fetchChatChannelRuntime(qrChannelId);
const snapshot = getRuntimeSnapshot(data);
setQrData(snapshot?.qr_data_url || '');
setQrStatus(snapshot?.status || '');
setQrError(snapshot?.last_error || '');
} catch (error: any) {
setQrData('');
setQrStatus('');
setQrError(error?.message || 'Failed to load QR.');
} finally {
qrPollingInFlightRef.current = false;
setQrLoading(false);
}
}, [qrChannelId]);
useEffect(() => {
if (!qrVisible) {
return;
}
void loadQr();
const timer = window.setInterval(() => {
void loadQr();
}, 1000);
return () => window.clearInterval(timer);
}, [qrVisible, loadQr]);
return (
<Card className="bg-transparent border border-border-button px-5 pt-[10px] pb-5 rounded-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-0 pb-3">
@@ -85,6 +138,22 @@ export const AddedChannelCard = (props: IAddedChannelCardProps) => {
>
<Settings size={14} />
</Button>
{channel.channel === ChatChannelKey.WHATSAPP && (
<Button
variant={'ghost'}
className="rounded-lg px-2 py-1 bg-transparent hover:bg-bg-card"
onClick={() => {
setQrChannelId(item.id);
setQrData('');
setQrStatus('');
setQrError('');
setQrVisible(true);
}}
title="Show QR"
>
<QrCode size={14} />
</Button>
)}
<Button
variant={'ghost'}
className="rounded-lg px-2 py-1 bg-transparent hover:bg-state-error-5 hover:text-state-error"
@@ -111,6 +180,46 @@ export const AddedChannelCard = (props: IAddedChannelCardProps) => {
channel={connectTarget}
/>
)}
<Modal
open={qrVisible}
onOpenChange={(open) => !open && setQrVisible(false)}
title="WhatsApp QR Code"
size="large"
footer={
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => void loadQr()}>
<RefreshCw className={qrLoading ? 'animate-spin' : ''} />
Refresh
</Button>
<Button onClick={() => setQrVisible(false)}>
{t('common.close')}
</Button>
</div>
}
>
<div className="flex flex-col gap-3">
<div
className={`inline-flex items-center gap-2 rounded-full border px-2 py-1 text-sm ${getChatChannelRuntimeStatusClass(qrStatus)}`}
>
<QrCode className="size-4" />
<span>{getChatChannelRuntimeStatusText(qrStatus)}</span>
</div>
{qrError ? (
<div className="text-sm text-state-error">{qrError}</div>
) : null}
{qrData ? (
<img
src={qrData}
alt="WhatsApp QR"
className="mx-auto w-56 max-w-full rounded-lg border border-border-button bg-white"
/>
) : (
<div className="text-sm text-text-secondary">
QR is not ready yet.
</div>
)}
</div>
</Modal>
</Card>
);
};

View File

@@ -93,6 +93,60 @@ export const useChatChannelInfo = () => {
return { chatChannelInfo };
};
export const getChatChannelRuntimeStatusClass = (status?: string) => {
const normalized = (status || '').toLowerCase();
if (normalized === 'connected') {
return 'bg-state-success/10 text-state-success border-state-success/20';
}
if (
normalized === 'connecting' ||
normalized === 'reconnecting' ||
normalized === 'qr'
) {
return 'bg-state-warning/10 text-state-warning border-state-warning/20';
}
if (normalized === 'waiting') {
return 'bg-state-warning/10 text-state-warning border-state-warning/20';
}
if (
normalized === 'error' ||
normalized === 'disconnected' ||
normalized === 'stopped'
) {
return 'bg-state-error/10 text-state-error border-state-error/20';
}
return 'bg-gray-500/10 text-text-secondary border-border-button';
};
export const getChatChannelRuntimeStatusText = (status?: string) => {
const normalized = (status || '').toLowerCase();
if (normalized === 'connected') {
return 'Connected';
}
if (normalized === 'connecting') {
return 'Connecting...';
}
if (normalized === 'reconnecting') {
return 'Reconnecting...';
}
if (normalized === 'qr') {
return 'Scan the QR code below';
}
if (normalized === 'waiting') {
return 'Waiting for the channel to start';
}
if (normalized === 'error') {
return 'Runtime error';
}
if (normalized === 'disconnected') {
return 'Disconnected';
}
if (normalized === 'stopped') {
return 'Stopped';
}
return 'Waiting for runtime...';
};
const isPlainObject = (value: unknown): value is Record<string, any> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -697,7 +751,6 @@ ChatChannelFormDefaultValues[
].config.credential.connection_type = 'webhook';
ChatChannelFormDefaultValues[ChatChannelKey.FEISHU].config.credential.domain =
'feishu';
export const getChatChannelFields = (
key?: ChatChannelKey,
): FormFieldConfig[] => {

View File

@@ -102,9 +102,16 @@ export const useAddChatChannel = () => {
queryClient.invalidateQueries({
queryKey: ChatChannelKeys.detail(values.id),
});
} else if (values.channel === ChatChannelKey.WHATSAPP) {
setEditingRecord(res.data as IChatChannel);
queryClient.invalidateQueries({
queryKey: ChatChannelKeys.detail(res.data?.id),
});
}
message.success(t('message.operated'));
hideModal();
if (isEdit || values.channel !== ChatChannelKey.WHATSAPP) {
hideModal();
}
}
} finally {
setLoading(false);

View File

@@ -66,6 +66,7 @@ const ChatChannel = () => {
ChatChannelKey.TELEGRAM,
ChatChannelKey.QQBOT,
ChatChannelKey.WECOM,
ChatChannelKey.WHATSAPP,
].includes(id), // Show only selected chat channels
)
.map((id) => ({

View File

@@ -28,4 +28,7 @@ export const updateChatChannel = (id: string, data: Record<string, any>) =>
export const deleteChatChannel = (id: string) =>
request.delete(api.chatChannelDel(id));
export const fetchChatChannelRuntime = (id: string) =>
request.get(api.chatChannelRuntime(id));
export default chatChannelService;

View File

@@ -102,6 +102,8 @@ export default {
chatChannelDetail: (id: string) => `${restAPIv1}/chat-channels/${id}`,
chatChannelUpdate: (id: string) => `${restAPIv1}/chat-channels/${id}`,
chatChannelDel: (id: string) => `${restAPIv1}/chat-channels/${id}`,
chatChannelRuntime: (id: string) =>
`${restAPIv1}/chat-channels/${id}/runtime`,
// plugin
llmTools: `${restAPIv1}/plugin/tools`,