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

Розбираємо, що автоматизувати в бізнесі та як це працює на практиці. Без спаму.