Перейти к содержанию
12 минут чтения
Тема: Обработка ошибок Telegram Bot API

Ошибка Telegram can't parse entities: HTML и MarkdownV2 без ошибок

Уведомление с тестовыми данными отправляется, а после подстановки имени клиента или комментария Telegram отвечает can't parse entities. На примере сообщения о новом заказе разберём, как найти проблемный фрагмент, правильно подготовить HTML или MarkdownV2 и отправить его напрямую в Telegram на PHP. Те же правила пригодятся для сообщений из Laravel, CRM и обработчика webhook.

Что означает can't parse entities

Telegram получил запрос, но не смог разобрать разметку текста. Например, в сообщении открыт и не закрыт тег, использован неподдерживаемый HTML-элемент или служебный символ MarkdownV2 попал в текст без экранирования. Это другая ситуация, чем таймаут подключения или отсутствие доступа к чату.

В ответе обычно есть error_code: 400 и пояснение в description. Продолжение фразы can't parse entities помогает сузить поиск: там может упоминаться тег, незакрытый фрагмент или зарезервированный символ. Но по одному числу 400 нельзя определить причину: ошибочный chat_id и другие некорректные параметры тоже могут привести к отказу.

1. Отделите ошибку текста от ошибки отправки

Проверьте фактические параметры sendMessage: итоговую строку text, значение parse_mode и наличие entities. В шаблоне всё может быть правильно, а ошибка возникает уже после подстановки данных или обрезки строки.

  1. Возьмите свой тестовый чат и подготовьте короткое сообщение с вымышленными данными.
  2. Для одной проверочной отправки уберите оба параметра форматирования: parse_mode и entities. Передавайте обычный текст без тегов.
  3. Если отправка прошла, добавьте минимальную разметку: например, только жирный заголовок.
  4. Возвращайте динамические поля по одному. Так будет видно, после какого значения запрос перестаёт проходить.
JSON · параметры проверочного sendMessage, chat_id вымышлен
{
  "chat_id": "123456789",
  "text": "Новый заказ\nКлиент: Анна <VIP> & партнёры\nКомментарий: файл report_v2.pdf"
}

Без режима форматирования угловые скобки, амперсанд и подчёркивание в таком тексте не требуют HTML- или Markdown-экранирования. Если простой текст тоже отклонён, читайте новую ошибку: возможно, сначала нужно проверить получателя и доступ бота.

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

2. HTML: отделите теги шаблона от данных

Telegram поддерживает определённый набор HTML-тегов, а не произвольную веб-страницу. Например, для жирного заголовка подходит <b>, но перенос строки проще передать символом \n, а не тегом <br>. Перечень возможностей есть в официальном описании HTML.

Проблемный шаблон часто выглядит так:

PHP · пример ошибки, не используйте для отправки
$customer = 'Анна <VIP> & партнёры';
$text = "<b>Новый заказ</b>\nКлиент: ".$customer;

При parse_mode: HTML часть имени <VIP> будет воспринята как тег. Исправлять нужно способ вставки данных: имя клиента должно оставаться текстом независимо от его содержимого.

Создайте telegram-formatting.php. Как и другие PHP-файлы ниже, он должен начинаться с <?php; открывающий тег в блоках опущен.

PHP · telegram-formatting.php, HTML
declare(strict_types=1);

function telegramHtml(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

Эта функция подходит для обычных текстовых полей внутри HTML-шаблона. Передавайте ей исходное значение, которое ещё не экранировали. Теги, написанные разработчиком, оставляйте отдельно:

PHP · собираем HTML-сообщение
$customer = 'Анна <VIP> & партнёры';
$comment = 'Размер 2 < 3, скидка 5%';

$text = '<b>Новый заказ №42</b>'."\n"
    .'Клиент: '.telegramHtml($customer)."\n"
    .'Комментарий: '.telegramHtml($comment);

$payload = [
    'chat_id' => $chatId,
    'text' => $text,
    'parse_mode' => 'HTML',
];

Экранируйте динамические значения, а не всю готовую строку с тегами: иначе вместо жирного заголовка пользователь увидит буквальный текст разметки. Не применяйте здесь htmlentities() с набором HTML5-сущностей: поддержка именованных сущностей у Telegram ограничена, тогда как используемый выше htmlspecialchars подходит для этого примера.

Для ссылок нужна отдельная проверка URL и его допустимой схемы; экранирование не делает произвольный адрес доверенным. В примере динамические значения вставляются только как видимый текст, не как теги, атрибуты или адреса ссылок.

3. MarkdownV2: экранируйте специальные символы в данных

MarkdownV2 и старый режим Markdown — разные форматы. Если в запросе указан MarkdownV2, пример для другого режима может не подойти. Обычное имя файла report_v2.pdf уже содержит символы, значимые для этого формата.

Для динамического обычного текста добавьте в тот же telegram-formatting.php вторую функцию:

PHP · telegram-formatting.php, MarkdownV2
function telegramMarkdownText(string $value): string
{
    $backslash = '\\';
    $special = str_split('_*[]()~`>#+-=|{}.!');
    $special[] = $backslash;
    $replacements = [];

    foreach ($special as $character) {
        $replacements[$character] = $backslash.$character;
    }

    return strtr($value, $replacements);
}

Функция обрабатывает в том числе обратную косую черту. Замена через массив strtr не проходит повторно по уже вставленным символам. Но если вызвать саму функцию дважды, получится двойное экранирование — её нужно применять один раз к исходным данным.

PHP · собираем MarkdownV2-сообщение
$customer = 'Анна (VIP)';
$filename = 'report_v2.pdf';

$text = '*Новый заказ*'."\n"
    .'Клиент: '.telegramMarkdownText($customer)."\n"
    .'Файл: '.telegramMarkdownText($filename);

$payload = [
    'chat_id' => $chatId,
    'text' => $text,
    'parse_mode' => 'MarkdownV2',
];

Звёздочки заголовка принадлежат шаблону, а символы в имени и названии файла — данным. Такой подход не превращает пользовательский ввод в часть разметки. Если добавить в статический текст шаблона точку, восклицательный знак или другой специальный символ, его тоже нужно подготовить по правилам MarkdownV2.

У этого помощника есть граница применения: он не предназначен для содержимого code/pre и URL внутри Markdown-ссылок. В этих местах действуют другие правила экранирования. Не вставляйте результат функции в произвольное место сложного шаблона; отдельные контексты описаны в документации MarkdownV2.

Отправка HTML-сообщения на PHP без SDK

Нужны PHP 8.2+ с расширением curl, токен вашего бота и ID собственного тестового чата. Сохраните токен в переменной окружения TELEGRAM_BOT_TOKEN, адресата — в TELEGRAM_CHAT_ID. PHP читает их через getenv; файл .env сам по себе не загружается.

Рядом с telegram-formatting.php из раздела HTML создайте send-formatted.php. Пример делает одну попытку, показывает код и описание отказа Telegram и не повторяет отправку при сетевой ошибке.

PHP · send-formatted.php, прямой Telegram Bot API
declare(strict_types=1);

require __DIR__.'/telegram-formatting.php';

$token = trim((string) getenv('TELEGRAM_BOT_TOKEN'));
$chatId = trim((string) getenv('TELEGRAM_CHAT_ID'));
if ($token === '' || $chatId === '') {
    throw new RuntimeException('Задайте TELEGRAM_BOT_TOKEN и TELEGRAM_CHAT_ID.');
}

$text = '<b>Новый заказ №42</b>'."\n"
    .'Клиент: '.telegramHtml('Анна <VIP> & партнёры')."\n"
    .'Комментарий: '.telegramHtml('Файл report_v2.pdf, доставка после 18:00');

$payload = [
    'chat_id' => $chatId,
    'text' => $text,
    'parse_mode' => 'HTML',
];

$curl = curl_init('https://api.telegram.org/bot'.$token.'/sendMessage');
if ($curl === false) {
    throw new RuntimeException('Не удалось создать HTTP-запрос.');
}
curl_setopt_array($curl, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
    CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CONNECTTIMEOUT => 5,
    CURLOPT_TIMEOUT => 20,
]);
$body = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
unset($curl);

if (!is_string($body)) {
    fwrite(STDERR, "Ответ не получен. Результат отправки неизвестен; не повторяйте её автоматически.\n");
    exit(1);
}

try {
    $response = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
    fwrite(STDERR, "Получен ответ без корректного JSON, HTTP $status. Проверьте маршрут запроса.\n");
    exit(1);
}

if (!is_array($response)) {
    fwrite(STDERR, "Неожиданный формат ответа, HTTP $status.\n");
    exit(1);
}
if ($status >= 200 && $status < 300 && ($response['ok'] ?? null) === true) {
    echo "Telegram подтвердил отправку. Проверьте текст и оформление.\n";
} else {
    $code = is_int($response['error_code'] ?? null) ? (string) $response['error_code'] : 'не указан';
    $description = is_string($response['description'] ?? null) ? $response['description'] : 'без описания';
    fwrite(STDERR, "HTTP $status; код Telegram: $code; $description\n");
    exit(1);
}

Запуск php send-formatted.php действительно отправляет одно уведомление. Заголовок должен быть жирным, а имя — отображаться как «Анна <VIP> & партнёры». Для MarkdownV2 замените сборку текста и parse_mode на вариант выше.

Здесь json_encode применяется ко всему массиву запроса. Не вызывайте его отдельно для значения text: JSON-кодирование транспорта и экранирование разметки — разные операции. В своей HTTP-библиотеке также проверьте автоматические повторы и возможность прочитать тело ответа при HTTP 400.

Что ещё часто ломает разметку

Ошибки подготовки текста Telegram
СитуацияКак исправить
HTML-шаблон отправлен с MarkdownV2Задавайте режим рядом со сборкой сообщения, чтобы шаблон и parse_mode не расходились.
В данные попали <, >, & или спецсимволы MarkdownЭкранируйте каждое динамическое поле для выбранного контекста один раз.
В Telegram скопировали HTML из редактора сайтаПодготовьте отдельный шаблон с поддерживаемыми тегами. CSS и произвольная веб-вёрстка здесь не работают.
Строку обрезали после добавления разметкиМожно потерять закрывающий тег или разрезать escape-последовательность. Ограничивайте исходные поля до форматирования, учитывайте UTF-8 и итоговую длину.
Использовали addslashes или strip_tagsЭти функции не заменяют экранирование для Telegram. Выбирайте обработку по формату и месту вставки.
Убрали parse_mode, но оставили entitiesФорматирование по-прежнему передаётся явно. Для проверки обычного текста уберите оба параметра.

Если приложение формирует entities напрямую, нужны корректные диапазоны в единицах UTF-16. Длина в байтах PHP и количество видимых символов не заменяют такие смещения, особенно с emoji. Для простого уведомления легче поддерживать небольшой HTML-шаблон; ручные entities имеют смысл, когда приложение уже умеет правильно вычислять эти диапазоны.

Не исправляйте текст удалением всех знаков пунктуации или пользовательских символов. Комментарий, название файла и артикул должны дойти без искажения. Если оформление не нужно, отправляйте исходный текст без parse_mode и entities.

Нужно ли повторять запрос после ошибки разметки

Тот же текст с тем же режимом снова будет отклонён. В очереди это ошибка подготовки сообщения: сохраните причину, остановите автоматические попытки этого задания и исправьте шаблон или данные.

Можно заранее предусмотреть отправку обычного текста как отдельную политику приложения. Но сначала должен быть подтверждён именно отказ из-за разметки, а запасной текст нужно собрать из исходных полей без тегов. Любой код 400 не является основанием для такого действия: отказ может быть связан с адресатом, правами или другими параметрами.

Не запускайте запасную отправку после таймаута или обрыва соединения: первое сообщение могло уже попасть в чат. Ограничения частоты тоже обрабатываются отдельно — см. 429 и время ожидания.

Как проверить исправление

  1. Без форматирования короткое уведомление приходит в нужный тестовый чат.
  2. Один и тот же шаблон работает с обычным именем, угловыми скобками, амперсандом, кавычками и апострофом.
  3. Для MarkdownV2 проверены скобки, подчёркивание, точка, восклицательный знак и обратная косая черта.
  4. Русский текст, emoji и переносы строк сохранились, данные не превратились в теги или ссылки.
  5. Шаблон не обрезается после форматирования; исходные данные не экранируются повторно.
  6. Ошибка подготовки уведомления не запускает бесконечные повторы очереди.

Большую часть проверок можно выполнить локально: тестировать чистые функции форматирования и массив параметров без сетевого запроса. Затем сделайте одну контрольную отправку каждого используемого шаблона в свой чат: локальный тест строки не заменяет проверку её отображения в Telegram.

Если вы используете BotGate

При работе через BotGate ошибка метода Telegram возвращается с HTTP 400. В botgate/sdk её можно перехватить как TelegramException: telegramErrorCode() содержит код Telegram, getMessage() — описание. Шлюз не исправляет разметку и не выбирает parse_mode за приложение.

Функции форматирования остаются теми же. Меняется способ отправки: вместо прямого curl-запроса используйте клиент SDK. TelegramException с HTTP 400 не запускает встроенные повторы; для контроля остальных ошибок пример отключает их через maxRetries: 0.

Для полного примера нужны PHP 8.2+, botgate/sdk и тестовый получатель в TELEGRAM_CHAT_ID. Установка: composer require botgate/sdk.

Возьмите bootstrap.php из руководства PHP. Он создаёт $bot с maxRetries: 0 и функцию $requiredEnv. Рядом сохраните telegram-formatting.php с помощниками выше и файл отправки:

PHP · send-formatted-botgate.php
declare(strict_types=1);

use BotGate\Exception\BotGateException;
use BotGate\Exception\TelegramException;

require __DIR__.'/bootstrap.php';
require __DIR__.'/telegram-formatting.php';

$chatId = trim($requiredEnv('TELEGRAM_CHAT_ID'));
$customer = 'Анна <VIP> & партнёры';
$comment = 'Файл report_v2.pdf, доставка после 18:00';

$text = '<b>Новый заказ №42</b>'."\n"
    .'Клиент: '.telegramHtml($customer)."\n"
    .'Комментарий: '.telegramHtml($comment);

try {
    $bot->call('sendMessage', [
        'chat_id' => $chatId,
        'text' => $text,
        'parse_mode' => 'HTML',
    ]);
    echo "Telegram подтвердил отправку. Проверьте текст и оформление.\n";
} catch (TelegramException $error) {
    fwrite(STDERR, 'Telegram '.($error->telegramErrorCode() ?? 'без кода').': '.$error->getMessage()."\n");
    exit(1);
} catch (BotGateException $error) {
    fwrite(STDERR, "Отправка не подтверждена. Уточните результат перед повтором.\n");
    exit(1);
}

Запуск php send-formatted-botgate.php действительно отправляет одно уведомление. При успехе заголовок должен быть жирным, а имя — отображаться как «Анна <VIP> & партнёры», без потери символов. Для проверки MarkdownV2 замените сборку текста и parse_mode на вариант из раздела MarkdownV2.

SDK сам сериализует массив запроса. Не передавайте в text результат json_encode($text): это добавит ещё один слой JSON к содержимому сообщения. JSON-экранирование транспорта и экранирование разметки решают разные задачи.

Источники

Помощники в статье рассчитаны на текстовые поля указанных шаблонов. Для URL, блоков кода и других контекстов используйте соответствующие правила формата.