Бот электронной очереди 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», а клиенту бот предлагает встать в очередь заново. Можно сначала отправить повторное напоминание и только потом пропускать, это настраивается под регламент точки.