Перейти к содержанию

Разбивка сообщений

Как одна HTML-страница превращается в несколько сообщений мессенджера, почему это происходит и что с этим можно сделать.

Документ описывает фактическое поведение кода, а не желаемое. Места, где поведение расходится со спецификацией или выглядит как дефект, помечены ⚠️ и собраны в бэклог.


Почему сообщение делится

Пять причин, из которых растут все остальные:

  1. Подпись к медиа ограничена 1024 символами, а текст сообщения — 4096. Если текст не влезает в подпись, фото и текст приходится разносить.
  2. Лимит длины текста. Telegram — 4096 символов, MAX — 4000.
  3. У сообщения ровно один reply_markup. Нельзя одновременно убрать reply-клавиатуру и показать inline-кнопки.
  4. Один тип контента на сообщение. Текст, фото, документ, геолокация и запрос контакта — это разные API-методы.
  5. Внутри сообщения нет блочной вёрстки. Заголовки, списки и таблицы в обычном parse_mode=HTML не поддерживаются, поэтому «страницу» верстают несколькими .bf-message.

Матрица разбивки

# Триггер Что получает пользователь Где в коде
1 Текст длиннее 4096 символов N сообщений, кнопки под последним telegram_api.py, html_chunks.py
2 Одно фото и подпись длиннее 1024 символов Фото без подписи, следом текст с кнопками core/bot_impl.py:230-277, botfarm/celery.py:670-778
3 Два и более фото Альбом без подписей, следом текст с кнопками core/bot_impl.py:279-312
4 Есть inline-кнопки И надо убрать reply-клавиатуру «⏳» → удаление → настоящее сообщение (3 вызова API) core/bot_impl.py:560-582, botfarm/celery.py:1174-1192
5 Несколько .bf-message на странице По сообщению на блок core/views/page_sender.py:975
6 .bf-location, .bf-request-contact Всегда отдельное сообщение page_sender.py:1149-1230, 1232-1248
7 MAX: подпись длиннее 4000 символов Фото, следом текст core/bot_impl.py:1037-1119
8 Instagram: любое количество фото N фото + текст = N+1 сообщений core/bot_impl.py
9 Instagram: вложение и текст Всегда раздельно core/bot_impl.py:1358-1396
10 Ошибка 400 при редактировании Удаление + отправка заново botfarm/celery.py:372-452

Случай №5 — единственный спроектированный. Остальные девять — обход технических ограничений.


Как это выглядит для пользователя

Фото теряет подпись (случай №2). Пришло фото, под ним пусто, следующим сообщением — текст. Порог — 1024 символа, как и у самой платформы (bot_impl.py:238, page_sender.py:559).

Раньше проверка считала UTF-8 байты, и кириллическая подпись разносилась на два сообщения уже примерно на 512 символах — вдвое раньше, чем требует Telegram. Исправлено.

Альбом всегда без подписей (случай №3). В sendMediaGroup подпись каждого элемента жёстко выставлена в пустую строку (bot_impl.py:292), текст уезжает отдельным сообщением с задержкой в секунду, чтобы не обогнать альбом.

Раньше второе сообщение отправлялось только при непустом reply_markup, поэтому страница с картинками и без единой кнопки теряла текст бесследно. Теперь сообщение уходит всегда, когда есть текст (bot_impl.py:302-312); если текста нет, но есть кнопки, подставляется невидимый символ — Telegram отклоняет sendMessage с пустым text.

Мигание «⏳» (случай №4). Возникает при переходе со страницы с .bf-request-contact на страницу с inline-кнопками. Telegram не даёт совместить ReplyKeyboardRemove и InlineKeyboardMarkup в одном reply_markup, поэтому код шлёт заглушку, удаляет её и отправляет настоящее сообщение.

Нарезка длинного текста (случай №1). Клавиатура оказывается под последним сообщением, id остальных сохраняются в ChatPage.extra_message_ext_id и удаляются при следующей навигации. Поле — CharField(64), туда помещается около шести id; если чанков окажется больше, лишние останутся в чате, о чём пишется предупреждение в лог.


Разбивка или обрезка

Это разные вещи, и на страничном пути сейчас работает вторая.

Нарезка реализована и корректна — html_chunks.split_html_text. Она режет текст только по границам «атомов» (тег, HTML-сущность или один символ), поэтому никогда не рвёт разметку, а теги, оставшиеся открытыми на границе, закрывает в конце чанка и переоткрывает в начале следующего вместе с атрибутами:

<b>…длинный текст</b>  →  <b>…часть 1</b>  +  <b>часть 2…</b>

invoke_telegram (telegram_api.py:87) вешает клавиатуру только на последнее сообщение и возвращает его ответ — именно оно становится message_ext_id страницы. Id остальных сообщений складываются в список extra_message_ids, попадают в ChatPage.extra_message_ext_id через запятую и удаляются при следующей навигации. Если какой-то чанк не отправился, отправка прерывается и наверх возвращается ответ упавшего чанка, а не мнимый успех последнего.

Нарезка включена: TGImpl.send_message больше не обрезает текст. Предохранитель MAX_MESSAGE_CHUNKS = 6 не даёт странице-аномалии превратиться в десятки сообщений — всё, что не влезло в шесть чанков, отбрасывается с предупреждением в лог.

⚠️ editMessageText резать не умеет. Одно сообщение нельзя отредактировать в три, поэтому страницу длиннее лимита _send_page_message отправляет новым сообщением и удаляет прежнее (page_sender.py:556-566). Для страниц короче 4096 ничего не меняется — они по-прежнему редактируются на месте, и переход по кнопке выглядит как раньше.

Сегодня нарезка реально срабатывает только у вызывающих в обход TGImpl (core/tg_update_processor.py:37,97,108,175,230,258, core/views/hook_tg.py:64,414, botfarm/celery.py:393,584,712,1186), а они шлют короткие константы.

Как считается длина

trim_text меряет длину в кодовых единицах UTF-16 (util.text_length) — ровно так же, как это делает Telegram. Для латиницы и кириллицы это совпадает с числом символов, эмодзи вне BMP занимают две единицы. Та же мера применяется к MAX, у которого лимит 4000 символов (POST /messages).

Раньше trim_text считал UTF-8 байты, из-за чего русский текст обрезался примерно вдвое раньше: на ~2045 символах в Telegram и ~1998 в MAX. Исправлено, лимиты вынесены в константы TG_TEXT_LIMIT и TG_CAPTION_LIMIT (bot_impl.py:30-31).


Расхождение платформ

Telegram MAX VK Instagram
Лимит текста 4096 символов 4000 символов не ограничивается кодом не ограничивается кодом
Фактическая обрезка 4096 символов 4000 символов нет нет
Подпись к медиа 1024 символа общий лимит текста — не поддерживается
Альбом sendMediaGroup, до 10 одно сообщение, до 12 вложений одно сообщение, до 10 вложений не реализован
Нарезка длинного текста есть нет, обрезка нет нет

VK: альбом одним сообщением. VKImpl.send_photos (bot_impl.py:750-779) загружает все картинки и отправляет их одним messages.send — до 10 вложений, вместе с текстом и клавиатурой. Разбивки у VK нет вообще. Лишние картинки сверх десяти отбрасываются с предупреждением в лог.

Раньше метод не был переопределён. Ошибки при этом не возникало: BaseImpl объявлен обычным классом без ABCMeta (bot_impl.py:94), поэтому @abc.abstractmethod (bot_impl.py:126-128) ничего не проверяет и вызов молча возвращал None — страница с двумя и более картинками у VK-бота не отправлялась совсем. Исправлено.

MAX: альбом одним сообщением. До 12 вложений вместе с клавиатурой и текстом; при наличии кнопок под картинки остаётся на одно место меньше. Лишние отбрасываются с предупреждением.

MAX: клавиатура переносится по лимитам платформы. До 7 кнопок в ряду и до 3, если в ряду есть link, open_app, request_geo_location или request_contact. Ряд из четырёх .bf-url кнопок раньше отклонялся платформой, теперь переносится на следующую строку.


Как уменьшить число сообщений уже сегодня

Рекомендации авторам страниц, без изменений в коде:

  • Держите подпись к фото в пределах ~500 символов кириллицей. Порог считается в байтах: 1024 байта это 1024 латинских или ~512 кириллических символа.
  • Не смешивайте .bf-request-contact и inline-кнопки на соседних экранах, если важна скорость отклика — переход между ними стоит трёх вызовов API вместо одного.
  • Одна картинка вместо альбома сохраняет подпись в том же сообщении (пока текст влезает в 1024 байта). Альбом подпись теряет всегда.
  • <blockquote> работает и в Telegram, и в MAX — им можно визуально отделить блок текста, не заводя второй .bf-message. Вариант <blockquote expandable> понимает только Telegram, MAX покажет обычную цитату.

См. также