Для примера возьмём личный чат с тестовым ботом. Он отправит кнопку «Проверить статус заявки», а после нажатия заменит текст сообщения на «Тестовая заявка: принята в работу» и уберёт кнопку. Статус фиксированный: пример показывает обработку нажатия, а не подключение к CRM или базе заявок.
Проверьте путь callback_query до обработчика
Нажмите кнопку один раз и сопоставьте время нажатия с журналом входящих обновлений. Ищите update_id, callback_query.id и наличие callback_query.data. На время диагностики достаточно записывать эти идентификаторы и тип события; весь профиль пользователя, токен и тело запроса в журнал не нужны.
Упрощённое тестовое обновление выглядит так. Все идентификаторы вымышлены: для ответа Telegram нужен ID текущего, реально полученного нажатия.
{
"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
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";
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
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 не требуется.
Проверьте пользователя и обновите исходное сообщение
Сохраните callback.php рядом с HTTP-помощником. Функция получает callback_query, ID владельца тестового чата и функцию вызова API. Сначала проверяется право на действие, затем отправляется ответ на нажатие и изменяется сообщение.
<?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
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
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";
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: setWebhook и allowed_updates