php

Telegram-бот на PHP: webhook, inline-кнопки и работа с Bot API

Telegram-бот на чистом PHP можно собрать за час: получить токен у @BotFather, поднять HTTPS-эндпоинт и научить его понимать команды и нажатия inline-кнопок. В этом руководстве — рабочий каркас, который вы поставите на свой сервер и расширите под любую задачу: уведомления, опросы, FAQ, заявки.

Получение токена и регистрация бота

Откройте в Telegram чат с @BotFather, отправьте /newbot, придумайте имя и username (заканчивается на bot). В ответ придёт строка вида 123456789:AAH...XYZ — это HTTP API token, через который бот ходит к серверам Telegram. Храните его как пароль: вынесите в .env или константу вне репозитория.

<?php
// config.php — НЕ коммитить в git
define('BOT_TOKEN', '123456789:AAH...XYZ');
define('BOT_API', 'https://api.telegram.org/bot' . BOT_TOKEN . '/');

// Секрет для проверки, что webhook действительно от Telegram
define('WEBHOOK_SECRET', 'k2Lp9xQz7vR4nM8wJ3sT');

Все запросы к Bot API — это обычные HTTP-вызовы вида https://api.telegram.org/bot<TOKEN>/methodName. Параметры передаются GET, POST или JSON-телом. Ответ всегда JSON с полем ok.

Схема обработки сообщений ботом через webhook
Жизненный цикл сообщения: от пользователя до ответа

Webhook против long polling

У Bot API два способа получать обновления:

  • long polling — ваш скрипт сам дёргает getUpdates в цикле. Подходит для локальной разработки и серверов без публичного HTTPS.
  • webhook — Telegram POSTит JSON на ваш URL при каждом событии. Это рабочий вариант для продакшена: меньше задержка, не нужен фоновой процесс, скрипт стартует только когда есть что обработать.

Webhook требует HTTPS с валидным сертификатом (Let’s Encrypt подходит) и публичный URL. Зарегистрировать webhook — один разовый вызов:

<?php
// set_webhook.php — запустить один раз вручную
require __DIR__ . '/config.php';

$params = [
    'url'          => 'https://example.com/bot.php',
    'secret_token' => WEBHOOK_SECRET,        // Telegram пришлёт его в заголовке
    'allowed_updates' => json_encode(['message', 'callback_query']),
    'drop_pending_updates' => true,           // сбросить накопившиеся апдейты
];

$response = file_get_contents(BOT_API . 'setWebhook?' . http_build_query($params));
echo $response;
// Ожидаем: {"ok":true,"result":true,"description":"Webhook was set"}

Проверить, что webhook повешен корректно, можно через getWebhookInfo — ответ покажет URL, дату последней ошибки и количество ожидающих апдейтов.


Скелет webhook-обработчика

Файл bot.php — единая точка входа. Telegram POSTит сюда JSON, мы его парсим и решаем, что делать. Главное правило — отвечать как можно быстрее (под 60 секунд, а лучше под 5), иначе Telegram повторит доставку и пользователь получит дубль.

<?php
require __DIR__ . '/config.php';

// 1. Проверяем секрет — Telegram кладёт его в заголовок
$header = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? '';
if (!hash_equals(WEBHOOK_SECRET, $header)) {
    http_response_code(403);
    exit;
}

// 2. Читаем тело запроса
$input = file_get_contents('php://input');
$update = json_decode($input, true);

if (!$update) {
    http_response_code(400);
    exit;
}

// 3. Логируем для отладки (на проде уровень DEBUG отключайте)
file_put_contents(__DIR__ . '/bot.log', date('c') . ' ' . $input . PHP_EOL, FILE_APPEND);

// 4. Маршрутизация
if (isset($update['message'])) {
    handleMessage($update['message']);
} elseif (isset($update['callback_query'])) {
    handleCallback($update['callback_query']);
}

// Telegram достаточно HTTP 200
http_response_code(200);

Главный приём для скорости — закрывать соединение перед тяжёлой работой через fastcgi_finish_request(). Telegram получает 200 и считает доставку успешной, а ваш скрипт продолжает выполняться:

<?php
http_response_code(200);
header('Content-Length: 0');
if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();   // ответ ушёл, скрипт работает дальше
}

// Тут уже можно обращаться к медленному API, рассылать и т.д.
processHeavyTask($update);

Команды и текстовые сообщения

Сообщение приходит в поле message.text. Команды (/start, /help) начинаются со слэша. Удобно вытащить команду регуляркой и хранить обработчики в массиве:

<?php
function handleMessage(array $msg): void {
    $chatId = $msg['chat']['id'];
    $text   = trim($msg['text'] ?? '');

    // Команда вида /cmd@MyBot или /cmd arg
    if (preg_match('~^/(\w+)(?:@\S+)?(?:\s+(.+))?$~u', $text, $m)) {
        $cmd  = strtolower($m[1]);
        $args = $m[2] ?? '';
        runCommand($cmd, $args, $chatId);
        return;
    }

    // Не команда — отправим эхо или прогоним через свой парсер
    sendMessage($chatId, 'Не понял. Наберите /help');
}

function runCommand(string $cmd, string $args, int $chatId): void {
    $handlers = [
        'start' => fn() => sendMessage($chatId, 'Привет! Я бот karoche.ru. Жми /menu'),
        'help'  => fn() => sendMessage($chatId, "Доступно:\n/menu\n/about"),
        'menu'  => fn() => sendMenu($chatId),
        'about' => fn() => sendMessage($chatId, 'PHP-бот, открытый код'),
    ];

    if (isset($handlers[$cmd])) {
        $handlers[$cmd]();
    } else {
        sendMessage($chatId, 'Неизвестная команда: /' . $cmd);
    }
}

Для отправки текста — метод sendMessage. Поддерживается простое HTML-форматирование: <b>, <i>, <code>, <a>. Не забывайте экранировать пользовательский ввод функцией htmlspecialchars() — иначе бот словит ошибку парсинга на сообщении со знаком <.

<?php
function apiCall(string $method, array $params = []): array {
    $ch = curl_init(BOT_API . $method);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => http_build_query($params),
        CURLOPT_TIMEOUT        => 10,
    ]);
    $body = curl_exec($ch);
    curl_close($ch);
    return json_decode($body, true) ?? ['ok' => false];
}

function sendMessage(int $chatId, string $text, array $extra = []): array {
    return apiCall('sendMessage', array_merge([
        'chat_id'    => $chatId,
        'text'       => $text,
        'parse_mode' => 'HTML',
        'disable_web_page_preview' => true,
    ], $extra));
}

Inline-кнопки и callback_query

Inline-клавиатура — это кнопки, прикреплённые к конкретному сообщению. У каждой кнопки есть callback_data (до 64 байт) — короткий маркер, который придёт обратно при нажатии в виде callback_query.

<?php
function sendMenu(int $chatId): void {
    $keyboard = [
        'inline_keyboard' => [
            // Первый ряд
            [
                ['text' => 'Статьи', 'callback_data' => 'cat:articles'],
                ['text' => 'Поиск',  'callback_data' => 'cat:search'],
            ],
            // Второй ряд
            [
                ['text' => 'Контакты', 'callback_data' => 'cat:contacts'],
            ],
            // Третий — внешняя ссылка
            [
                ['text' => 'Открыть karoche.ru', 'url' => 'https://karoche.ru'],
            ],
        ],
    ];

    sendMessage($chatId, '<b>Главное меню</b>', [
        'reply_markup' => json_encode($keyboard),
    ]);
}

Когда пользователь нажмёт кнопку, прилетит callback_query. Его нужно обработать и обязательно ответить через answerCallbackQuery — иначе кнопка будет крутить «часики» 30 секунд.

<?php
function handleCallback(array $cb): void {
    $chatId    = $cb['message']['chat']['id'];
    $messageId = $cb['message']['message_id'];
    $data      = $cb['data'];
    $callbackId = $cb['id'];

    // Сразу гасим "часики" на кнопке
    apiCall('answerCallbackQuery', [
        'callback_query_id' => $callbackId,
        'text' => 'Загружаю...', // короткий тост, опционально
    ]);

    // Парсим callback_data — формат "action:param"
    [$action, $param] = explode(':', $data, 2) + [null, null];

    if ($action === 'cat') {
        $titles = [
            'articles' => 'Свежие статьи: PHP, JS, Bitrix',
            'search'   => 'Введите запрос текстом',
            'contacts' => 'serzhmir79@gmail.com',
        ];
        $text = $titles[$param] ?? 'Раздел не найден';

        // Редактируем то же сообщение, не плодим новые
        apiCall('editMessageText', [
            'chat_id'    => $chatId,
            'message_id' => $messageId,
            'text'       => $text,
            'parse_mode' => 'HTML',
            'reply_markup' => json_encode([
                'inline_keyboard' => [[
                    ['text' => '« Назад', 'callback_data' => 'menu:main'],
                ]],
            ]),
        ]);
    }

    if ($action === 'menu' && $param === 'main') {
        // Возврат к главному меню — редактируем текущее сообщение
        apiCall('editMessageText', [
            'chat_id'    => $chatId,
            'message_id' => $messageId,
            'text'       => '<b>Главное меню</b>',
            'parse_mode' => 'HTML',
            'reply_markup' => json_encode(buildMainMenu()),
        ]);
    }
}

Принципы работы с callback_data:

  • Лимит 64 байта — длинные параметры не лезут. Храните состояние в БД и передавайте только короткий ID.
  • Делайте префикс действия (cat:, order:, page:) — это упрощает маршрутизацию.
  • Не доверяйте данным: пользователь может прислать что угодно через свой клиент. Валидируйте.
  • Для пагинации формат list:page:5 работает без БД — текущий номер хранится в самой кнопке.

Файлы, фото и клавиатура внизу экрана

Кроме inline-кнопок есть reply-клавиатура — заменяет системную и появляется внизу экрана. Удобна для постоянного меню, голосования по числам, выбора шага в форме:

<?php
$replyKb = [
    'keyboard' => [
        [['text' => '/menu'], ['text' => '/help']],
        [['text' => 'Поделиться номером', 'request_contact' => true]],
        [['text' => 'Прислать локацию',   'request_location' => true]],
    ],
    'resize_keyboard' => true,    // подгонка по высоте
    'one_time_keyboard' => false, // не прятать после нажатия
];

sendMessage($chatId, 'Выберите действие:', [
    'reply_markup' => json_encode($replyKb),
]);

// Чтобы убрать клавиатуру:
// 'reply_markup' => json_encode(['remove_keyboard' => true])

Отправка фото — отдельный метод sendPhoto. Можно слать URL картинки, file_id (если уже грузили в Telegram) или multipart-файл с диска через CURLFile:

<?php
function sendPhoto(int $chatId, string $path, string $caption = ''): array {
    $ch = curl_init(BOT_API . 'sendPhoto');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => [
            'chat_id' => $chatId,
            'photo'   => new CURLFile($path),
            'caption' => $caption,
            'parse_mode' => 'HTML',
        ],
    ]);
    $body = curl_exec($ch);
    curl_close($ch);
    return json_decode($body, true);
}

Безопасность и эксплуатация

Чек-лист продакшена:

  1. HTTPS обязателен. Self-signed сертификаты Telegram больше не принимает — берите Let’s Encrypt.
  2. Проверяйте secret_token через hash_equals, чтобы исключить таймтак-атаки. Без него на ваш URL может прийти мусор от ботов.
  3. Не логируйте токен и тело сообщений с приватными данными. Логи имеют свойство утекать.
  4. Ограничивайте rate limit на стороне бота: Telegram держит лимит ~30 сообщений в секунду на бота и 1 в секунду в один чат. Используйте очередь (Redis, Beanstalkd).
  5. Сохраняйте update_id и игнорируйте дубли — Telegram повторяет доставку, если не получил 200.
  6. Owner-команды вроде /broadcast закрывайте проверкой chat.id по списку администраторов.
  7. Хостинг — подойдёт любой PHP-FPM с HTTPS. На shared-хостинге может быть ограничен fastcgi_finish_request, проверяйте.

Локальная разработка без белого IP решается через ngrok или cloudflared tunnel: получаете публичный HTTPS-URL, вешаете на него webhook, отлаживаете в IDE.


Итог: чеклист рабочего бота

  • Получили токен у @BotFather и положили в config.php вне корня сайта.
  • Настроили HTTPS-домен и поставили webhook через setWebhook с secret_token.
  • Парсим message и callback_query, отдаём 200 быстро.
  • Команды роутятся через массив обработчиков, ответы экранированы для HTML parse_mode.
  • Inline-кнопки используют короткий callback_data с префиксом действия.
  • Тяжёлую работу делаем после fastcgi_finish_request или в очереди.
  • Логи пишем, дубли по update_id отбрасываем, rate limit учитываем.

Этот каркас — основа, на которой собираются и FAQ-ответчики, и боты заявок с интеграцией в CRM, и рассыльщики уведомлений из 1С-Битрикс. Дальше остаётся подключить БД (MySQL через PDO либо Redis для состояний диалога), добавить FSM для пошаговых форм и логи в Sentry — и у вас полноценный продакшен-бот на голом PHP, без фреймворков и зависимостей.