Telegram Bot API 10.4: ИИ рисует интерфейс прямо в сообщении
9 ноября Telegram выпустил Bot API 10.4 с HTML-виджетами. Бот отправляет документ через sendWidget, а клиент рисует кнопки, графики и компактный интерфейс внутри пузыря сообщения. Для LLM-агента это способ отвечать полезным действием, а не очередной простынёй текста.
Автор — Максим Астраханцев, основатель ООО «Сидон», seedon.ru. Мы разрабатываем платформу «Оркестра» и разбираем, как дать её агентам компактный интерфейс в Telegram: до 32768 байт исходного HTML и 4096 байт после zlib-9. Ниже — четыре интерактивных учебных примера, код отправки и промпт для генератора.
Источники: официальный changelog 10.4, руководство по Widgets, параметры sendWidget. Проверено 10.11.2026.
Generative UI: модель выбирает представление, бот управляет состоянием
Человек спрашивает: «Как идут заказы?» Текстовая сводка заставляет мысленно сравнивать строки. Мини-дашборд показывает ту же информацию вкладками и графиком. При этом виджет не заменяет весь Telegram: rich messages и inline-кнопки уже решали часть задач, а Mini Apps остаются подходящими для больших приложений.
Надёжнее дать агенту несколько проверенных шаблонов и контракт состояния, чем разрешить произвольный HTML. Модель выбирает компонент, заполняет структуру, валидатор проверяет размер и поддерживаемые конструкции. Первый экран строится синхронно из уже переданных данных: обещание «всегда за 0.1 секунды» из этого не следует. Ответ LLM, доставка и скорость устройства измеряются отдельно.
HTML после отправки неизменяем. Это важный инженерный поворот: не перегенерируйте виджет ради нового счётчика. Бот обновляет chat_data и public_data, а интерфейс слушает события и меняет только нужные узлы. Новый макет требует нового виджета.
1. Дашборд: вкладки и график без открытия приложения
Две кнопки выбирают период, итог и столбцы пересчитываются локально. На устройстве Telegram нажатие может вызывать HapticFeedback.selectionChanged(). В нашем рабочем контуре @MultiCodeAstr_bot проверялся тот же способ отправки через sendWidget; здесь показан отдельный учебный шаблон, без внутренних данных и переписки.
Для статического среза не нужны запросы к боту вообще. Если период требует свежей информации, нажатие передаёт короткую команду через TelegramWidget.request(); расчёт и доступ к базе остаются на сервере. Не прячьте большой JSON в команде — передавайте идентификатор периода.
const W = window.TelegramWidget;
button.onclick = () => {
W?.HapticFeedback.selectionChanged();
redrawLocalChart('week');
};2. Конфигуратор: короткая команда вместо формы на десять экранов
Клиент выбирает количество кнопками «−» и «+». Виджет отправляет order:2, а бот проверяет пользователя, доступный продукт и серверную цену. Предварительный итог внутри пузыря — представление, а не основание для списания денег.
request() возвращает Promise с {result}. На стороне бота нужен answerWidgetQuery(widget_query_id=..., data=...) в течение 15 секунд. Ответ предназначен только вызвавшему виджету и сам не обновляет состояние сообщения. Для сохранённого результата отдельно вызывают editMessageWidgetData. Тяжёлую работу выносите из этого короткого пути ответа.
const W = window.TelegramWidget;
payButton.hidden = !W?.wallet;
payButton.onclick = () => {
W.request('pay:order42', {wallet:true})
.then(a => W.wallet.sendTransaction(JSON.parse(a.result), 'paid:order42'))
.then(a => show(a.result), e => show('Ошибка: '+e.code));
};TON Wallet открывает отдельное нативное подтверждение. Виджет не выполняет скрытый платёж. Транзакцию формирует бот: адрес получателя, сумма в nanotons строкой, срок действия. После уведомления sent_transaction сервер проверяет транзакцию в сети — отправителя, получателя, сумму и результат. Одного присланного хеша недостаточно. Повторный callback должен быть идемпотентным; после таймаута нельзя вслепую платить ещё раз.
В учебном превью выше ни настоящий кошелёк, ни перевод не вызываются: этот фрагмент показывает архитектуру интеграции, а реальные реквизиты и решения остаются на вашем backend.
3. Пульт роя: один макет, общая ревизия состояния
«План готов → код готов → проверяется» — удобный компактный монитор работы агентов. Создайте виджет первым sendWidget(..., is_shared=True), сохраните message.widget.id. Метод sendSharedWidget потом отправляет уже существующий общий объект по идентификатору: он не создаёт новый HTML-макет.
# Первый экземпляр, в production — личный чат с ботом
message = bot_api('sendWidget', chat_id=chat_id, html=html, is_shared=True,
public_data=json.dumps({'r':40,'agents':['План готов','Код','Ждёт']}))
widget_id = message['widget']['id']
bot_api('sendSharedWidget', chat_id=another_private_chat_id, widget_id=widget_id)
bot_api('editSharedWidgetData', widget_id=widget_id, cache_time=10,
public_data=json.dumps({'r':41,'agents':['План готов','Код готов','Проверяет']}))watchPublicData() включает клиентское получение общей информации, пока виджет на экране. Это polling с cache_time, а не произвольный WebSocket: по умолчанию 30 секунд, допустимы 5–3600 секунд. Обновление самого shared widget — не чаще раза в секунду. Персональное состояние конкретного сообщения меняется через editMessageWidgetData; общее — через editSharedWidgetData.
let newest = -1;
function apply(state) {
if (!state || state.r < newest) return;
newest = state.r;
render(state);
}
TelegramWidget.onEvent('publicDataChanged', () => {
apply(JSON.parse(TelegramWidget.publicData || 'null'));
});
TelegramWidget.watchPublicData();Запоздавший poll может принести ревизию 40 после ответа с ревизией 41. Проверка выше не даст интерфейсу «откатиться». Ревизию назначает сервер, а не LLM и не случайный таймер. Для реального управления агентами каждую команду дополнительно проверяйте по роли пользователя и разрешённым действиям.
4. Ephemeral-ответ в группе и новые имена ботов
Человек нажимает inline-кнопку в общем чате и получает личный расчёт на месте. В ephemeral_message_parameters указывают callback_query_id, не передавая receiver_user_id: Telegram адресует ответ именно нажавшему. Это отдельный механизм сообщений, не «HTML-виджеты уже в группах».
# В вашем существующем обработчике callback_query
bot_api('answerCallbackQuery', callback_query_id=query['id'])
bot_api('sendMessage', text='Ваш персональный расчёт готов',
ephemeral_message_parameters={'callback_query_id':query['id']})Нужно по-прежнему вызывать answerCallbackQuery. Исключены callback из channel direct messages chats; доставка ephemeral-сообщения не гарантируется, особенно пользователю офлайн. Для счёта или результата, который нельзя потерять, предусмотрите обычный сохранённый канал выдачи.
В том же релизе Premium-пользователю разрешили назначить вторичные usernames с окончаниями agent, ai, app, game двум своим ботам. Это суффиксы Telegram-имён, не DNS-домены «.ai» или «.agent»; доступность конкретного имени проверяется отдельно.
Песочница QuickJS: компактный контрол, а не браузер
| Ограничение | Значение | Следствие для агента |
|---|---|---|
| HTML, UTF-8 | 1–32768 B | Проверяем байты, не количество символов |
| zlib level 9 | ≤4096 B | Убираем библиотеки и тяжёлые data: картинки |
| chat_data / public_data | Каждое ≤1024 B | Передаём состояние, а не всю базу |
| request(data) | 1–64 B UTF-8 | Короткая команда + серверный контекст |
| Сеть | 0; NetworkDisabledError | Все внешние запросы делает бот |
| CPU / память | 300 мс/с; 8 МБ | Без тяжёлых циклов, 500 мс на одну задачу максимум |
| DOM / высота | 2000 узлов; 50–600 px | Небольшой экран, без внутреннего скролла |
Виджет имеет только ограниченное подмножество HTML/CSS/DOM. Полей ввода input, select и textarea нет; клавиатуры тоже нет. Не подключайте Chart.js из CDN. Для небольшого графика достаточно SVG или Canvas. Прозрачный корень и --tg-theme-* помогают вписаться в светлый и тёмный пузырь.
.horizontal-slider { touch-action: pan-y; }
.footer { padding-bottom: max(8px, env(safe-area-inset-bottom)); }
// Когда чат забрал вертикальный жест — отменяем начатое действие.
slider.addEventListener('pointercancel', cancelDrag);Вертикальный drag всегда отдаётся прокрутке чата. Горизонтальный control заявляет жест через touch-action: pan-y; pan-x его не захватывает. Нижний угол занят временем сообщения: учитывайте safe area, а не накрывайте штамп кнопкой. Заголовок и caption должны объяснять смысл и в клиенте без поддержки виджетов.
Копируемый Python: размер проверяем, HTML отправляем исходным
Ключ остаётся в окружении вашего backend. Этот код не запускает второй poller рядом с работающим webhook, не печатает token-bearing URL и ничего не отправляет при импорте. send_dashboard вызывается только вашим обработчиком после выбора chat_id.
"""Minimal sendWidget helper. Calls are examples: importing does not send anything."""
import json
import os
from pathlib import Path
import urllib.error
import urllib.request
import zlib
def checked_html(path):
raw=Path(path).read_bytes()
html=raw.decode('utf-8')
if b'\0' in raw or not 1<=len(raw)<=32768:
raise ValueError('HTML: 1..32768 UTF-8 bytes, no NUL')
if len(zlib.compress(raw,9))>4096:
raise ValueError('HTML: more than 4096 bytes after zlib-9')
return html
def bot_api(method,**payload):
token=os.environ['TELEGRAM_BOT_TOKEN']
# Keep the token out of command lines, source, logs and exception text.
request=urllib.request.Request('https://api.telegram.org/bot'+token+'/'+method,
data=json.dumps(payload,ensure_ascii=False).encode('utf-8'),
headers={'Content-Type':'application/json'})
try:
with urllib.request.urlopen(request,timeout=12) as response:
result=json.load(response)
except Exception:
raise RuntimeError('Bot API request failed; do not log credential-bearing URL') from None
if not result.get('ok'):raise RuntimeError('Bot API rejected request')
return result['result']
def send_dashboard(chat_id):
# Send plain text HTML, not zlib bytes or base64.
return bot_api('sendWidget',chat_id=chat_id,html=checked_html('dashboard.html'),
caption='Продажи: интерактивное учебное демо',height=330)
def answer_order(query):
# Invoke from your existing authenticated update handler. Never start a
# second getUpdates poller beside an existing webhook/poller.
if query.get('type',{}).get('type')!='data':raise ValueError('Unexpected query type')
command,qty=query['data'].split(':',1)
if command!='order' or not qty.isdecimal() or not 1<=int(qty)<=5:
raise ValueError('Invalid order')
# Authenticate query['from']['id'], check ACL and product price on the server.
price=int(qty)*200_000_000
answer=json.dumps({'quantity':int(qty),'amount_nanoton':str(price)})
return bot_api('answerWidgetQuery',widget_query_id=query['id'],data=answer)Для генератора нужен отдельный шлюз качества: белый список компонентов, предельные размеры, тестирование событий и явная обработка неуспешного запроса. После проверки размера результат всё равно нужно открыть в настоящем Telegram: браузерное превью не воспроизводит QuickJS, ограничения DOM и жесты приложения полностью.
Готовый системный промпт для LLM-генератора
Ты создаёшь компактный Telegram HTML widget, profile 1, а не Mini App.
Верни один HTML-документ с inline CSS/JS, без Markdown-ограждений.
Лимиты: 32768 байт UTF-8 raw и 4096 байт zlib level 9; NUL запрещён.
Никаких внешних ресурсов, fetch, XHR, WebSocket, навигации, библиотек и секретов.
QuickJS: 8 МБ RAM; 300 мс JS/с; задача не дольше 500 мс. Не перестраивай DOM на каждом кадре.
Только поддержанные элементы; ввод через button и горизонтальные жесты, без input/select/textarea.
Палитра через --tg-theme-*, корневой фон прозрачный, системный шрифт.
Проверь 320 и 430 px, высоту меньше 560 px, обе темы и отсутствие сетевых обращений.
Учти env(safe-area-inset-bottom), нижний угол с временем и ведущие 20 px системного жеста.
Для горизонтального drag ставь touch-action:pan-y и обрабатывай pointercancel.
Первое состояние нарисуй синхронно. TelegramWidget может отсутствовать в браузерном превью.
Проверяй allow_queries и доступность wallet. request(data) — 1..64 байта UTF-8, один запрос в полёте.
Для долгой работы бот быстро подтверждает запрос, а состояние меняет отдельным edit.
Показывай pending и ошибки BUSY, RATE_LIMITED, TIMEOUT, NOT_ALLOWED, FAILED, INVALID_ANSWER.
Ответ answerWidgetQuery приходит как {result}; сам по себе он не меняет chatData/publicData.
Shared state: watchPublicData(), publicDataChanged, монотонная revision r; старое состояние игнорируй.
В продакшене 10.4 виджеты только в личном чате. Группы/inline/пересылки — test server.
Контракт приложения: public_data={r:integer,agents:[string,string,string]}, chat_data=null.
Поддержи ручную проверку размеров zlib-9; не утверждай, что браузерное превью проверило QuickJS.Что взять в первый продакшен-пилот
Начните с одного read-only дашборда в личном чате. Не давайте модели самостоятельно назначать цены, права или реквизиты. Сначала докажите, что фиксированный шаблон корректно показывает новое состояние; затем подключите короткие команды. Платёж и управление агентами — отдельные проверяемые переходы, а не побочный эффект красивого интерфейса.
В статье — локальные учебные примеры. Отправка сообщений, управление настоящим роем и транзакции из этих превью отключены. Для своей платформы используйте подходящий контракт и проверенные данные, а не копируйте демонстрационные числа в реальную аналитику.
Частые вопросы (FAQ)
Чем HTML-виджет Telegram отличается от Mini App?
Виджет находится внутри сообщения, запускает компактный HTML и JavaScript в QuickJS и не обращается в интернет. Mini App открывает размещённое на сервере веб-приложение в WebView. Виджет удобен для одного действия или короткой сводки. Сложные формы, клавиатурный ввод и полноценную навигацию по-прежнему лучше выносить в Mini App.
Можно ли уже отправлять виджеты в группы и каналы?
На 10 ноября 2026 года production поддерживает виджеты только в личном чате с ботом; пересылка таких сообщений защищена. Группы, inline-режим и пересылки доступны на тестовом сервере. Ephemeral-ответ по callback_query_id — отдельная возможность: персональное текстовое сообщение в группе не означает, что групповые HTML-виджеты уже включены в production.
Нужно ли отправлять в sendWidget сжатый HTML?
Нет. В поле html передают исходный текст в UTF-8, а сервер Telegram самостоятельно выполняет сжатие. Перед отправкой проверьте оба ограничения: не более 32768 байт исходника и 4096 байт после zlib уровня девять. Проверка размера не заменяет проверку поддерживаемого DOM, отсутствия сетевых вызовов и работы виджета в настоящем клиенте Telegram.
Нужен такой интерфейс вашему ИИ-боту?
Разбираем архитектуру, показываем код и делимся шаблонами в @seedon_ru. Для внедрения — AI-диспетчер Seedon и интеграции API и CRM.