📋 Что получится
- Виджет чата на сайте
- Панель оператора с очередью обращений
- Индикатор "оператор печатает..."
- Передача чата другому оператору
- История переписки
Любой бэкенд: Примеры на PHP, но паттерн работает с любым языком.
🏗️ Архитектура
1
Посетитель открывает виджет
↓
2
Создаётся тикет, назначается оператор
↓
3
Сообщения идут через Pushler в реальном времени
↓
4
Оператор закрывает тикет
🗄️ База данных
SQL
-- Тикеты (обращения)
CREATE TABLE support_tickets (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
visitor_id VARCHAR(64) NOT NULL, -- UUID или session ID
visitor_name VARCHAR(100),
visitor_email VARCHAR(255),
operator_id BIGINT NULL,
status ENUM('waiting', 'active', 'closed') DEFAULT 'waiting',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
closed_at TIMESTAMP NULL,
INDEX idx_status (status),
INDEX idx_operator (operator_id, status)
);
-- Сообщения
CREATE TABLE support_messages (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
ticket_id BIGINT NOT NULL,
sender_type ENUM('visitor', 'operator', 'system') NOT NULL,
sender_id VARCHAR(64), -- visitor_id или operator user_id
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ticket_id) REFERENCES support_tickets(id),
INDEX idx_ticket (ticket_id, created_at)
);
-- Операторы онлайн
CREATE TABLE support_operators_online (
user_id BIGINT PRIMARY KEY,
active_tickets INT DEFAULT 0,
max_tickets INT DEFAULT 5,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
💻 Код сервера
Создание тикета (посетитель)
PHP
<?php
// create_ticket.php — посетитель начинает чат
require_once 'vendor/autoload.php';
use PushlerRu\PushlerClient;
$pushler = new PushlerClient('key_ваш_ключ', 'secret_ваш_секрет');
header('Content-Type: application/json');
// Генерируем или получаем visitor_id из cookie/session
$visitorId = $_COOKIE['visitor_id'] ?? generateVisitorId();
$name = $_POST['name'] ?? 'Гость';
$email = $_POST['email'] ?? null;
$message = $_POST['message'];
$pdo = getDbConnection();
// Создаём тикет
$stmt = $pdo->prepare('
INSERT INTO support_tickets (visitor_id, visitor_name, visitor_email, status)
VALUES (?, ?, ?, "waiting")
');
$stmt->execute([$visitorId, $name, $email]);
$ticketId = $pdo->lastInsertId();
// Первое сообщение
$stmt = $pdo->prepare('
INSERT INTO support_messages (ticket_id, sender_type, sender_id, content)
VALUES (?, "visitor", ?, ?)
');
$stmt->execute([$ticketId, $visitorId, $message]);
// Находим свободного оператора
$operatorId = findAvailableOperator($pdo);
if ($operatorId) {
// Назначаем оператора
$stmt = $pdo->prepare('UPDATE support_tickets SET operator_id = ?, status = "active" WHERE id = ?');
$stmt->execute([$operatorId, $ticketId]);
incrementOperatorTickets($pdo, $operatorId);
// Уведомляем оператора
$pushler->trigger(
"private-operator-{$operatorId}",
'ticket:assigned',
[
'ticket_id' => $ticketId,
'visitor_name' => $name,
'message' => $message
]
);
}
// Уведомляем всех операторов о новом тикете в очереди
$pushler->trigger(
'private-support-queue',
'ticket:new',
[
'ticket_id' => $ticketId,
'visitor_name' => $name,
'status' => $operatorId ? 'active' : 'waiting',
'operator_id' => $operatorId
]
);
// Устанавливаем cookie
setcookie('visitor_id', $visitorId, time() + 86400 * 365, '/');
echo json_encode([
'ticket_id' => $ticketId,
'visitor_id' => $visitorId,
'status' => $operatorId ? 'active' : 'waiting'
]);
function findAvailableOperator(PDO $pdo): ?int
{
$stmt = $pdo->query('
SELECT user_id FROM support_operators_online
WHERE active_tickets < max_tickets
AND last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE)
ORDER BY active_tickets ASC
LIMIT 1
');
return $stmt->fetchColumn() ?: null;
}
Отправка сообщения
PHP
<?php
// send_support_message.php
require_once 'vendor/autoload.php';
use PushlerRu\PushlerClient;
$pushler = new PushlerClient('key_ваш_ключ', 'secret_ваш_секрет');
$ticketId = (int) $_POST['ticket_id'];
$content = trim($_POST['content']);
$socketId = $_POST['socket_id'] ?? null;
// Определяем отправителя
$operatorId = getCurrentUserId(); // null для посетителей
$visitorId = $_COOKIE['visitor_id'] ?? null;
$pdo = getDbConnection();
// Проверяем доступ к тикету
$stmt = $pdo->prepare('SELECT * FROM support_tickets WHERE id = ?');
$stmt->execute([$ticketId]);
$ticket = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$ticket) {
http_response_code(404);
exit;
}
// Проверяем права
if ($operatorId) {
$senderType = 'operator';
$senderId = $operatorId;
$senderName = getUserById($operatorId)['name'];
} elseif ($visitorId === $ticket['visitor_id']) {
$senderType = 'visitor';
$senderId = $visitorId;
$senderName = $ticket['visitor_name'];
} else {
http_response_code(403);
exit;
}
// Сохраняем сообщение
$stmt = $pdo->prepare('
INSERT INTO support_messages (ticket_id, sender_type, sender_id, content)
VALUES (?, ?, ?, ?)
');
$stmt->execute([$ticketId, $senderType, $senderId, $content]);
$messageId = $pdo->lastInsertId();
$messageData = [
'id' => $messageId,
'ticket_id' => $ticketId,
'sender_type' => $senderType,
'sender_name' => $senderName,
'content' => $content,
'created_at' => date('c')
];
// Отправляем в канал тикета (обоим участникам)
$pushler->trigger(
"private-ticket-{$ticketId}",
'message:new',
['message' => $messageData],
$socketId // Исключаем отправителя
);
echo json_encode(['message' => $messageData]);
Авторизация каналов
PHP
<?php
// auth.php
require_once 'vendor/autoload.php';
use PushlerRu\PushlerClient;
$pushler = new PushlerClient('key_ваш_ключ', 'secret_ваш_секрет');
$channelName = $_POST['channel_name'];
$socketId = $_POST['socket_id'];
$operatorId = getCurrentUserId();
$visitorId = $_COOKIE['visitor_id'] ?? null;
// Канал тикета — для посетителя и оператора
if (preg_match('/^private-ticket-(\d+)$/', $channelName, $matches)) {
$ticketId = (int) $matches[1];
$pdo = getDbConnection();
$stmt = $pdo->prepare('SELECT * FROM support_tickets WHERE id = ?');
$stmt->execute([$ticketId]);
$ticket = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$ticket) {
http_response_code(404);
exit;
}
// Посетитель или назначенный оператор
$isVisitor = $visitorId === $ticket['visitor_id'];
$isOperator = $operatorId && $operatorId == $ticket['operator_id'];
if (!$isVisitor && !$isOperator) {
http_response_code(403);
exit;
}
echo json_encode($pushler->authorizeChannel($channelName, $socketId));
exit;
}
// Канал оператора
if (preg_match('/^private-operator-(\d+)$/', $channelName, $matches)) {
if (!$operatorId || $operatorId != (int) $matches[1]) {
http_response_code(403);
exit;
}
echo json_encode($pushler->authorizeChannel($channelName, $socketId));
exit;
}
// Очередь поддержки — только операторы
if ($channelName === 'private-support-queue') {
if (!$operatorId || !isOperator($operatorId)) {
http_response_code(403);
exit;
}
echo json_encode($pushler->authorizeChannel($channelName, $socketId));
}
🌐 Виджет для посетителя
JavaScript
class SupportWidget {
constructor(appKey) {
this.appKey = appKey;
this.ticketId = null;
this.visitorId = null;
this.pushler = null;
this.channel = null;
this.isOpen = false;
}
init() {
this.createWidget();
this.bindEvents();
}
createWidget() {
const widget = document.createElement('div');
widget.id = 'support-widget';
widget.innerHTML = `
<button id="support-toggle" class="support-btn">💬</button>
<div id="support-chat" class="support-chat hidden">
<div class="chat-header">
<span>Поддержка</span>
<button id="chat-close">×</button>
</div>
<div id="chat-messages" class="chat-messages"></div>
<div id="chat-typing" class="typing-indicator hidden">
Оператор печатает...
</div>
<form id="chat-form" class="chat-form">
<input type="text" id="chat-input" placeholder="Сообщение..." />
<button type="submit">→</button>
</form>
<div id="start-form" class="start-form">
<input type="text" id="visitor-name" placeholder="Ваше имя" required />
<textarea id="first-message" placeholder="Чем можем помочь?" required></textarea>
<button type="button" id="start-chat">Начать чат</button>
</div>
</div>
`;
document.body.appendChild(widget);
}
bindEvents() {
document.getElementById('support-toggle').onclick = () => this.toggle();
document.getElementById('chat-close').onclick = () => this.close();
document.getElementById('start-chat').onclick = () => this.startChat();
document.getElementById('chat-form').onsubmit = (e) => {
e.preventDefault();
this.sendMessage();
};
document.getElementById('chat-input').oninput = () => this.handleTyping();
}
toggle() {
this.isOpen = !this.isOpen;
document.getElementById('support-chat').classList.toggle('hidden', !this.isOpen);
}
close() {
this.isOpen = false;
document.getElementById('support-chat').classList.add('hidden');
}
async startChat() {
const name = document.getElementById('visitor-name').value;
const message = document.getElementById('first-message').value;
const response = await fetch('/create_ticket.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `name=${encodeURIComponent(name)}&message=${encodeURIComponent(message)}`
});
const data = await response.json();
this.ticketId = data.ticket_id;
this.visitorId = data.visitor_id;
// Скрываем форму, показываем чат
document.getElementById('start-form').classList.add('hidden');
document.getElementById('chat-form').classList.remove('hidden');
// Добавляем первое сообщение
this.addMessage({
sender_type: 'visitor',
sender_name: name,
content: message,
created_at: new Date().toISOString()
});
// Подключаемся к Pushler
this.connect();
if (data.status === 'waiting') {
this.addSystemMessage('Ожидайте, оператор скоро подключится...');
}
}
connect() {
this.pushler = new PushlerClient({
appKey: this.appKey,
authEndpoint: '/auth.php'
});
this.pushler.on('connected', () => {
this.channel = this.pushler.subscribe(`private-ticket-${this.ticketId}`);
this.channel.on('message:new', (data) => {
this.addMessage(data.message);
this.hideTyping();
});
this.channel.on('operator:typing', () => {
this.showTyping();
});
this.channel.on('ticket:closed', () => {
this.addSystemMessage('Чат завершён. Спасибо за обращение!');
document.getElementById('chat-form').classList.add('hidden');
});
});
}
async sendMessage() {
const input = document.getElementById('chat-input');
const content = input.value.trim();
if (!content) return;
input.value = '';
await fetch('/send_support_message.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `ticket_id=${this.ticketId}&content=${encodeURIComponent(content)}&socket_id=${this.pushler.socketId}`
});
// Оптимистично добавляем
this.addMessage({
sender_type: 'visitor',
content: content,
created_at: new Date().toISOString()
});
}
addMessage(msg) {
const container = document.getElementById('chat-messages');
const div = document.createElement('div');
div.className = `message ${msg.sender_type}`;
div.innerHTML = `
<div class="content">${this.escapeHtml(msg.content)}</div>
<div class="time">${this.formatTime(msg.created_at)}</div>
`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
addSystemMessage(text) {
const container = document.getElementById('chat-messages');
const div = document.createElement('div');
div.className = 'message system';
div.textContent = text;
container.appendChild(div);
}
showTyping() {
document.getElementById('chat-typing').classList.remove('hidden');
clearTimeout(this.typingTimeout);
this.typingTimeout = setTimeout(() => this.hideTyping(), 3000);
}
hideTyping() {
document.getElementById('chat-typing').classList.add('hidden');
}
handleTyping() {
// Throttle
const now = Date.now();
if (now - (this.lastTypingSent || 0) < 2000) return;
this.lastTypingSent = now;
fetch('/typing.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `ticket_id=${this.ticketId}&sender_type=visitor`
});
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
formatTime(date) {
return new Date(date).toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit'
});
}
}
// Инициализация
const support = new SupportWidget('key_ваш_ключ');
support.init();
🎧 Панель оператора
JavaScript
class OperatorPanel {
constructor(operatorId, appKey) {
this.operatorId = operatorId;
this.appKey = appKey;
this.activeTickets = new Map();
this.currentTicketId = null;
}
async connect() {
this.pushler = new PushlerClient({
appKey: this.appKey,
authEndpoint: '/auth.php'
});
this.pushler.on('connected', () => {
// Канал оператора
const operatorChannel = this.pushler.subscribe(`private-operator-${this.operatorId}`);
operatorChannel.on('ticket:assigned', (data) => {
this.addTicket(data);
this.playNotification();
});
// Очередь поддержки
const queueChannel = this.pushler.subscribe('private-support-queue');
queueChannel.on('ticket:new', (data) => {
this.updateQueue(data);
});
});
}
subscribeToTicket(ticketId) {
const channel = this.pushler.subscribe(`private-ticket-${ticketId}`);
channel.on('message:new', (data) => {
this.handleNewMessage(ticketId, data.message);
});
channel.on('visitor:typing', () => {
this.showVisitorTyping(ticketId);
});
return channel;
}
async openTicket(ticketId) {
this.currentTicketId = ticketId;
// Загружаем историю
const response = await fetch(`/get_messages.php?ticket_id=${ticketId}`);
const data = await response.json();
this.renderMessages(data.messages);
// Подписываемся если ещё не подписаны
if (!this.activeTickets.has(ticketId)) {
const channel = this.subscribeToTicket(ticketId);
this.activeTickets.set(ticketId, { channel });
}
}
async sendMessage(content) {
await fetch('/send_support_message.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `ticket_id=${this.currentTicketId}&content=${encodeURIComponent(content)}&socket_id=${this.pushler.socketId}`
});
}
async closeTicket(ticketId) {
await fetch('/close_ticket.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `ticket_id=${ticketId}`
});
this.activeTickets.get(ticketId)?.channel?.unsubscribe();
this.activeTickets.delete(ticketId);
}
}
const operator = new OperatorPanel(currentOperatorId, 'key_ваш_ключ');
operator.connect();
⚠️ Типичные ошибки
1. Посетитель теряет чат при перезагрузке
JavaScript
// ❌ Неправильно — ticket_id только в памяти
this.ticketId = response.ticket_id;
// ✅ Правильно — сохраняем в localStorage
localStorage.setItem('support_ticket_id', response.ticket_id);
// При загрузке страницы восстанавливаем
const savedTicket = localStorage.getItem('support_ticket_id');
if (savedTicket) {
this.resumeChat(savedTicket);
}
2. Оператор не видит новые сообщения в свёрнутом тикете
JavaScript
// ❌ Неправильно — подписка только при открытии
openTicket(id) {
this.subscribeToTicket(id); // Пропустим сообщения до открытия!
}
// ✅ Правильно — подписка сразу при назначении
operatorChannel.on('ticket:assigned', (data) => {
this.subscribeToTicket(data.ticket_id); // Сразу слушаем
this.addTicketToList(data);
});
Готовы попробовать?
Создайте бесплатный аккаунт и начните интеграцию за пару минут