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-8answerCallbackQueryза ~30 секунд, даже при ошибках- HTTP 200 и
"ok":trueдо того, как считать вызов успешным - HTML-экранирование пользовательского текста при
parse_mode=HTML - Секрет вебхука проверен; дубликаты отфильтрованы по
update_id
Клавиатура при отправке, callback при приёме, ответ плюс edit, короткий payload — весь цикл.
Нужно больше, чем сниппет: клавиатуры, Mini Apps, платежи? Начните с botservice.biz.