Разработка · 10 мин чтения

Бот электронной очереди в Telegram: запись, номер и уведомление о вызове

Бот электронной очереди telegram заменяет терминал с талонами и табло: клиент пишет боту, получает номер, уходит по делам, а когда подходит его черёд, ему приходит сообщение. Для салона, сервисного центра, поликлиники или выдачи заказов этого хватает, а железа покупать не нужно. Ниже разбираю, как я собираю такие боты: структура данных, выдача номера без дублей, вызов оператором и уведомления, а также выбор между PHP-вебхуком на обычном хостинге и ботом на aiogram.

Как устроена электронная очередь в Telegram

В очереди три действия, и вокруг них строится весь бот. Клиент встаёт в очередь, получает номер и видит, сколько человек перед ним. Оператор нажимает «Следующий», и система выбирает самый ранний талон со статусом «ожидает». Клиенту уходит сообщение «Номер 17, подойдите к окну 2». Всё остальное (отмена, несколько услуг, статистика) надстраивается поверх этих трёх шагов.

У Telegram есть два способа получать события от пользователей: long polling и вебхук. При polling процесс бота постоянно крутится и сам опрашивает Telegram. При вебхуке Telegram сам делает POST на ваш URL, когда приходит сообщение. Для очереди оба подходят, и от выбора зависит, где бот будет жить. Об этом отдельный раздел ниже.

Клиентский сценарий у меня обычно такой:

  • команда /start показывает кнопки с услугами (например, «Консультация», «Выдача заказа»);
  • после выбора услуги бот выдаёт номер и пишет, сколько человек впереди;
  • кнопка «Моя позиция» пересчитывает очередь на текущий момент;
  • кнопка «Отменить» снимает талон, и оператор его уже не увидит;
  • когда остаётся один человек перед клиентом, приходит предупреждение «Вы следующий»;
  • при вызове приходит сообщение с номером окна.

Операторский сценарий проще. Либо оператор работает в том же боте (его Telegram ID внесён в список сотрудников и ему доступны команды /next и /done), либо у него маленькая веб-страница с одной кнопкой. Первый вариант я предлагаю чаще: не надо делать авторизацию, оператор уже авторизован в Telegram.

Запись в очередь и выдача номера

Таблицы в MySQL

Для самой очереди хватает двух таблиц: справочник услуг и список сотрудников с окнами лежат отдельно. В первой лежат талоны, во второй счётчик номеров по услуге и дню. Нумерация начинается с единицы каждое утро, поэтому дата входит в ключ.

CREATE TABLE queue_tickets (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  chat_id BIGINT NOT NULL,
  service_id INT NOT NULL,
  ticket_date DATE NOT NULL,
  ticket_no INT NOT NULL,
  status ENUM('waiting','called','done','cancelled') NOT NULL DEFAULT 'waiting',
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uq_day_no (service_id, ticket_date, ticket_no),
  KEY idx_wait (service_id, status, ticket_no),
  KEY idx_client (chat_id, ticket_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE queue_counters (
  service_id INT NOT NULL,
  ticket_date DATE NOT NULL,
  last_no INT NOT NULL,
  PRIMARY KEY (service_id, ticket_date)
) ENGINE=InnoDB;

Уникальный индекс uq_day_no страхует от дублей: даже если в коде появится ошибка, две одинаковые «семёрки» за один день база не примет. Отдельный Postgres для этого не нужен, MySQL с InnoDB справляется.

Номер без дублей при одновременных записях

Самая частая ошибка новичков: взять MAX(ticket_no) + 1 и вставить. Два клиента нажали кнопку в одну секунду, оба прочитали 16, оба получили 17. Я выдаю номер через счётчик с атомарным инкрементом: INSERT ... ON DUPLICATE KEY UPDATE last_no = LAST_INSERT_ID(last_no + 1). Строка счётчика блокируется до конца транзакции, и каждый вызов получает своё значение.

Там же проверяется, не стоит ли клиент уже в очереди. Без этой проверки человек нажмёт кнопку трижды и займёт три места, а оператор потом будет вызывать пустоту.

Бесплатный материал

🎁 Полезный скрипт в подарок

Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.

Без спама. Отписка в 1 клик.

Вебхук на PHP: рабочий вариант для обычного хостинга

Когда у клиента уже есть сайт на Тильде или WordPress, у него оплачен хостинг с PHP и MySQL. Бот на вебхуке работает на нём без отдельного сервера: Telegram присылает POST на PHP-файл, файл отвечает через Bot API. Ничего не нужно держать запущенным, процесс живёт только на время запроса. Нужен только HTTPS-адрес, а он на хостинге обычно уже есть.

Вебхук регистрируется одним запросом. Параметр secret_token нужен, чтобы отсекать чужие POST: Telegram будет класть его значение в заголовок X-Telegram-Bot-Api-Secret-Token.

curl -X POST "https://api.telegram.org/bot<TOKEN>/setWebhook" \
  -d "url=https://example.ru/queue/webhook.php" \
  -d "secret_token=my_long_random_string"

Ниже функции выдачи номера и вызова следующего. Подключение к базе ($pdo) и константа BOT_TOKEN лежат в отдельном конфиге.

<?php
function tg(string $method, array $data): ?array {
    $ch = curl_init("https://api.telegram.org/bot" . BOT_TOKEN . "/" . $method);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query($data),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
    ]);
    $res = curl_exec($ch);
    curl_close($ch);
    return is_string($res) ? json_decode($res, true) : null;
}

function take_ticket(PDO $pdo, int $chatId, int $serviceId): array {
    $pdo->beginTransaction();
    $stmt = $pdo->prepare("SELECT ticket_no FROM queue_tickets
        WHERE chat_id = ? AND service_id = ? AND ticket_date = CURDATE()
        AND status IN ('waiting','called') LIMIT 1 FOR UPDATE");
    $stmt->execute([$chatId, $serviceId]);
    $existing = $stmt->fetchColumn();
    if ($existing !== false) {
        $pdo->commit();
        return ['no' => (int)$existing, 'new' => false];
    }
    $pdo->prepare("INSERT INTO queue_counters (service_id, ticket_date, last_no)
        VALUES (?, CURDATE(), LAST_INSERT_ID(1))
        ON DUPLICATE KEY UPDATE last_no = LAST_INSERT_ID(last_no + 1)")
        ->execute([$serviceId]);
    $no = (int)$pdo->query("SELECT LAST_INSERT_ID()")->fetchColumn();
    $pdo->prepare("INSERT INTO queue_tickets (chat_id, service_id, ticket_date, ticket_no)
        VALUES (?, ?, CURDATE(), ?)")
        ->execute([$chatId, $serviceId, $no]);
    $pdo->commit();
    return ['no' => $no, 'new' => true];
}

function call_next(PDO $pdo, int $serviceId): ?array {
    $pdo->beginTransaction();
    $stmt = $pdo->prepare("SELECT id, chat_id, ticket_no FROM queue_tickets
        WHERE service_id = ? AND ticket_date = CURDATE() AND status = 'waiting'
        ORDER BY ticket_no LIMIT 1 FOR UPDATE");
    $stmt->execute([$serviceId]);
    $t = $stmt->fetch(PDO::FETCH_ASSOC);
    if (!$t) {
        $pdo->commit();
        return null;
    }
    $pdo->prepare("UPDATE queue_tickets SET status = 'called' WHERE id = ?")
        ->execute([$t['id']]);
    $pdo->commit();
    return $t;
}

В точке входа webhook.php сначала сверяется секрет из заголовка ($_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN']), потом читается тело через json_decode(file_get_contents('php://input'), true). Если пришло сообщение, идентификатор чата лежит в message.chat.id, текст в message.text. Нажатия на inline-кнопки приходят отдельным полем callback_query, и на них нужно ответить методом answerCallbackQuery, иначе у пользователя на кнопке будет крутиться индикатор загрузки.

Один нюанс с датой: CURDATE() берёт время сервера MySQL. Если хостинг в другом часовом поясе, чем ваш филиал, «новый день» начнётся не в полночь по месту. Я проверяю это в первый день и при необходимости задаю часовой пояс сессии командой SET time_zone при подключении.

Уведомление о вызове и предупреждение «вы следующий»

Вызов оператором это три строки: call_next() возвращает талон, а tg('sendMessage', ...) отправляет клиенту текст с номером и окном. Номер окна берётся из профиля оператора: в таблице сотрудников у каждого Telegram ID привязан к окну и набору услуг, которые он обслуживает.

Для предупреждения «вы следующий» после каждого вызова я смотрю на ближайший талон со статусом «ожидает» и отправляю ему короткое сообщение. Если сделать только вызов, клиенты начинают спрашивать у стойки, не пропустили ли их, и смысл очереди теряется. Предупреждение за одного человека обычно хватает, а за два-три включаю, когда от точки до места ожидания идти дольше.

Что ещё стоит заложить сразу:

  • Тайм-аут неявки. Если клиент не подошёл, оператор нажимает «Пропустить», талон получает статус «cancelled» и бот пишет, что можно встать заново.
  • Повторный вызов. Кнопка «Напомнить» отправляет то же сообщение ещё раз, статус не меняется.
  • Закрытие дня. Остатки в статусе «ожидает» помечаются отменёнными, утром счётчик начинается с единицы сам, потому что ключ включает дату.
  • Заблокированный бот. Если клиент остановил бота, Bot API вернёт ответ с ok: false и описанием ошибки. Ответ tg() нужно проверять и показывать оператору «клиент недоступен», а не вызывать человека повторно в пустоту.

Если записей много и очередь нужна по нескольким точкам, в таблицы добавляется поле branch_id, и всё остальное остаётся как есть.

PHP-вебхук или aiogram: что выбрать под очередь

Бот на aiogram удобнее, когда логика становится многошаговой: диалоги с состояниями, запись на конкретное время, напоминания по расписанию, интеграция с несколькими внешними API. У aiogram есть конечные автоматы состояний и фоновые задачи, а в PHP-вебхуке всё это пришлось бы писать вручную. Но aiogram-бот это постоянно работающий процесс. На обычном хостинге его не запустить, нужен отдельный VPS: его настраивают, обновляют, следят за перезапуском процесса и платят каждый месяц. Пока это не нужно, я так не делаю.

Критерий PHP-вебхук на хостинге aiogram на отдельном сервере
Где работает на хостинге сайта, отдельный сервер не нужен на VPS, процесс запущен постоянно
Обслуживание почти нет, как у обычного сайта обновления, мониторинг, перезапуск, ежемесячная оплата сервера
Сложные диалоги возможны, но состояния пишутся вручную в базе штатные состояния и фильтры в самой библиотеке
Напоминания по времени через cron хостинга, минимальный шаг зависит от тарифа фоновые задачи внутри процесса
Подходит для одной-двух услуг, простая очередь очередь плюс запись по слотам, оплата, CRM

Стоимость сервера и домена от очереди не зависит, это отдельная строка бюджета: цена домена определяется зоной, а VPS тарифом хостера. Я не включаю их в стоимость разработки и считаю отдельно.

На практике очередь без записи по слотам почти всегда закрывается PHP-вебхуком. Если же заказчику нужны ещё платная запись, выгрузка в CRM и автоматические напоминания, переходим на aiogram с сервером, и это обсуждается с самого начала. Часть таких связок (например, передачу сделки в CRM) можно вынести в сценарий n8n, но у него тоже свой сервер или облако, о чём я предупреждаю заранее.

Стоимость моей работы над Telegram-ботом начинается от 30 000 ₽, итог зависит от числа услуг, окон и интеграций. Если нужно собрать очередь под вашу точку, можно заказать Telegram-бота для электронной очереди с учётом вашего хостинга.

Ошибки, на которых спотыкаются чаще всего

Первая ошибка касается идентификации клиента. У бота есть chat_id и имя из апдейта: в объекте User поле first_name обязательное, обычно доступны ещё last_name и username. Нет только телефона, его клиент передаёт сам. Для очереди имени хватает, а вот в поликлинике оператору нужен ещё и номер, чтобы связаться с клиентом. Тогда я добавляю кнопку «Поделиться контактом» (у Telegram есть для этого специальная кнопка запроса контакта) и сохраняю номер. Хранить его стоит на своём хостинге в РФ, а не в таблицах иностранных сервисов, потому что это персональные данные.

Вторая: отсутствие ограничений на нажатия. Клиент дважды тапнул «Встать в очередь» с задержкой связи, и Telegram прислал два одинаковых события. Проверка существующего талона в транзакции, как в take_ticket(), превращает дубль в безобидное повторное сообщение с тем же номером.

Третья: бот отвечает слишком долго. Telegram ждёт ответа на вебхук ограниченное время и при ошибках повторяет доставку, из-за чего одно событие может прийти дважды. Тяжёлые действия (выгрузки, отчёты) нужно выносить из обработчика, а сам обработчик должен быстро отвечать кодом 200. Для защиты от повторов я сохраняю update_id последнего обработанного обновления и игнорирую меньшие или равные значения.

Четвёртая: нет резервного канала для оператора. Если интернет на точке пропал, очередь встаёт. На этот случай оператору нужна возможность вызвать клиента вручную, а потом отметить его в боте. Я добавляю команду, которая вызывает конкретный номер, а не только следующий.

Пятая: путают номер и позицию. Номер 17 при вызове идёт на ресепшен, а позиция «перед вами 4 человека» меняется каждую минуту. Если показывать клиенту только номер, он не поймёт, ждать ли ему час или пять минут. Поэтому «Моя позиция» считает количество талонов со статусом «ожидает» и меньшим номером, и показывает это число рядом с номером.

Условия и возможности Telegram Bot API меняются, поэтому перед запуском я сверяюсь с актуальной документацией Telegram.

Частые вопросы

Нужен ли отдельный сервер для бота электронной очереди?

Нет, если бот работает через вебхук на PHP. Он запускается на том же хостинге, где стоит сайт, и процесс не держится постоянно. Отдельный сервер нужен для бота на aiogram, потому что это постоянно запущенный процесс, или когда к очереди добавляются фоновые задачи.

Как клиент узнаёт, что его вызывают?

Оператор нажимает «Следующий», система находит самый ранний талон в статусе «ожидает» и отправляет клиенту сообщение через метод sendMessage. В тексте указан номер и окно. Дополнительно можно слать предупреждение, когда перед клиентом остаётся один человек.

Можно ли вести очередь на несколько услуг и окон?

Да. В обеих таблицах есть поле service_id, номера считаются отдельно по каждой услуге и дню. Оператор привязан к окну и набору услуг, поэтому /next выбирает талон только из его услуг.

Что делать, если клиент не пришёл на вызов?

Оператор нажимает «Пропустить», талон переходит в статус «cancelled», а клиенту бот предлагает встать в очередь заново. Можно сначала отправить повторное напоминание и только потом пропускать, это настраивается под регламент точки.

Есть задача?

Обсудим в мессенджере

Расскажите, что нужно сделать — отвечу в течение 4 часов в рабочее время. Первая консультация бесплатно.

Продолжая пользование настоящим сайтом Вы выражаете своё согласие на обработку Ваших персональных данных (файлов куки) с использованием Yandex.Metrika.
Понятно