Возможности Как это работает Тарифы Документация Cookbook Партнёрам Личный кабинет
Бизнес-кейсы
🎧

Виджет поддержки

12 мин чтения Средне

Чат с оператором на сайте. Панель оператора с очередью обращений, передача чатов.

Support PHP JavaScript

📋 Что получится

  • Виджет чата на сайте
  • Панель оператора с очередью обращений
  • Индикатор "оператор печатает..."
  • Передача чата другому оператору
  • История переписки
Любой бэкенд: Примеры на 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);
});

Готовы попробовать?

Создайте бесплатный аккаунт и начните интеграцию за пару минут