sendMessage в Telegram на PHP: cURL, 3 уровня проверок и защита parse_mode HTML

Отправка сообщений через метод sendMessage — самая частая операция при работе с Telegram Bot API. При этом на практике разработчики часто ограничиваются вызовом file_get_contents без проверки HTTP-кода и валидации JSON. В продакшене такой подход приводит к молчаливым падениям, «зависшим» скриптам и поломке разметки из-за незаэкранированных символов.

В этом руководстве мы разберем, как построить надежную отправку сообщений на чистом PHP с использованием cURL, реализовать трехуровневую обработку ошибок и исключить сбои парсинга parse_mode=HTML.

Почему file_get_contents вредит продакшену

Использование file_get_contents("https://api.telegram.org/bot...") — классическая антипрактика в PHP по следующим причинам:

  • Отсутствие контроля таймаутов: По умолчанию PHP будет ждать ответа до истечения default_socket_timeout (обычно 60 секунд). Если у Telegram или магистрального провайдера возникнет сетевой затор, ваши рабочие процессы PHP-FPM быстро заблокируют весь пул сокетов.
  • Сложность работы с HTTP-ошибками: Если Telegram вернет статус 400 Bad Request или 429 Too Many Requests, file_get_contents выбросит PHP Warning и вернет false, потеряв тело ответа, в котором Telegram подробнее объясняет причину ошибки (массив parameters с полем retry_after).
  • Отсутствие гибкой настройки SSL: Вы не можете явно настроить тайм-ауты соединения и рукопожатия TLS.

Решение — использование расширения cURL с явной установкой CURLOPT_CONNECTTIMEOUT и CURLOPT_TIMEOUT.

Трехуровневая валидация ответа Telegram API

Успешный сетевой запрос не означает, что сообщение доставлено. При обработке ответа Telegram нужно последовательно пройти три этапа проверки:

  1. Транспортный уровень (cURL): Проверка curl_errno(). Ошибки DNS, таймаут соединения (error code 28), проблемы со сбросом SSL-сессии.
  2. Уровень HTTP-протокола: Проверка кода ответа через CURLINFO_HTTP_CODE. Telegram возвращает 200 OK при успехе, но при ошибках шлет 400 (неверные параметры), 401 (неверный токен), 403 (бот заблокирован пользователем), 409 (конфликт вебхуков) или 429 (превышение лимитов).
  3. Уровень приложения (JSON Payload): Валидация структуры ответа через json_last_error() и проверку поля $response['ok'] === true.

Ниже представлен базовый транспортный модуль, выполняющий этот алгоритм:

<?php

declare(strict_types=1);

function executeTelegramApi(string $method, array $payload): array
{
$token = getenv('TELEGRAM_BOT_TOKEN');
if (!$token) {
throw new RuntimeException('TELEGRAM_BOT_TOKEN is not set in environment');
}

$url = "https://api.telegram.org/bot{$token}/{$method}";
$ch = curl_init();

curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
]);

$rawResponse = curl_exec($ch);
$curlErrno = curl_errno($ch);
$curlError = curl_error($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// 1. Проверка cURL ошибки
if ($curlErrno !== 0) {
return [
'ok' => false,
'error_type' => 'transport',
'description' => "cURL error ({$curlErrno}): {$curlError}",
];
}

// 2. Декодирование JSON и проверка json_last_error
$responseData = json_decode((string)$rawResponse, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return [
'ok' => false,
'error_type' => 'json',
'description' => 'JSON decode error: ' . json_last_error_msg(),
'raw_body' => $rawResponse,
];
}

// 3. Проверка флага ok от Telegram API и HTTP статуса
if ($httpCode !== 200 || empty($responseData['ok'])) {
return [
'ok' => false,
'error_type' => 'api',
'http_code' => $httpCode,
'error_code' => $responseData['error_code'] ?? $httpCode,
'description' => $responseData['description'] ?? 'Unknown Telegram Error',
'parameters' => $responseData['parameters'] ?? [],
];
}

return [
'ok' => true,
'result' => $responseData['result'],
];
}

Экранирование спецсимволов при parse_mode=HTML

При отправке форматированных сообщений с опцией 'parse_mode' => 'HTML' Telegram ожидает валидный HTML-подмножество тегов (<b>, <i>, <a>, <code>, <pre>). Если динамические данные (имя пользователя, название товара, текст комментария) содержат символы <, >, & или ", Telegram вернет ошибку 400 Bad Request: can't parse entities и сообщение не отправится.

Для защиты текста необходимо пропускать ВСЕ внешние динамические переменные через htmlspecialchars() со специальным набором флагов:

<?php

declare(strict_types=1);

/**
* Безопасное экранирование текста для Telegram HTML parse_mode
*/
function telegramEscape(string $text): string
{
return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}

/**
* Пример формирования сообщения с динамическими данными
*/
function buildOrderMessage(int $orderId, string $clientName, string $userComment): string
{
$safeName = telegramEscape($clientName);
$safeComment = telegramEscape($userComment);

return "<b>Новый заказ №{$orderId}</b>
" .
"Клиент: <i>{$safeName}</i>
" .
"Комментарий: <code>{$safeComment}</code>";
}

Интеграция: отправка сообщения с обработкой лимитов 429

Сберем всё вместе: создадим сценарий отправки сервисного уведомления, учитывающий корректную работу с parse_mode, проверкой статуса ответа и анализом задержки retry_after при превышении лимитов Telegram API.

<?php

declare(strict_types=1);

$chatId = (int)getenv('TELEGRAM_NOTIFY_CHAT_ID');
$rawUserInput = 'ООО "Вектор & Ко" <info@vector.test>'; // Данные с опасными символами

$messageText = buildOrderMessage(10854, $rawUserInput, 'Просьба перезвонить до 18:00');

$response = executeTelegramApi('sendMessage', [
'chat_id' => $chatId,
'text' => $messageText,
'parse_mode' => 'HTML',
'disable_web_page_preview' => true,
]);

if (!$response['ok']) {
// Обработка ошибок
if ($response['error_type'] === 'api') {
// Ошибка 429: Too Many Requests
if ($response['http_code'] === 429 && isset($response['parameters']['retry_after'])) {
$waitSeconds = (int)$response['parameters']['retry_after'];
error_log("Telegram Rate Limit Hit. Need to wait {$waitSeconds} seconds.");
// Здесь можно отправить задачу повторно в очередь (RabbitMQ / Redis / Yii Queue)
} else {
error_log(sprintf(
"Telegram API Error [%d]: %s",
$response['error_code'],
$response['description']
));
}
} else {
error_log("Network/JSON Error: " . $response['description']);
}
} else {
$sentMessageId = $response['result']['message_id'];
echo "Сообщение успешно отправлено! ID: {$sentMessageId}
";
}

Частые ошибки в продакшене

  • Забытый Content-Type: Если при передаче json_encode не указать заголовок Content-Type: application/json, cURL отправит данные как multipart/form-data или application/x-www-form-urlencoded, что вызовет непредсказуемое поведение парсера Telegram.
  • Отсутствие опции ENT_QUOTES: Обычный htmlspecialchars($str) без флагов не экранирует одиночные кавычки, что при определенной разметке может ломать ссылки <a href="...">.
  • Игнорирование error_code 403: Если пользователь заблокировал бота, API вернет 403 Forbidden: bot was blocked by the user. Вы должны перехватывать этот статус и помечать пользователя в своей БД как неактивного, чтобы не тратить квоту запросов.

Если вам нужна помощь в проектировании отказоустойчивой архитектуры ботов или выстраивании сложных Telegram-интеграций, обратитесь к специалистам BotCreator.

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

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