Отказоустойчивая отправка sendMessage в Telegram на PHP: cURL, обработка ошибок API и безопасный HTML

Разработчики часто сталкиваются с ситуацией, когда отправка уведомлений через Telegram Bot API начинает «падать» или молча терять сообщения. Распространенная причина — упрощенная реализация через file_get_contents без проверки HTTP-кодов или неэкранированный текст в режиме parse_mode: HTML, который приводит к ошибке 400 Bad Request: can't parse entities.

В продакшн-окружении отправка сообщений должна быть надежным процессом. Для этого требуется полноценный cURL-клиент, трехуровневая валидация ответов сервера и строгое экранирование динамических данных.

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

Использование file_get_contents('https://api.telegram.org/bot...') создает три ключевые проблемы:

  • Отсутствие гибких таймаутов: Функция использует системный default_socket_timeout. Если Telegram или шлюз подвиснет, скрипт будет заблокирован на 60 секунд.
  • Сложность сбора HTTP-статусов: При ответах 400 Bad Request или 429 Too Many Requests функция возвращает false и генерирует PHP Warning, вместо того чтобы отдать тело ответа с детализацией ошибки.
  • Отсутствие точного контроля заголовков: Передача данных в формате application/x-www-form-urlencoded или application/json требует громоздкой настройки контекста через stream_context_create.

Использование cURL позволяет явно задать сетевые лимиты, получить точный HTTP-код ответа и прочитать JSON с ошибкой от Telegram.

Трехуровневая система проверки ответа Telegram API

Любой запрос к api.telegram.org должен проходить через три последовательных этапа проверки:

  1. Сетевой уровень (cURL): Проверка отсутствия ошибок сокета, таймаутов и проблемы с SSL-сертификатами (проверка curl_errno).
  2. Синтаксический уровень (JSON): Валидация структуры ответа с помощью json_decode и контроль json_last_error().
  3. Прикладной уровень (Telegram Bot API): Анализ поля ok в полученном массиве. Telegram может вернуть HTTP 200 или 400, но если ok === false, запрос считается необработанным.

Ниже представлен базовый функция-клиент, реализующий все три уровня проверок:

<?php

/**
* Отправка сообщения в Telegram с кастомной обработкой ошибок
*
* @param string $botToken Токен бота из getenv()
* @param int|string $chatId ID чата
* @param string $text Текст сообщения
* @param string $parseMode Режим разметки (HTML)
* @return array Массив result из ответа Telegram
* @throws RuntimeException При сетевых ошибках или некорректном ответе API
*/
function sendTelegramApiMessage(string $botToken, int|string $chatId, string $text, string $parseMode = 'HTML'): array
{
$url = "https://api.telegram.org/bot{$botToken}/sendMessage";

$payload = [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => $parseMode,
'disable_web_page_preview' => true,
];

$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 7,
CURLOPT_SSL_VERIFYPEER => true,
]);

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

// 1. Сетевая проверка
if ($curlErrno !== 0) {
throw new RuntimeException("cURL Network Error [{$curlErrno}]: {$curlError}");
}

// 2. Проверка парсера JSON
$data = json_decode((string)$response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException("JSON Parse Error: " . json_last_error_msg() . " | Raw response: " . substr((string)$response, 0, 200));
}

// 3. Проверка статуса Telegram API
if (!isset($data['ok']) || $data['ok'] !== true) {
$errorCode = $data['error_code'] ?? $httpCode;
$description = $data['description'] ?? 'Unknown Telegram API Error';

// Дополнительно проверяем наличие Retry-After при лимите 429
$retryAfter = $data['parameters']['retry_after'] ?? null;
$suffix = $retryAfter ? " (Retry-After: {$retryAfter}s)" : "";

throw new RuntimeException("Telegram API Error [{$errorCode}]: {$description}{$suffix}");
}

return $data['result'];
}

Таймауты: CURLOPT_CONNECTTIMEOUT и CURLOPT_TIMEOUT

При отправке уведомлений важно разграничивать два вида таймаутов:

  • CURLOPT_CONNECTTIMEOUT: Время ожидания упреждающего TCP/TLS соединения с серверами Telegram. Значения в 3–5 секунд более чем достаточно.
  • CURLOPT_TIMEOUT: Полное время выполнения запроса. Для синхронной отправки в HTTP-запросе веб-сайта ставьте 5–10 секунд, чтобы скрипт не зависал при проблемах на магистральных каналах.

Если вам необходимо рассылать сотни сообщений, не выполняйте их последовательно внутри одного HTTP-запроса пользователя. Сохраняйте уведомления в БД или очередь (Yii Queue, Laravel Queues) и обрабатывайте их фоновым демоном.

Безопасный parse_mode HTML и экранирование спецсимволов

Режим parse_mode: HTML позволяет форматировать текст с помощью ограниченного набора тегов: <b>, <i>, <code>, <pre>, <a href="...">. Однако любой неэкранированный символ <, > или & в пользовательских данных разрушит HTML-дерево, и Telegram вернет ошибку 400 Bad Request.

Для защиты шаблонов нельзя применять htmlspecialchars ко всему итоговому тексту сообщения — это превратит ваши теги <b> в &lt;b&gt;. Экранировать нужно исключительно динамические подстановки (имена, комментарии, данные из форм).

<?php

/**
* Безопасное формирование HTML-сообщения по шаблону
*
* @param string $template Шаблон с плейсхолдерами, например: "Привет, {name}!"
* @param array<string, scalar> $params Динамические данные для подстановки
* @return string Готовая к отправке HTML-строка
*/
function formatTelegramHtmlTemplate(string $template, array $params): string
{
$replacements = [];
foreach ($params as $key => $value) {
$safeValue = htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$replacements['{' . $key . '}'] = $safeValue;
}

return strtr($template, $replacements);
}

Разбор ошибок: ok:false и структура ответа при сбоях

При возникновении ошибки Telegram Bot API всегда возвращает JSON вида:

{
"ok": false,
"error_code": 400,
"description": "Bad Request: can't parse entities: Unexpected end tag at byte 45"
}

Наиболее распространенные коды ошибок:

  • 400 Bad Request: chat not found — Бот не может найти чат. Возникает, если указан несуществующий chat_id или пользователь не запускал бота.
  • 403 Forbidden: bot was blocked by the user — Пользователь остановил или заблокировал бота. В CRM требуется пометить данный chat_id как неактивный.
  • 429 Too Many Requests — Превышен лимит отправки сообщений. В массиве parameters придет поле retry_after с указанием количества секунд для паузы.

Интеграция в сервисный слой приложения

Для использования в реальных проектах оберните логику в сервисный класс, который берет токен из переменных окружения и изолирует бизнес-логику от сбоев Telegram API.

<?php

class TelegramNotifier
{
private string $botToken;

public function __construct(?string $botToken = null)
{
$this->botToken = $botToken ?? (string)getenv('TELEGRAM_BOT_TOKEN');
if (empty($this->botToken)) {
throw new InvalidArgumentException("Telegram bot token is not configured in environment");
}
}

/**
* Отправка уведомления о новой заявке с сайта
*/
public function notifyNewLead(int|string $chatId, string $clientName, string $phone, string $comment): bool
{
$template = "<b>Новая заявка с сайта!</b>

"
. "<b>Имя:</b> {name}
"
. "<b>Телефон:</b> {phone}
"
. "<b>Комментарий:</b> {comment}";

$text = formatTelegramHtmlTemplate($template, [
'name' => $clientName,
'phone' => $phone,
'comment' => $comment,
]);

try {
sendTelegramApiMessage($this->botToken, $chatId, $text, 'HTML');
return true;
} catch (Throwable $e) {
// Запись ошибки в системный лог
error_log(sprintf("[TelegramNotifier Error] Chat: %s | Message: %s", $chatId, $e->getMessage()));
return false;
}
}
}

Такой подход защитит основное приложение: даже если сервисы Telegram временно недоступны или клиент ввел специмволы в комментарии, форма на сайте отработает корректно, а факт ошибки зафиксируется в логах.

Разрабатывать отказоустойчивые вебхуки и интеграции бота с CRM удобно на базе платформы BotCreator.

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

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