Что означает can't parse entities
Telegram получил запрос, но не смог разобрать разметку текста. Например, в сообщении открыт и не закрыт тег, использован неподдерживаемый HTML-элемент или служебный символ MarkdownV2 попал в текст без экранирования. Это другая ситуация, чем таймаут подключения или отсутствие доступа к чату.
В ответе обычно есть error_code: 400 и пояснение в description. Продолжение фразы can't parse entities помогает сузить поиск: там может упоминаться тег, незакрытый фрагмент или зарезервированный символ. Но по одному числу 400 нельзя определить причину: ошибочный chat_id и другие некорректные параметры тоже могут привести к отказу.
1. Отделите ошибку текста от ошибки отправки
Проверьте фактические параметры sendMessage: итоговую строку text, значение parse_mode и наличие entities. В шаблоне всё может быть правильно, а ошибка возникает уже после подстановки данных или обрезки строки.
- Возьмите свой тестовый чат и подготовьте короткое сообщение с вымышленными данными.
- Для одной проверочной отправки уберите оба параметра форматирования: parse_mode и entities. Передавайте обычный текст без тегов.
- Если отправка прошла, добавьте минимальную разметку: например, только жирный заголовок.
- Возвращайте динамические поля по одному. Так будет видно, после какого значения запрос перестаёт проходить.
{
"chat_id": "123456789",
"text": "Новый заказ\nКлиент: Анна <VIP> & партнёры\nКомментарий: файл report_v2.pdf"
}
Без режима форматирования угловые скобки, амперсанд и подчёркивание в таком тексте не требуют HTML- или Markdown-экранирования. Если простой текст тоже отклонён, читайте новую ошибку: возможно, сначала нужно проверить получателя и доступ бота.
Каждый успешный диагностический запрос создаёт сообщение. Выполняйте эти проверки вручную в своём чате, а в журнал приложения сохраняйте код ошибки и название шаблона. Для воспроизведения обычно достаточно заменить персональные данные вымышленными, сохранив проблемные символы.
2. HTML: отделите теги шаблона от данных
Telegram поддерживает определённый набор HTML-тегов, а не произвольную веб-страницу. Например, для жирного заголовка подходит <b>, но перенос строки проще передать символом \n, а не тегом <br>. Перечень возможностей есть в официальном описании HTML.
Проблемный шаблон часто выглядит так:
$customer = 'Анна <VIP> & партнёры'; $text = "<b>Новый заказ</b>\nКлиент: ".$customer;
При parse_mode: HTML часть имени <VIP> будет воспринята как тег. Исправлять нужно способ вставки данных: имя клиента должно оставаться текстом независимо от его содержимого.
Создайте telegram-formatting.php. Как и другие PHP-файлы ниже, он должен начинаться с <?php; открывающий тег в блоках опущен.
declare(strict_types=1);
function telegramHtml(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
Эта функция подходит для обычных текстовых полей внутри 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 вторую функцию:
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 не проходит повторно по уже вставленным символам. Но если вызвать саму функцию дважды, получится двойное экранирование — её нужно применять один раз к исходным данным.
$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 и не повторяет отправку при сетевой ошибке.
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.
Что ещё часто ломает разметку
| Ситуация | Как исправить |
|---|---|
| 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 и время ожидания.
Как проверить исправление
- Без форматирования короткое уведомление приходит в нужный тестовый чат.
- Один и тот же шаблон работает с обычным именем, угловыми скобками, амперсандом, кавычками и апострофом.
- Для MarkdownV2 проверены скобки, подчёркивание, точка, восклицательный знак и обратная косая черта.
- Русский текст, emoji и переносы строк сохранились, данные не превратились в теги или ссылки.
- Шаблон не обрезается после форматирования; исходные данные не экранируются повторно.
- Ошибка подготовки уведомления не запускает бесконечные повторы очереди.
Большую часть проверок можно выполнить локально: тестировать чистые функции форматирования и массив параметров без сетевого запроса. Затем сделайте одну контрольную отправку каждого используемого шаблона в свой чат: локальный тест строки не заменяет проверку её отображения в 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 с помощниками выше и файл отправки:
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-экранирование транспорта и экранирование разметки решают разные задачи.
Источники
- Telegram: форматирование сообщений — HTML, MarkdownV2 и явные entities.
- MessageEntity — единицы измерения offset и length.
- PHP: htmlspecialchars — обработка текстовых значений для HTML.
- BotGate PHP SDK — отправка параметров и обработка исключений.
Помощники в статье рассчитаны на текстовые поля указанных шаблонов. Для URL, блоков кода и других контекстов используйте соответствующие правила формата.