Сообщения

HTML и Markdown

Разбираем HTML и MarkdownV2 в Telegram Bot API: как экранировать пользовательский ввод, оформлять ссылки, спойлеры, блоки кода и ловить ошибки парсинга. Примеры на PHP с cURL.

Telegram Bot API поддерживает два режима разметки в поле text метода sendMessage (и в caption при отправке медиа): HTML и MarkdownV2. Режим задаётся параметром parse_mode. Если его не передать — текст уйдёт как есть, Telegram не экранирует и не парсит ничего.

Подготовка: хелпер telegramApi()

Используем функцию telegramApi(), которую мы сделали в главе Первый запрос: getMe. Она делает POST к api.telegram.org, проверяет HTTP-код, json_last_error() и поле ok в ответе. Здесь только вызываем её — реализацию не копируем.

<?php
// reminder: telegramApi() из главы first-request
// function telegramApi(string $token, string $method, array $params = []): array { ... }

$token = getenv('TELEGRAM_BOT_TOKEN') ?: exit('TELEGRAM_BOT_TOKEN not set');
$chatId = 123456789; // замените на реальный chat_id (см. главу chat-user-ids)

HTML-режим: самый предсказуемый для веб-разработчика

Поддерживаемые теги: <b>, <i>, <u>, <s>, <strike>, <del>, <code>, <pre>, <a href="...">, <tg-spoiler>. Перенос строки — обычный \n. Тег <br> не работает — он уйдёт в текст буквально.

Правило №1: всегда экранируйте пользовательский ввод через htmlspecialchars(). Иначе имя <script>alert(1)</script> сломает разметку или вернёт ошибку 400 Bad Request: can't parse entities.

<?php
$safe = static fn(string $s): string =
htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

$name = "Ivan <script>alert('xss')</script>";
$comment = "Нормальный комментарий\nсо строкой";

$response = telegramApi($token, 'sendMessage', [
'chat_id' => $chatId,
'parse_mode' => 'HTML',
'text' => "<b>Новая заявка</b>\n"
. "Имя: {$safe($name)}\n"
. "Комментарий: {$safe($comment)}",
]);

// Как посмотреть ответ API в терминале:
var_dump($response);
// или для читаемости:
echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);

Пример успешного ответа API:

{
"ok": true,
"result": {
"message_id": 42,
"from": {
"id": 987654321,
"is_bot": true,
"first_name": "MyBot",
"username": "mybot"
},
"chat": {
"id": 123456789,
"first_name": "Ivan",
"type": "private"
},
"date": 1700000000,
"text": "<b>Новая заявка</b>\nИмя: Ivan <script>alert('xss')</script>\nКомментарий: Нормальный комментарий\nсо строкой"
}
}

Поле ok — признак успеха. В result приходит объект Message с message_id (нужен для редактирования/удаления, см. главу Редактирование и удаление) и экранированным текстом, как его увидел Telegram.

Ссылки и спойлеры

Используем функцию telegramApi() из главы Первый запрос: getMe.

<?php
$orderId = 123;
$details = "Секретная информация для клиента";

$response = telegramApi($token, 'sendMessage', [
'chat_id' => $chatId,
'parse_mode' => 'HTML',
'text' => "<a href=\"https://example.com/order/{$orderId}\">Заказ #{$orderId}</a>\n"
. "<tg-spoiler>{$safe($details)}</tg-spoiler>",
]);

if (!$response['ok']) {
// Ошибка парсинга — смотрим description
error_log('sendMessage failed: ' . ($response['description'] ?? 'unknown'));
}

Типичная ошибка невалидного HTML (забыли экранировать < в пользовательском тексте):

{
"ok": false,
"error_code": 400,
"description": "Bad Request: can't parse entities: Character '<' is reserved and must be escaped with the corresponding HTML entity"
}

В description часто пишут номер символа (byte offset) — это помогает найти проблему в длинном тексте.

MarkdownV2: мощнее, но капризнее

MarkdownV2 поддерживает цитаты (>), списки (-, 1.), inline-код (`code`), блоки кода (```php ... ```). Но он требует: любой спецсимвол _ * [ ] ( ) ~ ` > # + - = | { } . ! в тексте должен быть экранирован обратным слэшем \.

Используем функцию telegramApi() из главы Первый запрос: getMe.

<?php
$escapeV2 = static fn(string $s): string =
addcslashes($s, '_*[]()~`>#+-=|{}.!');

$name = "O'Reilly & Co.";
$sum = "1_000.50";
$orderId = 456;

$response = telegramApi($token, 'sendMessage', [
'chat_id' => $chatId,
'parse_mode' => 'MarkdownV2',
'text' => "*Заказ \\#{$orderId}*\n"
. "Клиент: {$escapeV2($name)}\n"
. "Сумма: {$escapeV2($sum)} руб.",
]);

var_dump($response);

Если забыть экранировать _ в сумме 1_000.50 — Telegram вернёт 400: can't parse entities и укажет позицию ошибки. В MarkdownV2 даже точка в конце предложения должна быть экранирована, если она не часть разметки.

Блоки кода с подсветкой синтаксиса

В HTML-режиме используйте <pre language="php">...</pre> — Telegram подсветит синтаксис. Внутри <pre> экранирование не нужно (тег защищает содержимое), но внешние данные перед вставкой в <pre> всё равно пропускайте через htmlspecialchars на случай </pre> в тексте.

Используем функцию telegramApi() из главы Первый запрос: getMe.

<?php
$code = '<?php echo "Hello"; ?>';

$response = telegramApi($token, 'sendMessage', [
'chat_id' => $chatId,
'parse_mode' => 'HTML',
'text' => "Пример кода:\n<pre language=\"php\">{$safe($code)}</pre>",
]);

echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);

В MarkdownV2 блоки кода оформляются тройными обратными кавычками с указанием языка: ```php\ncode\n```. Внутри экранирование не требуется.

Что выбрать: чек-лист

  • HTML — если в сообщении есть пользовательские данные (имена, комментарии, адреса). Экранирование одной функцией htmlspecialchars, меньше сюрпризов.
  • MarkdownV2 — если текст полностью под вашим контролем (статические шаблоны, хелпы, меню) и нужны списки/цитаты/моноширинный код в строке.
  • Без parse_mode — простые уведомления одной строкой. \n всё равно работают как переносы.

Типичные ошибки и как их ловить

  • Забыли экранировать — 400 Bad Request: can't parse entities. Смотрите description в ответе, там часто пишут номер символа (byte offset).
  • <br> вместо \n — тег уйдёт в текст буквально. В HTML/Markdown перенос только \n.
  • Передали и parse_mode, и entities — конфликт параметров, вернёт 400.
  • Текст длиннее 4096 символов — нужно резать на части или использовать sendDocument с файлом (см. главу Фото, документы, media group).

Для отладки всегда логируйте полный ответ:

Используем функцию telegramApi() из главы Первый запрос: getMe.

<?php
$response = telegramApi($token, 'sendMessage', [
'chat_id' => $chatId,
'text' => 'Test',
]);

if (!$response['ok']) {
// В description часто есть подсказка: "can't parse entities at byte offset 42"
error_log('Telegram API error: ' . json_encode($response, JSON_UNESCAPED_UNICODE));
// Можно прислать админу в отдельный чат
$adminChatId = 999999999;
telegramApi($token, 'sendMessage', [
'chat_id' => $adminChatId,
'text' => "⚠️ Ошибка отправки: {$response['description']}",
]);
}

Дальше: Фото, документы, media group.