צ'אט תמיכה חי מבוסס WebSocket עם תור והיסטוריה
תארו לעצמכם חנות מקוונת עם 10,000 מבקרים יומיים שמאבדת עד 30% מהלידים כי התגובות לוקחות שעה. לקוח עוזב למתחרה שמציעה צ'אט מיידי. צ'אט מקוון באתר פותר את זה: נציג רואה את המשתמש ויכול לעזור מיד. לפי Statista, צ'אט באתר מגדיל המרות ב-20-40%. עלויות התמיכה יורדות ב-30-50% בזכות אוטומציה של שאלות טיפוסיות וקיצור זמן תגובה. אבל היישום מורכב יותר מחיבור סקריפט מוכן—הוא דורש ארכיטקטורה חזקה שמטפלת באלפי חיבורים בו-זמנית, מנהלת תור, ולעולם לא מאבדת הודעות בזמן ניתוקים. אנחנו מתכננים ופורסים מערכות כאלה למסחר אלקטרוני, SaaS ופינטק. הניסיון שלנו כולל למעלה מ-10 פרויקטים ברמת מורכבות דומה, ואנחנו מבטיחים פעילות יציבה בעומסים של עד 10K סשנים בו-זמניים.
אנחנו לוקחים על עצמנו את כל המחזור: מתכנון שרת WebSocket ועד פריסה וניטור. להלן פרטים טכניים, קוד ולוחות זמנים.
בליבה נמצא שרת WebSocket על Node.js ו-Socket.IO. הבחירה הזו מונעת מביצועים וממנגנוני התחברות מחדש מובנים.
למה WebSocket במקום Long Polling?
Long Polling יוצר עומס מיותר: כל בקשה מייצרת חבילת HTTP חדשה עם כותרות. WebSocket שומר על חיבור קבוע, מפחית תעבורה פי 10 או יותר ומוריד את זמן האחזור ל-50 אלפיות שנייה—מהיר פי 6 מ-Long Polling. לצ'אט עם קהל גדול, זה קריטי.
| תכונה | WebSocket | Long Polling |
|---|---|---|
| זמן אחזור | ~50 אלפיות שנייה | ~300+ אלפיות שנייה |
| תעבורה להודעה | ~100 בתים | ~800 בתים (עם כותרות) |
| קנה מידה | מתאם Redis, Nginx | round-robin, מורכב יותר |
| התחברות מחדש אוטומטית | מובנית | דורשת יישום |
בפועל, ההבדל מורגש ב-100+ סשנים בו-זמניים: WebSocket צורך 60% פחות משאבי CPU.
איך להבטיח חיבור WebSocket יציב בזמן ניתוקים?
Socket.IO (ראו תיעוד) מספק מנגנונים מובנים: heartbeat, timeouts, ונסיגה אקספוננציאלית. אנחנו מגדירים pingInterval ו-pingTimeout לפי העומס של הפרויקט שלכם. בזמן אובדן חיבור, הלקוח מתחבר מחדש אוטומטית, והשרת משחזר את הקשר של הצ'אט. היסטוריית ההודעות נשמרת במסד הנתונים, כך שאף הודעה לא הולכת לאיבוד.
מה קורה כשהשרת קורס?
Socket.IO שומר מצב ב-Redis. לאחר הפעלה מחדש, השרת משחזר סשנים. הודעות שלא נמסרו נשמרות במסד הנתונים ונשלחות מאוחר יותר.
דוגמה להרחבת קנה מידה ל-10K חיבורים
הרחבת WebSocket ל-10K חיבורים מושגת עם מתאם Redis ו-Nginx. כל שרת מטפל בעד 1000 חיבורים; כשחורגים מזה, נוספות מופעים חדשים. ניטור דרך Prometheus+Grafana.
ארכיטקטורה וערימת טכנולוגיות
המערכת מורכבת משלושה חלקים: שרת על Node.js + Socket.IO, ווידג'ט מבקר (React), ופאנל נציג. נתונים נשמרים ב-PostgreSQL עם אינדקסים על chat_id ו-sender_type.
צד שרת: 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>
);
} תהליך עבודה
- אנליטיקה: קביעת עומס צפוי, מספר נציגים ודרישות אינטגרציה.
- עיצוב: ארכיטקטורת שרת, סכמת מסד נתונים, חוזי API.
- יישום: פיתוח שרת, ווידג'ט ופאנל נציג במקביל.
- בדיקות: בדיקות עומס (עד 10K חיבורים), בדיקות סובלנות לתקלות.
- פריסה: הגדרת Nginx (פרוקסי WebSocket), מתאם Redis להרחבה אופקית, ניטור דרך Prometheus+Grafana.
אינטגרציה עם CRM דרך webhooks מאפשרת העברת היסטוריית שיחות. אם צריך, אנחנו מוסיפים תור (Bull/RabbitMQ) להודעות לא מקוונות ושליחת תמלילים באימייל.
טיפול בהודעות לא מקוונות
אם נציג לא מקבל צ'אט בתוך זמן קצוב, ההודעה נשמרת במסד הנתונים ותמליל נשלח לאימייל של הלקוח עם הבטחה להגיב בשעות העבודה. לעסקים בעומס גבוה, אנחנו מוסיפים הסלמה: אם התור חורג מהמגבלה, נוסף נציג שני או מופעלת אינטגרציה עם Telegram.
מה כלול בתוצאה
- תיעוד API והוראות פריסה
- תמונת Docker מוכנה לשרת
- קוד מקור של הווידג'ט ופאנל הנציג
- הדרכה לצוות התמיכה (שעה מקוונת)
- תמיכה חינמית ל-3 חודשים לאחר ההשקה
הניסיון שלנו כולל למעלה מ-10 פרויקטים ברמת מורכבות דומה. אנחנו מציעים אחריות לקוד ל-12 חודשים. לפי Statista, צ'אט באתר מגדיל המרות ב-20-40%. יישום צ'אט תמיכה באמצעות WebSocket הוא השקעה בנאמנות לקוחות. צרו קשר כדי לדון בפרטים ולקבל גישת הדגמה לפאנל הנציג. הזמינו פיתוח של צ'אט חי למשימות שלכם ונשמח לייעץ.
לוח זמנים ליישום
| משימה | לוח זמנים |
|---|---|
| שרת Socket.IO + אירועים בסיסיים | 2–3 ימים |
| ווידג'ט מבקר (React) | 2–3 ימים |
| פאנל נציג | 3–4 ימים |
| היסטוריית צ'אט + תמלילים באימייל | +1–2 ימים |
| מערכת מלאה עם תור וניטור | 8–12 ימים |







