Реализация live-чата поддержки
Live-чат позволяет пользователям общаться с операторами поддержки в реальном времени. Архитектура: WebSocket-сервер (Socket.IO или Laravel Reverb), база для хранения истории, интерфейс оператора с очередью диалогов.
Архитектура
[Visitor widget] ←──WebSocket──→ [Chat Server]
[Operator panel] ←──WebSocket──→ ↕
[Database: chats, messages]
↕
[Queue: offline messages → email]
Backend: Node.js + Socket.IO
import { Server } from 'socket.io';
import { createServer } from 'http';
const io = new Server(createServer(), {
cors: { origin: process.env.FRONTEND_URL, credentials: true },
});
// Авторизация операторов
io.use(async (socket, next) => {
const token = socket.handshake.auth.token;
if (token) {
const user = await verifyToken(token);
if (user?.role === 'operator') {
socket.data.user = user;
socket.data.isOperator = true;
}
}
next();
});
io.on('connection', (socket) => {
const { chatId, isOperator } = socket.data;
// Посетитель начинает чат
socket.on('chat:start', async (data) => {
const chat = await Chat.create({
visitor_name: data.name,
visitor_email: data.email,
page_url: data.pageUrl,
status: 'waiting',
});
socket.join(`chat:${chat.id}`);
socket.data.chatId = chat.id;
// Уведомить всех операторов о новом чате
io.to('operators').emit('chat:new', {
id: chat.id,
visitor_name: chat.visitor_name,
page_url: chat.page_url,
started_at: chat.created_at,
});
socket.emit('chat:started', { chatId: chat.id });
});
// Оператор принимает чат
socket.on('chat:accept', async ({ chatId }) => {
if (!isOperator) return;
const chat = await Chat.findByPk(chatId);
if (!chat || chat.status !== 'waiting') return;
await chat.update({ operator_id: socket.data.user.id, status: 'active', accepted_at: new Date() });
socket.join(`chat:${chatId}`);
// Уведомить посетителя
io.to(`chat:${chatId}`).emit('chat:accepted', {
operator: { name: socket.data.user.name, avatar: socket.data.user.avatar },
});
});
// Сообщение
socket.on('chat:message', async ({ chatId, text }) => {
const chat = await Chat.findByPk(chatId);
if (!chat) return;
const message = await Message.create({
chat_id: chatId,
sender_type: isOperator ? 'operator' : 'visitor',
sender_id: socket.data.user?.id ?? null,
text: sanitize(text),
});
io.to(`chat:${chatId}`).emit('chat:message', {
id: message.id,
text: message.text,
sender_type: message.sender_type,
sent_at: message.created_at,
});
// Обновить время последнего сообщения в чате
await chat.update({ last_message_at: new Date() });
});
// Печатает...
socket.on('chat:typing', ({ chatId }) => {
socket.to(`chat:${chatId}`).emit('chat:typing', {
sender_type: isOperator ? 'operator' : 'visitor',
});
});
// Закрыть чат
socket.on('chat:close', async ({ chatId }) => {
await Chat.update({ status: 'closed', closed_at: new Date() }, { where: { id: chatId } });
io.to(`chat:${chatId}`).emit('chat:closed');
// Отправить транскрипт на email
const chat = await Chat.findByPk(chatId, { include: [Message] });
if (chat.visitor_email) {
await emailService.sendTranscript(chat);
}
});
// Операторы присоединяются к комнате для получения новых чатов
if (isOperator) {
socket.join('operators');
io.to('operators').emit('operator:online', { id: socket.data.user.id });
}
socket.on('disconnect', async () => {
if (isOperator) {
io.to('operators').emit('operator:offline', { id: socket.data.user?.id });
} else if (socket.data.chatId) {
// Посетитель ушёл
io.to(`chat:${socket.data.chatId}`).emit('visitor:left');
}
});
});
React: виджет для посетителя
import { useState, useEffect, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
interface Message {
id: string;
text: string;
sender_type: 'visitor' | 'operator';
sent_at: string;
}
export function ChatWidget() {
const [isOpen, setIsOpen] = useState(false);
const [status, setStatus] = useState<'idle' | 'starting' | 'waiting' | 'active' | 'closed'>('idle');
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [chatId, setChatId] = useState<string | null>(null);
const [operator, setOperator] = useState<{ name: string } | null>(null);
const socketRef = useRef<Socket | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
socketRef.current = io(import.meta.env.VITE_CHAT_SERVER);
const socket = socketRef.current;
socket.on('chat:started', ({ chatId }) => {
setChatId(chatId);
setStatus('waiting');
});
socket.on('chat:accepted', ({ operator }) => {
setOperator(operator);
setStatus('active');
setMessages(prev => [...prev, {
id: 'system-accepted',
text: `${operator.name} подключился к чату`,
sender_type: 'operator',
sent_at: new Date().toISOString(),
}]);
});
socket.on('chat:message', (message) => {
setMessages(prev => [...prev, message]);
});
socket.on('chat:closed', () => setStatus('closed'));
return () => { socket.disconnect(); };
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const startChat = () => {
setStatus('starting');
socketRef.current?.emit('chat:start', {
name: 'Посетитель',
pageUrl: window.location.href,
});
};
const sendMessage = () => {
if (!input.trim() || !chatId) return;
socketRef.current?.emit('chat:message', { chatId, text: input });
setInput('');
};
if (!isOpen) {
return (
<button className="chat-trigger" onClick={() => setIsOpen(true)} aria-label="Открыть чат">
💬
</button>
);
}
return (
<div className="chat-widget" role="dialog" aria-label="Чат поддержки">
<header>
<h2>{operator ? `Чат с ${operator.name}` : 'Чат поддержки'}</h2>
<button onClick={() => setIsOpen(false)} aria-label="Закрыть">×</button>
</header>
<div className="messages" aria-live="polite">
{status === 'idle' && (
<div className="start-screen">
<p>Привет! Чем можем помочь?</p>
<button onClick={startChat}>Начать чат</button>
</div>
)}
{status === 'waiting' && <p>Ищем свободного оператора...</p>}
{messages.map(msg => (
<div key={msg.id} className={`message message--${msg.sender_type}`}>
<p>{msg.text}</p>
<time>{new Date(msg.sent_at).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}</time>
</div>
))}
<div ref={messagesEndRef} />
</div>
{status === 'active' && (
<footer>
<input
value={input}
onChange={e => {
setInput(e.target.value);
socketRef.current?.emit('chat:typing', { chatId });
}}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && (e.preventDefault(), sendMessage())}
placeholder="Напишите сообщение..."
/>
<button onClick={sendMessage} disabled={!input.trim()}>Отправить</button>
</footer>
)}
</div>
);
}
Панель оператора
function OperatorDashboard() {
const [chats, setChats] = useState<Chat[]>([]);
const [activeChat, setActiveChat] = useState<string | null>(null);
// Получить очередь ожидающих чатов
socket.on('chat:new', (chat) => {
setChats(prev => [chat, ...prev]);
// Звуковое уведомление
new Audio('/notification.mp3').play().catch(() => {});
});
const acceptChat = (chatId: string) => {
socket.emit('chat:accept', { chatId });
setActiveChat(chatId);
setChats(prev => prev.filter(c => c.id !== chatId));
};
return (
<div className="operator-dashboard">
<aside className="chat-queue">
<h2>Ожидают ({chats.length})</h2>
{chats.map(chat => (
<div key={chat.id} className="queue-item">
<span>{chat.visitor_name}</span>
<span>{chat.page_url}</span>
<button onClick={() => acceptChat(chat.id)}>Принять</button>
</div>
))}
</aside>
{activeChat && <ChatWindow chatId={activeChat} socket={socket} />}
</div>
);
}
Срок реализации
| Задача | Срок |
|---|---|
| Socket.IO сервер + базовые события | 2–3 дня |
| Виджет посетителя (React) | 2–3 дня |
| Панель оператора | 3–4 дня |
| История чатов + транскрипты на email | +1–2 дня |
| Полная система с очередью и мониторингом | 8–12 дней |







