Перейти к содержанию
14 минут чтения
Тема: Telegram Bot API PHP · Кнопки · Webhook

Кнопки Telegram-бота не работают: callback_query и ответ на нажатие

Бот отправил сообщение с кнопкой, но после нажатия ничего не происходит или долго крутится индикатор. Проверим, доходит ли событие до приложения, получает ли Telegram ответ на нажатие и выполняется ли нужное действие. Затем соберём небольшой пример на PHP.

Для примера возьмём личный чат с тестовым ботом. Он отправит кнопку «Проверить статус заявки», а после нажатия заменит текст сообщения на «Тестовая заявка: принята в работу» и уберёт кнопку. Статус фиксированный: пример показывает обработку нажатия, а не подключение к CRM или базе заявок.

Сначала определите тип кнопки

У похожих элементов интерфейса разные события. Поэтому до проверки webhook посмотрите, что именно отправлено в reply_markup.

  • Обычная текстовая кнопка ReplyKeyboardMarkup под полем ввода отправляет сообщение. Её текст ищут в message.text.

  • Кнопка InlineKeyboardMarkup под сообщением с callback_data присылает callback_query. Её значение находится в callback_query.data.

  • Кнопка под сообщением с url открывает ссылку. Обработчик callback_query для неё не вызывается.

Дальше рассматриваем именно inline-кнопку с callback_data. Для каждого такого элемента задайте одно действие; не указывайте одновременно url и callback_data. Значение callback_data занимает от 1 до 64 байт, поэтому длинный JSON и пользовательские тексты лучше заменить коротким ключом.

Форматы кнопок описаны в InlineKeyboardButton. Если интерфейс ещё проектируется, начните с понятного действия, которое пользователь должен выполнить.

Проверьте путь callback_query до обработчика

Нажмите кнопку один раз и сопоставьте время нажатия с журналом входящих обновлений. Ищите update_id, callback_query.id и наличие callback_query.data. На время диагностики достаточно записывать эти идентификаторы и тип события; весь профиль пользователя, токен и тело запроса в журнал не нужны.

Упрощённое тестовое обновление выглядит так. Все идентификаторы вымышлены: для ответа Telegram нужен ID текущего, реально полученного нажатия.

JSON · пример входящего обновления
{
  "update_id": 10001,
  "callback_query": {
    "id": "438200000000000001",
    "from": {"id": 123456789, "is_bot": false, "first_name": "Тест"},
    "message": {
      "message_id": 42,
      "date": 1791378000,
      "chat": {"id": 123456789, "type": "private"},
      "text": "Тестовая заявка"
    },
    "chat_instance": "900000000000000001",
    "data": "status:demo"
  }
}
  • Нет входящего HTTP-запроса — проверьте адрес webhook, HTTPS и ошибки доставки.

  • Запрос пришёл, но callback_query пропущен — проверьте маршрутизацию событий. Ветка, читающая только message.text, не обработает нажатие.

  • Обработчик вызван, но действие не найдено — сравните callback_query.data с ожидаемым значением. Видимый текст кнопки может отличаться от callback_data.

Если обновление не приходит вообще, используйте руководство по диагностике webhook. Не удаляйте webhook и не очищайте ожидающие обновления ради проверки кнопки.

Для прямой работы с Telegram проверьте getWebhookInfo: url должен указывать на ваш обработчик. Если allowed_updates задан явно, в нём нужен callback_query. Отсутствие параметра при повторном setWebhook сохраняет предыдущую настройку, поэтому случайно забытый фильтр не исправляется сам. Изменение фильтра относится к новым обновлениям.

После создания telegram.php из примера ниже можно выполнить следующую проверку. Она не меняет webhook и не отправляет сообщений. Проверьте last_error_message и pending_update_count; отсутствие ошибки доставки ещё не доказывает, что приложение правильно обрабатывает кнопку.

PHP · check-webhook.php
<?php
declare(strict_types=1);
require __DIR__.'/telegram.php';
$response = telegramRequest(requiredEnv('TELEGRAM_BOT_TOKEN'), 'getWebhookInfo');
echo json_encode($response['result'] ?? null,
    JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), "\n";
Bash · проверка webhook
php check-webhook.php

Ответьте на нажатие через answerCallbackQuery

В обработке есть три разных результата: сервер принял webhook, бот ответил на нажатие, приложение выполнило действие. Они не заменяют друг друга. HTTP 200 завершает доставку обновления. answerCallbackQuery завершает ожидание в клиенте Telegram. sendMessage или editMessageText показывают результат действия.

Для answerCallbackQuery передавайте callback_query.id в параметре callback_query_id. Это строка; не подставляйте update_id, message_id, chat_id или callback_data. Для обычного подтверждения остальные параметры не нужны. Если нужно объяснить отказ, используйте text; show_alert=true показывает предупреждение.

Telegram требует ответить на callback даже без уведомления пользователю: см. CallbackQuery. Отправка нового сообщения сама по себе этого требования не выполняет.

Проверьте результат вызова: успешный ответ API содержит ok=true и result=true. Не считайте один факт выполнения HTTP-запроса успехом. Если ответ неуспешный, прочитайте error_code и description и переходите к разделу об ошибках ниже.

Не откладывайте ответ на нажатие до завершения долгого запроса в CRM. Быстро проверьте допустимость действия и подтвердите нажатие; результат длительной работы отправьте отдельным сообщением или обновлением исходного текста.

Подготовьте тестовый бот и HTTP-помощник

Нужны 64-битный PHP 8.2 или новее, расширение cURL, исходящий HTTPS-доступ к api.telegram.org и публичный HTTPS-адрес обработчика. Composer для прямого примера не требуется. Пользователь сначала должен открыть личный чат с ботом и отправить /start.

Передайте процессам CLI и веб-сервера переменные TELEGRAM_BOT_TOKEN, TELEGRAM_TEST_USER_ID, TELEGRAM_WEBHOOK_SECRET и TELEGRAM_WEBHOOK_URL. TELEGRAM_TEST_USER_ID — числовой ID вашего тестового аккаунта из message.from.id входящего обновления, а не @username. В его личном чате этот ID также используется как chat_id. TELEGRAM_WEBHOOK_URL — полный HTTPS-адрес webhook.php.

TELEGRAM_WEBHOOK_SECRET задайте отдельной случайной строкой: от 1 до 256 символов A–Z, a–z, 0–9, _ или -. Не используйте токен бота вместо секрета. Обычный PHP не загружает .env автоматически; если приложение использует dotenv, выполните его загрузку перед чтением переменных.

Сохраните telegram.php и callback.php вне публичного каталога либо закройте прямой доступ к ним. Примеры используют одну папку для краткости: адаптируйте require к расположению файлов. Публичным должен быть только входящий webhook.php. Скрипты send-button.php и setup-webhook.php запускаются из CLI.

PHP · telegram.php
<?php
declare(strict_types=1);

final class TelegramApiError extends RuntimeException
{
    public function __construct(int $code, public readonly string $description)
    {
        parent::__construct('Telegram отклонил запрос.', $code);
    }
}

function requiredEnv(string $name): string
{
    $value = getenv($name);
    if ($value === false || $value === '') {
        throw new RuntimeException('Не задана переменная '.$name);
    }
    return $value;
}

function testUserId(): int
{
    $id = filter_var(requiredEnv('TELEGRAM_TEST_USER_ID'), FILTER_VALIDATE_INT,
        ['options' => ['min_range' => 1]]);
    if ($id === false) {
        throw new RuntimeException('Нужен числовой ID тестового пользователя.');
    }
    return $id;
}

/** @param array<string, mixed> $params
 *  @return array<string, mixed>
 */
function telegramRequest(string $token, string $method, array $params = []): array
{
    $body = json_encode($params === [] ? new stdClass() : $params,
        JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
    $curl = curl_init('https://api.telegram.org/bot'.$token.'/'.$method);
    if ($curl === false) {
        throw new RuntimeException('Не удалось создать HTTP-запрос.');
    }
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
        CURLOPT_POSTFIELDS => $body,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 2,
        CURLOPT_TIMEOUT => 4,
    ]);
    $raw = curl_exec($curl);
    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    if (!is_string($raw)) {
        throw new RuntimeException('Сбой соединения: результат запроса неизвестен.');
    }
    try {
        $decoded = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
    } catch (JsonException) {
        throw new RuntimeException('API вернул некорректный JSON.');
    }
    if (!is_array($decoded) || array_is_list($decoded)) {
        throw new RuntimeException('Получен неожиданный ответ API.');
    }
    if (($decoded['ok'] ?? null) === false
        && is_int($decoded['error_code'] ?? null)
        && is_string($decoded['description'] ?? null)) {
        throw new TelegramApiError($decoded['error_code'], $decoded['description']);
    }
    if ($status < 200 || $status >= 300 || ($decoded['ok'] ?? null) !== true) {
        throw new RuntimeException('Не подтверждён успешный ответ API.');
    }
    /** @var array<string, mixed> $decoded */
    return $decoded;
}

HTTP-помощник проверяет ответ Telegram и не делает автоматических повторов. Таймаут оставляет результат запроса неизвестным: повторный sendMessage может создать ещё одно сообщение. Короткие таймауты здесь рассчитаны на учебный сценарий; подбирайте их для своей среды и общего времени обработки webhook. В PHP 8.2–8.4 cURL-дескриптор освобождается при выходе из функции; в PHP 8.5 закрывать его через устаревший curl_close не требуется.

Отправьте кнопку в собственный тестовый чат

Этот скрипт действительно отправляет одно сообщение. Используйте только свой тестовый аккаунт. Убедитесь, что TELEGRAM_TEST_USER_ID задан в том же CLI-окружении, где запускается PHP.

PHP · send-button.php
<?php
declare(strict_types=1);
require __DIR__.'/telegram.php';

telegramRequest(requiredEnv('TELEGRAM_BOT_TOKEN'), 'sendMessage', [
    'chat_id' => testUserId(),
    'text' => 'Тестовая заявка. Нажмите кнопку, чтобы увидеть статус.',
    'reply_markup' => [
        'inline_keyboard' => [[[
            'text' => 'Проверить статус заявки',
            'callback_data' => 'status:demo',
        ]]],
    ],
]);
echo "Сообщение с кнопкой отправлено.\n";
Bash · отправка тестового сообщения
php send-button.php

Ожидаемый результат — сообщение с кнопкой под текстом. В callback_data лежит короткое действие status:demo. Это не команда оболочки и не доверенный ID заявки: приложение само решает, что разрешено выполнить по полученным данным.

Если сообщение не отправилось из-за chat not found, сначала проверьте chat_id и доступ бота. Обработку кнопки проверяют после успешной отправки сообщения.

Проверьте пользователя и обновите исходное сообщение

Сохраните callback.php рядом с HTTP-помощником. Функция получает callback_query, ID владельца тестового чата и функцию вызова API. Сначала проверяется право на действие, затем отправляется ответ на нажатие и изменяется сообщение.

PHP · callback.php
<?php
declare(strict_types=1);

/** @param array<string, mixed> $query
 *  @param callable(string, array<string, mixed>): array<string, mixed> $api
 */
function handleStatusCallback(array $query, int $ownerId, callable $api): void
{
    $id = $query['id'] ?? null;
    if (!is_string($id) || $id === '') {
        throw new InvalidArgumentException('Нет ID нажатия.');
    }
    $from = $query['from'] ?? null;
    $message = $query['message'] ?? null;
    $chat = is_array($message) ? ($message['chat'] ?? null) : null;
    if (!is_array($from) || ($from['id'] ?? null) !== $ownerId
        || !is_array($chat) || ($chat['type'] ?? null) !== 'private'
        || ($chat['id'] ?? null) !== $ownerId) {
        $api('answerCallbackQuery', [
            'callback_query_id' => $id,
            'text' => 'Эта кнопка доступна только в вашем тестовом чате.',
            'show_alert' => true,
        ]);
        return;
    }
    if (($query['data'] ?? null) !== 'status:demo'
        || !is_int($message['message_id'] ?? null)
        || !is_int($message['date'] ?? null) || $message['date'] === 0) {
        $api('answerCallbackQuery', [
            'callback_query_id' => $id,
            'text' => 'Кнопка устарела. Откройте новое сообщение.',
        ]);
        return;
    }

    $api('answerCallbackQuery', ['callback_query_id' => $id]);
    try {
        $api('editMessageText', [
            'chat_id' => $chat['id'],
            'message_id' => $message['message_id'],
            'text' => 'Тестовая заявка: принята в работу.',
            'reply_markup' => ['inline_keyboard' => []],
        ]);
    } catch (TelegramApiError $error) {
        if ($error->getCode() !== 400
            || !str_contains(strtolower($error->description), 'message is not modified')) {
            throw $error;
        }
        // Текст и клавиатура уже имеют нужное состояние.
    }
}

Пример работает только в личном чате заданного тестового пользователя и не раскрывает данные настоящей заявки. В рабочем приложении по callback_query.from.id проверяйте доступ пользователя к конкретной записи, её состояние и допустимость операции. Наличие ID в callback_data не даёт права просматривать или изменять заявку.

Для обычного сообщения chat_id берётся из callback_query.message.chat.id, а message_id — из callback_query.message.message_id. chat_instance для этого не подходит. События из inline-режима используют inline_message_id; данный пример их не обрабатывает. Значение date=0 у недоступного сообщения тоже не подходит для этого сценария.

Пустой inline_keyboard убирает кнопку после успешного изменения текста. Это удобный интерфейс для однократной проверки, но не защита от повторного выполнения действия: несколько нажатий могут успеть прийти до изменения сообщения.

Если повторное редактирование возвращает ошибку message is not modified, функция считает нужное состояние уже достигнутым. Проверка относится только к этой ошибке editMessageText; остальные ошибки не скрываются.

Подключите проверенный входящий запрос к функции

Следующий файл предназначен для прямого webhook Telegram. Он проверяет X-Telegram-Bot-Api-Secret-Token до разбора обновления. Не открывайте обработчик без проверки секрета: иначе посторонний запрос сможет имитировать нажатие.

PHP · webhook.php
<?php
declare(strict_types=1);
require __DIR__.'/telegram.php';
require __DIR__.'/callback.php';

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    http_response_code(405);
    exit;
}
try {
    $secret = requiredEnv('TELEGRAM_WEBHOOK_SECRET');
    $received = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? '';
    if (!is_string($received) || !hash_equals($secret, $received)) {
        http_response_code(403);
        exit;
    }
    $raw = file_get_contents('php://input');
    if ($raw === false) {
        throw new RuntimeException('Не удалось прочитать запрос.');
    }
    $update = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
    if (!is_array($update) || !is_int($update['update_id'] ?? null)) {
        http_response_code(400);
        exit;
    }
    $query = $update['callback_query'] ?? null;
    if (is_array($query)) {
        $token = requiredEnv('TELEGRAM_BOT_TOKEN');
        /** @var array<string, mixed> $query */
        handleStatusCallback($query, testUserId(),
            static fn (string $method, array $params): array =>
                telegramRequest($token, $method, $params));
    }
    // Успех только после обработки; прочие типы событий пример игнорирует.
    http_response_code(200);
    echo 'OK';
} catch (JsonException | InvalidArgumentException $error) {
    http_response_code(400);
} catch (Throwable $error) {
    // Не пишем токен, тело webhook или полный URL в журнал.
    error_log('Callback handler failed: '.get_class($error).' code='.$error->getCode());
    http_response_code(503);
}

Теперь настройте webhook для отдельного тестового бота. setup-webhook.php меняет адрес доставки и фильтр событий: не запускайте его для рабочего бота ради эксперимента. Если вашему приложению нужны другие типы обновлений, сохраните их в allowed_updates. Ожидающие обновления пример не удаляет.

PHP · setup-webhook.php
<?php
declare(strict_types=1);
require __DIR__.'/telegram.php';

$result = telegramRequest(requiredEnv('TELEGRAM_BOT_TOKEN'), 'setWebhook', [
    'url' => requiredEnv('TELEGRAM_WEBHOOK_URL'),
    'secret_token' => requiredEnv('TELEGRAM_WEBHOOK_SECRET'),
    'allowed_updates' => ['message', 'callback_query'],
]);
echo ($result['result'] ?? null) === true ? "Webhook настроен.\n" : "Проверьте ответ API.\n";
Bash · настройка тестового webhook
php setup-webhook.php

После настройки снова запустите check-webhook.php и проверьте адрес. Нажмите кнопку: индикатор должен завершиться, текст измениться, кнопка исчезнуть. В журнале запросов webhook ожидается HTTP 200.

Это синхронный учебный обработчик без БД и очереди. Он возвращает 200 после завершения операций, а при сбое API — 503. Ответ на нажатие и редактирование сообщения не являются транзакцией: часть действий могла успеть выполниться до ошибки. Повтор доставки не отменяет эту часть и может уже не позволить ответить на старый callback. Для рабочего приложения сохраняйте событие и управляйте его обработкой отдельно; не возвращайте успех до надёжного приёма события.

Защита действий от повторов разобрана в руководстве о дублях сообщений. Удаление кнопки не заменяет такую защиту.

Что проверить, если нажатие всё ещё не работает

  • Кнопка видна, входящего события нет. Проверьте её тип, webhook и allowed_updates. Нажмите заново после исправления фильтра.

  • Событие дошло, индикатор продолжает крутиться. Найдите вызов answerCallbackQuery и его ответ. Проверьте callback_query_id и токен того же бота, который отправил кнопку.

  • query is too old… или query ID is invalid. Не повторяйте сохранённый ID бесконечно. Проверьте задержку в обработчике и очереди, затем сделайте новое нажатие и ответьте на его ID. Здесь нет универсального числа секунд, на которое стоит опираться в логике приложения.

  • Индикатор исчез, текст не изменился. Подтверждение нажатия уже прошло; отдельно проверьте editMessageText, адресуемое сообщение и ответ API.

  • message is not modified. Сравните текущий текст и клавиатуру с отправляемыми. Если состояние уже нужное, повторное изменение не требуется.

  • Кнопка относится к старой или закрытой заявке. Проверьте запись и доступ, ответьте понятным уведомлением и предложите открыть актуальное сообщение. Если у пользователя больше нет права на запись, не показывайте её данные.

Сохраняйте в диагностике название метода, код ошибки и идентификаторы события. Описание ошибки используйте для разбора конкретной ситуации; не копируйте в публичные журналы токены, полный URL запроса и личные данные.

Если API вернул ограничение скорости, разберите ошибку 429 и retry_after. Долгое ожидание перед ответом на callback ухудшает сценарий: уменьшайте нагрузку и подтверждайте нажатие отдельно от тяжёлой работы.

Отделите быстрый ответ от длительной работы

Проверка статуса в примере мгновенная. Для формирования отчёта или вызова внешней CRM нужен другой порядок: проверьте действие и права, надёжно сохраните задание, ответьте на нажатие, затем обработайте задание в очереди. После выполнения обновите сообщение или отправьте результат.

Если не удалось сохранить задание, не сообщайте пользователю, что оно принято. Если задание сохранено, но answerCallbackQuery не прошёл, это отдельный сбой интерфейса; он не должен создавать ещё одно задание. Успешный ответ на нажатие также не доказывает, что worker выполнил работу.

Повтор доставки одного update и два отдельных нажатия — разные случаи. Для доставки используйте ключ с учётом бота и update_id; для бизнес-действия — идентификатор операции и ограничения её состояния. Например, уже подтверждённую заявку нельзя подтверждать заново только потому, что пришёл новый callback_query.id.

Особенности кнопок при подключении через BotGate

callback_query, callback_data и answerCallbackQuery сохраняют тот же смысл. BotGate передаёт вызовы Telegram API и доставляет обновления на клиентский Webhook URL; обработчик команд, доступ к заявкам и результат нажатия остаются в вашем приложении.

Для вызовов используйте PHP SDK и его метод $bot->call(): например, answerCallbackQuery с параметром callback_query_id из текущего события. PHP 8.2+ и установка composer require botgate/sdk описаны в руководстве по SDK. Проверяйте исключения вызова, а не только факт получения webhook.

Настройку клиента и получение обновлений смотрите в руководстве по BotGate PHP SDK.

Входящий запрос от BotGate проверяется по X-BotGate-Signature: это HMAC-SHA256 исходных байтов тела, подписанный webhook_secret бота. Заголовок X-Telegram-Bot-Api-Secret-Token из прямого примера не заменяет эту проверку. Поэтому webhook.php выше нельзя без изменений использовать как клиентский обработчик BotGate.

Алгоритм проверки подписи и заголовки приведены в документации webhook BotGate. Проверяйте подпись до обработки события и не пересобирайте JSON перед вычислением HMAC.

getUpdates через BotGate недоступен. Не переключайте рабочий webhook на прямой адрес ради диагностики. Проверьте доставку события в BotGate и приём на своём Webhook URL. Задержки доставки и очереди приложения особенно заметны для кнопок: обновление может прийти, когда ответ на исходное нажатие уже не принимается.

Проверьте весь сценарий на тестовых данных

  • Скрипт отправляет сообщение именно в ваш личный тестовый чат; бот уже получил /start.

  • Нажатие приходит как callback_query, а обработчик читает data и id из этого объекта.

  • answerCallbackQuery успешно завершается; клиент перестаёт показывать ожидание.

  • editMessageText меняет нужное сообщение и убирает кнопку.

  • Запрос с неправильным секретом отклоняется до вызова API.

  • Недопустимое действие или пользователь получают отказ без раскрытия данных заявки.

  • Сбой API не превращается в сообщение об успешной обработке; приложение различает принятый webhook и выполненное действие.

Документация методов и форматов

Telegram: InlineKeyboardButton и callback_data

Telegram: CallbackQuery

Telegram: answerCallbackQuery

Telegram: setWebhook и allowed_updates

Telegram: editMessageText

PHP: curl_exec и обработка результата HTTP-запроса

PHP: curl_close и изменение в PHP 8.5