Обработка Telegram InlineKeyboardMarkup на PHP: callback_query, answerCallbackQuery и ограничение полезной нагрузки 64 байта

InlineKeyboardMarkup на PHP

В этом уроке собираем небольшой PHP-обработчик для inline-клавиатур Telegram. Вы:

  • соберёте кнопки, у которых callback_data укладывается в лимит Bot API — 64 байта;
  • примете обновление с callback_query;
  • вызовете answerCallbackQuery, чтобы убрать спиннер на кнопке;
  • обновите сообщение через editMessageReplyMarkup;
  • используете тонкую обёртку cURL с проверкой HTTP-статуса и флага ok.

Это не полноценный фреймворк бота: без DI, очереди и secret token вебхука. Цель — рабочий путь для staging, который легко расширить.

1. Клиент Bot API

Проверяйте и HTTP-код, и поле ok в JSON. Telegram часто отвечает HTTP 200 с телом {"ok":false,...}.

<?php
// telegram.php

function tgApi(string $method, array $params, ?string $token = null): array
{
$token = $token ?? getenv('TELEGRAM_BOT_TOKEN');
if (!$token) {
throw new RuntimeException('TELEGRAM_BOT_TOKEN is not set');
}

$url = 'https://api.telegram.org/bot' . $token . '/' . $method;
$body = http_build_query($params, '', '&');

$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);

$raw = curl_exec($ch);
if ($raw === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException('cURL error: ' . $err);
}

$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($status !== 200) {
throw new RuntimeException('HTTP ' . $status . ' from ' . $method);
}

$decoded = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException('JSON decode failed: ' . json_last_error_msg());
}

if (!is_array($decoded) || !($decoded['ok'] ?? false)) {
$desc = $decoded['description'] ?? 'unknown error';
$code = $decoded['error_code'] ?? 0;
throw new RuntimeException('Telegram ok:false (' . $code . '): ' . $desc);
}

return $decoded['result'];
}

Токен берите из окружения. Не хардкодьте его и не возвращайте в текстах ошибок.

2. Сборка InlineKeyboardMarkup

InlineKeyboardMarkup — JSON-массив рядов кнопок. У кнопки есть text и одно поле действия: callback_data, url, web_app или switch_inline_query_*. Здесь используем callback_data — он порождает callback_query на вебхуке.

Жёсткий лимит: callback_data не больше 64 байт UTF-8 (байты, не символы). JSON-объекты почти всегда превышают лимит — получите BUTTON_DATA_INVALID.

Отправляйте короткий непрозрачный токен и резолвите его на сервере. Ниже l:42 / d:42 укладываются в бюджет с запасом.

<?php
// keyboards.php

require_once __DIR__ . '/telegram.php';

function likeDislikeKeyboard(int $postId, ?int $currentVote = null): array
{
// "l:1234567890" -> максимум ~12 байт
$likePayload = 'l:' . $postId;
$dislikePayload = 'd:' . $postId;

$likeText = $currentVote === 1 ? 'Нравится' : 'Лайк';
$dislikeText = $currentVote === -1 ? 'Не нравится' : 'Дизлайк';

return [
'inline_keyboard' => [
[
['text' => $likeText, 'callback_data' => $likePayload],
['text' => $dislikeText, 'callback_data' => $dislikePayload],
],
],
];
}

function sendPostWithKeyboard(int $chatId, string $text, int $postId): array
{
return tgApi('sendMessage', [
'chat_id' => $chatId,
'text' => $text,
'reply_markup' => json_encode(likeDislikeKeyboard($postId)),
]);
}

Для одноразовых подтверждений удобнее bin2hex(random_bytes(7)) (14 hex-символов): сначала INSERT маппинга, потом отправка клавиатуры.

3. Вебхук: приём callback_query

Telegram шлёт JSON по HTTPS. Типичный callback_query:

{
"update_id": 123456789,
"callback_query": {
"id": "...",
"from": {"id": 111, "is_bot": false, "first_name": "..."},
"chat_instance": "...",
"message": {
"message_id": 17,
"chat": {"id": 111, "type": "private"},
"date": 1700000000,
"text": "..."
},
"data": "l:42"
}
}

Всегда вызывайте answerCallbackQuery с id запроса, затем редактируйте или отправляйте новое сообщение. Без ответа кнопка крутит спиннер до таймаута клиента.

<?php
// webhook.php

declare(strict_types=1);

require_once __DIR__ . '/telegram.php';
require_once __DIR__ . '/keyboards.php';

header('Content-Type: application/json');

$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
http_response_code(400);
echo json_encode(['error' => 'empty body']);
return;
}

$update = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($update)) {
http_response_code(400);
echo json_encode(['error' => 'bad json']);
return;
}

try {
if (isset($update['callback_query'])) {
handleCallback($update['callback_query']);
} elseif (isset($update['message']['text']) && $update['message']['text'] === '/start') {
sendPostWithKeyboard(
(int) $update['message']['chat']['id'],
'Демо-пост — нажмите кнопки.',
42
);
}
} catch (Throwable $e) {
error_log('[telegram] ' . $e->getMessage());
}

http_response_code(200);
echo '{"ok":true}';

function handleCallback(array $cq): void
{
$queryId = (string) $cq['id'];
$data = (string) ($cq['data'] ?? '');
$message = $cq['message'] ?? null;
$chatId = (int) ($message['chat']['id'] ?? 0);
$messageId = (int) ($message['message_id'] ?? 0);

if (!preg_match('/^([ld]):(\d{1,10})$/', $data, $m)) {
tgApi('answerCallbackQuery', [
'callback_query_id' => $queryId,
'text' => 'Неизвестное действие.',
'show_alert' => true,
]);
return;
}

$vote = $m[1] === 'l' ? 1 : -1;
$postId = (int) $m[2];
$current = recordVote($chatId, $postId, $vote);

tgApi('answerCallbackQuery', [
'callback_query_id' => $queryId,
'text' => 'Сохранено.',
]);

tgApi('editMessageReplyMarkup', [
'chat_id' => $chatId,
'message_id' => $messageId,
'reply_markup' => json_encode(likeDislikeKeyboard($postId, $current)),
]);
}

function recordVote(int $chatId, int $postId, int $vote): int
{
static $store = [];
$store[$chatId][$postId] = ($store[$chatId][$postId] ?? 0) === $vote ? 0 : $vote;
return $store[$chatId][$postId];
}

Три важных момента:

  • Отвечайте на callback даже если последующее редактирование упало. Telegram ждёт ответ примерно 30 секунд.
  • editMessageText / editMessageReplyMarkup могут вернуть «message is not modified» при том же содержимом — это no-op, не повод для ретрая.
  • При parse_mode=HTML экранируйте пользовательский ввод через htmlspecialchars до вставки в HTML.

4. Заметки для продакшена

  • Секрет вебхука. Задайте secret_token в setWebhook и проверяйте заголовок X-Telegram-Bot-Api-Secret-Token.
  • Идемпотентность. Храните обработанные update_id и отбрасывайте дубликаты.
  • Лимиты. На 429 соблюдайте parameters.retry_after. При высокой нагрузке ставьте исходящие вызовы в очередь по chat id.
  • Дизайн callback_data. Держитесь ниже 64 байт. Лучше короткий токен, чем длинная строка.
  • Диплинки. t.me/Bot?start=payload срабатывает только на /start. Чтобы открыть приватный чат с payload из клавиатуры, используйте URL-кнопку.

5. Чеклист

  • callback_data ≤ 64 байт UTF-8
  • answerCallbackQuery за ~30 секунд, даже при ошибках
  • HTTP 200 и "ok":true до того, как считать вызов успешным
  • HTML-экранирование пользовательского текста при parse_mode=HTML
  • Секрет вебхука проверен; дубликаты отфильтрованы по update_id

Клавиатура при отправке, callback при приёме, ответ плюс edit, короткий payload — весь цикл.

Нужно больше, чем сниппет: клавиатуры, Mini Apps, платежи? Начните с botservice.biz.

Новые статьи — в Telegram

Разбираем, что автоматизировать в бизнесе и как это работает на практике. Без спама.