Реалізація Real-Time чату поддержки на сайті
Чат поддержки дозволяє користувачам спілкуватися з операторами у реальному часі. Складається з клієнтського віджету, інтерфейсу оператора та серверної логіки маршрутизації.
Архітектура
Користувач (віджет) Оператор (admin UI)
│ │
└─── WebSocket ──► Чат-сервер ◄───────────┘
│
PostgreSQL (історія)
Redis (активні сесії)
Серверна логіка
import { Server } from 'socket.io';
// Черга невідповіданих чатів
const waitingQueue: Map<string, ChatSession> = new Map();
const activeSessions: Map<string, ChatSession> = new Map();
io.on('connection', async (socket) => {
const { role } = socket.data; // 'user' або 'operator'
if (role === 'user') {
handleUserConnection(socket);
} else if (role === 'operator') {
handleOperatorConnection(socket);
}
});
async function handleUserConnection(socket: Socket) {
const userId = socket.data.userId;
// Створити або відновити сесію
let session = await sessionRepo.findActiveByUser(userId);
if (!session) {
session = await sessionRepo.create({
userId,
status: 'waiting',
startedAt: new Date()
});
}
socket.join(`session:${session.id}`);
if (session.status === 'waiting') {
waitingQueue.set(session.id, session);
socket.emit('queue:position', {
position: waitingQueue.size,
estimatedWait: waitingQueue.size * 2 // хвил
});
io.to('operators').emit('queue:updated', { count: waitingQueue.size });
}
socket.on('message:send', async ({ content }) => {
const message = await messageRepo.create({
sessionId: session.id,
senderId: userId,
senderRole: 'user',
content,
sentAt: new Date()
});
io.to(`session:${session.id}`).emit('message:new', message);
});
socket.on('disconnect', () => {
io.to(`session:${session.id}`).emit('user:offline', { userId });
});
}
async function handleOperatorConnection(socket: Socket) {
socket.join('operators');
// Оператор приймає чат з черги
socket.on('session:accept', async ({ sessionId }) => {
const session = waitingQueue.get(sessionId);
if (!session) return;
waitingQueue.delete(sessionId);
await sessionRepo.assign(sessionId, socket.data.userId);
socket.join(`session:${sessionId}`);
io.to(`session:${sessionId}`).emit('operator:joined', {
operatorId: socket.data.userId,
operatorName: socket.data.user.name
});
io.to('operators').emit('queue:updated', { count: waitingQueue.size });
// Відправити історію сообщень оператору
const history = await messageRepo.findBySession(sessionId);
socket.emit('session:history', history);
});
socket.on('message:send', async ({ sessionId, content }) => {
const message = await messageRepo.create({
sessionId,
senderId: socket.data.userId,
senderRole: 'operator',
content
});
io.to(`session:${sessionId}`).emit('message:new', message);
});
// Закрити чат
socket.on('session:close', async ({ sessionId, resolution }) => {
await sessionRepo.close(sessionId, resolution);
io.to(`session:${sessionId}`).emit('session:closed', { resolution });
});
}
Клієнтський віджет (React)
function SupportWidget() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [status, setStatus] = useState<'connecting' | 'waiting' | 'active' | 'closed'>('connecting');
const [input, setInput] = useState('');
const socketRef = useRef<Socket>();
useEffect(() => {
const socket = io('/support', {
auth: { token: getGuestToken() }
});
socket.on('queue:position', ({ position }) => {
setStatus('waiting');
setMessages([{ system: true, text: `Ви в черзі. Позиція: ${position}` }]);
});
socket.on('operator:joined', ({ operatorName }) => {
setStatus('active');
setMessages(prev => [...prev, {
system: true, text: `${operatorName} приєднався`
}]);
});
socket.on('message:new', (msg) => {
setMessages(prev => [...prev, msg]);
});
socketRef.current = socket;
return () => { socket.disconnect(); };
}, []);
const sendMessage = () => {
if (!input.trim()) return;
socketRef.current?.emit('message:send', { content: input });
setInput('');
};
return (
<div className={`chat-widget ${isOpen ? 'open' : ''}`}>
<button className="chat-toggle" onClick={() => setIsOpen(!isOpen)}>
💬 Поддержка
</button>
{isOpen && (
<div className="chat-window">
<MessageList messages={messages} />
{status === 'active' && (
<div className="chat-input">
<input value={input} onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && sendMessage()} />
<button onClick={sendMessage}>Відправити</button>
</div>
)}
</div>
)}
</div>
);
}
Повідомлення оператору
// Якщо оператор не в браузері — повідомлення через Telegram/email
async function notifyOperatorsNewChat(session: ChatSession) {
const onlineOperators = await redis.smembers('online:operators');
if (onlineOperators.length === 0) {
await telegramBot.sendMessage(OPERATORS_CHAT_ID,
`🆕 Новий запит в поддержку\nКористувач: ${session.userId}\nПершого сообщення: ${session.firstMessage}`
);
}
}
Часові рамки
Базовий чат з чергою та історією: 2–3 тижні. Повний: віджет + оператор UI + повідомлення + рейтинг: 4–6 тижнів.







