HTML‑виджеты Telegram до выхода Bot API 10.4: отправляем через MTProto из Telethon и разбираем грабли слоя 233

—

от автора

В Telegram появились HTMLBubbles: виджеты, где бот присылает сообщение и внутри него работает маленькая веб‑страница со скриптом. В публичном Bot API 10.3 метода для них пока нет, но в протоколе MTProto они уже есть. Я отправил свою мини‑игру через Telethon, подменив клиенту слой протокола, и собрал по дороге все грабли: схему, которая расходится с документацией, бота, который падает от собственного канала, и сервер, который не пускает виджеты туда, куда документация разрешает. Ниже — как это устроено, рабочий код и таблицы с тем, что проверено руками.

Что такое виджет и что он умеет

Виджет (в обиходе HTMLBubbles) — это один HTML‑документ, который бот кладёт в сообщение как медиа. Клиент рисует его прямо в пузыре чата: не нужно открывать Mini App, не нужен хостинг, весь код едет в самом сообщении. Telegram выложил песочницу и документацию в открытый репозиторий TelegramMessenger/widget‑sandbox: в демо там форма заказа, опрос, приглашение на событие, крестики‑нолики и бегущий курс.

Внутри виджета объект TelegramWidget. Главное в нём:

  • request(data) — отправить боту строку до 64 байт и получить ответ до 1 КБ;

  • openLink(url) и openTelegramLink(url) — открыть ссылку (обычная — с подтверждением, t.me — сразу);

  • chatData и publicData — состояние, которое пишет только бот: одно привязано к сообщению в чате, второе видно везде, где показан виджет;

  • viewer, chatType, isForward — кто смотрит и где лежит сообщение.

    Я проверял всё на мини‑игре: трясёшь бутылку газировки, множитель растёт, вовремя открыл — забрал очки, передержал — крышку сорвало. Пять бутылок за партию и общая таблица рекордов, которую держит бот. Живой бот для проверки: @game_barulin_cv_bot. Если клиент ТГ показывает «unsupported message», его нужно обновить.

ошибка на старых клиентах

ошибка на старых клиентах
Мой демо-виджет в чате

Мой демо‑виджет в чате

Почему не Bot API

Последняя публичная версия Bot API на момент написания — 10.3, виджетов в ней нет. В документации песочницы описан Bot API 10.4 с методами sendWidget, answerWidgetQuery, editMessageWidgetData и апдейтом widget_query, но он доступен только на бета‑сервере. Обычный бот на aiogram или python‑telegram‑bot виджет сегодня не отправит.

Зато в MTProto виджеты есть с 233-го слоя. Бот может ходить в MTProto напрямую по своему токену: так работают Telethon и Pyrogram. Остаётся объяснить библиотеке, что это за новые типы.

Где брать схему: документация врёт

Первое, на что я наткнулся: схема в документации песочницы (docs/tgw‑format.md) не совпадает со схемой в исходниках Telegram Desktop (Telegram/SourceFiles/mtproto/scheme/api.tl).

В документации:

inputMediaWidget#44f6c04e flags:# allow_queries:flags.0?true shared_widget:flags.1?true width:int height:int html:string public_data:flags.2?string chat_data:flags.3?string = InputMedia;

Флаги сдвинуты, fwd_allow_queries пропал, а ID конструктора считается от строки схемы, так что он тоже разный. Сервер принимает вариант из Desktop. Мораль: схему берите из клиента, которым Telegram пользуется сам, а документацию читайте как описание поведения.

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

Тип

Зачем

inputMediaWidget#44f6c04e

сам виджет: HTML, ширина, высота, флаг allow_queries. Отправляется обычным messages.sendMedia

messageMediaWidget#2238f214

так виджет приходит обратно в составе сообщения

updateBotWidgetQuery#b373fe68

апдейт, когда виджет вызвал TelegramWidget.request()

messages.setBotWidgetQueryAnswer#f8cdec6a

ответ бота, на него 15 секунд, иначе у виджета TIMEOUT

Учим Telethon новым типам

В Telethon 1.45 этих типов нет. Объект, который мы отправляем, — это наследник TLObject с методом bytes(): четыре байта ID конструктора, дальше поля в порядке схемы. Строки сериализует сам Telethon через serializebytes.

import structfrom telethon import functionsfrom telethon.tl.tlobject import TLObjectclass InputMediaWidget(TLObject):    """inputMediaWidget#44f6c04e flags:# allow_queries:flags.0?true shared_widget:flags.1?true    width:int height:int html:string public_data:flags.2?string chat_data:flags.3?string = InputMedia"""    CONSTRUCTOR_ID, SUBCLASS_OF_ID = 0x44F6C04E, 0xFAF846F4    def __init__(self, html, width=430, height=580, allow_queries=True):        self.html, self.width, self.height, self.allow_queries = html, width, height, allow_queries    def to_dict(self):        return {"_": "InputMediaWidget", "width": self.width, "height": self.height}    def _bytes(self):        flags = 1 if self.allow_queries else 0        return struct.pack("<IIii", self.CONSTRUCTOR_ID, flags, self.width, self.height) \            + self.serialize_bytes(self.html)await client(functions.messages.SendMediaRequest(peer=chat_id, media=InputMediaWidget(html), message=""))

Входящие типы устроены наоборот: класс с методом from_reader, который читает поля из BinaryReader, и регистрация в словаре telethon.tl.alltlobjects.tlobjects. После этого апдейт можно ловить через events.Raw.

from telethon import eventsfrom telethon.tl import alltlobjectsfrom telethon.tl.tlobject import TLObject, TLRequestclass UpdateBotWidgetQuery(TLObject):    """updateBotWidgetQuery#b373fe68 flags:# query_id:long query_type:WidgetQueryType user_id:long peer:Peer    msg_id:int chat_instance:long chat_type:string data:bytes widget_id:flags.0?long wallet_card:flags.1?wallet.Card"""    CONSTRUCTOR_ID, SUBCLASS_OF_ID = 0xB373FE68, 0x9F89304E    @classmethod    def from_reader(cls, r):        o = cls()        flags = r.read_int()        o.query_id, o.query_type, o.user_id = r.read_long(), r.tgread_object(), r.read_long()        o.peer, o.msg_id, o.chat_instance = r.tgread_object(), r.read_int(), r.read_long()        o.chat_type, o.data = r.tgread_string(), r.tgread_bytes()        o.widget_id = r.read_long() if flags & 1 else None        o.wallet_card = r.tgread_object() if flags & 2 else None        return oclass SetBotWidgetQueryAnswerRequest(TLRequest):    """messages.setBotWidgetQueryAnswer#f8cdec6a flags:# query_id:long result:flags.0?string = Bool"""    CONSTRUCTOR_ID, SUBCLASS_OF_ID = 0xF8CDEC6A, 0xF5B399AC    def __init__(self, query_id, result):        self.query_id, self.result = query_id, result    def _bytes(self):        return struct.pack("<IIq", self.CONSTRUCTOR_ID, 1, self.query_id) + self.serialize_bytes(self.result)alltlobjects.tlobjects[UpdateBotWidgetQuery.CONSTRUCTOR_ID] = UpdateBotWidgetQuery# query_type - тоже новый тип: widgetQueryTypeData#16d8cf0b, регистрируется так же@client.on(events.Raw(UpdateBotWidgetQuery))async def on_query(update):    data = update.data.decode()    await client(SetBotWidgetQueryAnswerRequest(update.query_id, '{"ok": true}'))

На стороне виджета это одна строка:

TelegramWidget.request('s:' + score).then(a => showTop(JSON.parse(a.result)))

Виджет отправляется, а запросы не приходят

С этим кодом виджет отправился с первого раза. А вот нажатия до бота не доходили: игра вызывает request, у бота тишина, через 15 секунд виджет получает TIMEOUT.

Причина в слое. При подключении клиент сообщает серверу, на каком слое схемы он говорит (invokeWithLayer). Telethon 1.45 представляется клиентом 229-го слоя, а updateBotWidgetQuery сервер присылает только тем, кто на 233-м. Лечится одной строкой до создания клиента:

import telethon.client.telegrambaseclient as tbctbc.LAYER = 233

По сути, бот выходит в сеть с поддельным паспортом. Работает, но у этого есть цена.

Цена подмены: бот падает при добавлении в канал

Сказав серверу «я на 233-м слое», вы получаете от него в 233-м формате вообще всё, а не только виджеты. Типы, которые изменились между 229 и 233, Telethon не знает и падает на них с TypeNotFoundError.

Пока бот жил в личке, это не мешало. Проблема вылезла, когда я решил пускать в игру только подписчиков своего канала. Чтобы бот видел подписчиков, его нужно сделать админом канала. После этого при каждом запуске Telethon делает updates.getDifference, в ответе приходит объект канала, и бот падает:

telethon.errors.common.TypeNotFoundError: Could not find a matching Constructor ID for the TLObject that was supposed to be read with ID 7ad15240.

Docker перезапускает контейнер, бот снова падает на старте. Снаружи это выглядит так, будто бот просто перестал отвечать.

Чинится это за полчаса, если знать, где смотреть. 7ad15240 ищем в api.tl Telegram Desktop — это channel. Потом сравниваем с тем, как этот тип читает Telethon (inspect.getsource(types.Channel.from_reader)). Разница оказалась маленькой: во втором поле флагов появились биты 21 и 23 без данных и поле pinned_msg_id:flags2.22?int в самом конце. Значит, можно отдать чтение родному коду Telethon и дочитать хвост самому:

from telethon.tl import alltlobjects, typesclass Channel233(types.Channel):    """channel#7ad15240 (233) = channel из Telethon (229) + pinned_msg_id:flags2.22?int в конце"""    CONSTRUCTOR_ID = 0x7AD15240    @classmethod    def from_reader(cls, reader):        pos = reader.tell_position()        reader.read_int()              # flags        flags2 = reader.read_int()        reader.set_position(pos)        obj = types.Channel.from_reader(reader)        if flags2 & (1 << 22):            reader.read_int()          # pinned_msg_id        return objalltlobjects.tlobjects[Channel233.CONSTRUCTOR_ID] = Channel233

Тем же способом я заранее поправил messageReplyHeader: в 233-м слое в конце добавилось quote_rich:flags.14?Vector, и без парсера бот упал бы, как только кто‑то ответит на его сообщение.

Чтобы не ждать следующего падения, полезно один раз посмотреть, каких конструкторов 233-го слоя Telethon вообще не знает:

import refrom telethon.tl import alltlobjectsknown = set(alltlobjects.tlobjects)for line in open("api.tl", encoding="utf-8"):    if line.startswith("---functions"):        break    m = re.match(r"^([\w.]+)#([0-9a-f]+) .*= ([\w.]+);", line)    if m and int(m.group(2), 16) not in known:        print(m.group(3), m.group(1))

У меня таких набралось 95. Из тех, что реально прилетают боту, — channel, channelFull, userFull, messageReplyHeader, остальное — кошельки, темы форумов и сами виджеты. Полные карточки канала и пользователя бот сам не запрашивает, так что их я пока не трогал.

Всё, что не про виджеты, — через Bot API

Вывод после падения: всё, что не касается виджетов, надёжнее делать через обычный Bot API тем же токеном. Он не зависит от подмены слоя, и MTProto‑сессия ему не мешает.

Проверка подписки — getChatMember. Бот должен быть админом канала, иначе Telegram отвечает member list is inaccessible. Прав админу можно не давать никаких. Меню команд при вводе «/» — setMyCommands. С scope типа chat можно показать админские команды только себе. Описание бота, которое видно до /start, — setMyDescription. Я подставляю туда текущего лидера таблицы рекордов. И ещё одна мелочь эксплуатации: после падения я поставил на сервер скрипт‑сторож. Он раз в 2 минуты смотрит на статус контейнера и счётчик перезапусков и пишет мне в Telegram через тот же Bot API. Бот может лежать, а Bot API у сторожа всё равно работает.

Что умеет движок виджетов

Для проверки я сделал пробник: виджет, который выводит размеры, поддержку CSS, что есть в TelegramWidget, и умеет спрашивать бота. Результаты на Telegram Desktop и мобильных клиентах, ноябрь 2026:

Что

Как на деле

Размер

дин HTML до 32 КБ и до 4 КБ в сжатом виде. Сервер сжимает как PHP gzcompress уровня 9, проверять удобно через zlib.compress(html, 9)

Ширина

Равна ширине пузыря: 430 px на десктопе, около 362 px на телефоне. Верстать в процентах

Высота

Задаётся в inputMediaWidget, до 600 px

CSS

Есть flex, grid, min(), clamp(), SVG, CSS‑анимации. Нет vh/vmin: высота считается нулевой

Внешние ресурсы

Нет: ни картинок, ни шрифтов, ни сетевых запросов. Только data: и встроенный SVG

Скрипт

Больше 500 мс непрерывной работы — «Виджет остановлен»

Хранилище

localStorage своё у каждого сообщения, до 4 КБ

Тапы

Один тап разблокирует один вызов: request, openLink или openTelegramLink

Без тапа

Только в личке с ботом: один запрос при появлении, дальше не чаще раза в 5 секунд

Первая версия виджета была обрезана справа на мобилках, хотя на десктопе всё работало корректно из-за ограничений, которых я нигде не нашёл

Первая версия виджета была обрезана справа на мобилках, хотя на десктопе всё работало корректно из‑за ограничений, которых я нигде не нашёл

Про «Виджет остановлен» стоит рассказать отдельно. Я сделал брызги при взрыве бутылки: 30 капель, у каждой свой таймер. Виджет падал на первом же взрыве. Свёл всё в один общий цикл анимации — продержался до второй бутылки. Помогло только убрать постоянную анимацию на JS совсем: брызги стали статичной картинкой, а бутылка качается одним разовым setTimeout.

Вторая неочевидная вещь — правило одного тапа. По надписи на бутылке открывается канал через openTelegramLink, и этот тап уже потрачен. Отправить боту клик в том же обработчике нельзя: вызов молча не выполнится. Поэтому клик копится в localStorage и уходит боту со следующим запросом.

Куда виджет пускают

По документации виджет можно переслать в любой чат, бот может прислать его через инлайн‑режим куда угодно, а напрямую — в личку и в группы. Я проверил каждый путь.

Куда

Документация

На деле

Как проверял

Личка с ботом

можно

работает

—

Пересылка из лички

можно

нельзя

Отправил виджет во всех четырёх сочетаниях allow_queries и shared_widget: сервер у всех вернул сообщение с noforwards. Бот этот флаг не ставит, обычные сообщения того же бота пересылаются

Инлайн, @бот в другом чате

можно

INPUT_MEDIA_WIDGET_NOT_ALLOWED

messages.setInlineBotResults с inputBotInlineMessageWidget

Канал

пока нельзя

INPUT_MEDIA_WIDGET_NOT_ALLOWED

Бот — админ с правом публикации

Группа обсуждения канала

можно

INPUT_MEDIA_WIDGET_NOT_ALLOWED

Бот — участник группы

Похоже, на бете виджеты открыты только в личке с ботом. Для продвижения это главное ограничение: игра не разлетается сама, делиться ей можно только ссылкой на бота. В канал я публикую обычный пост с гифкой и кнопкой «Играть», которая ведёт в бота.

Итого

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

Если хотите попробовать сейчас, я собрал шаблон: бот на Telethon, все типы с парсерами для 233-го слоя, пробник движка и сторож, запуск через Docker. Подставляете свой HTML и свою логику ответа: github.com/fedor‑barulin/shipuchka‑widget‑bot.

ссылка на оригинал статьи https://habr.com/ru/articles/1105576/